Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Module Hooks resolve is called without import attributes when importing the wrong symbol #56656

Open
dgp1130 opened this issue Jan 19, 2025 · 0 comments

Comments

@dgp1130
Copy link

dgp1130 commented Jan 19, 2025

Version

v23.6.0

Platform

This is running in WSL:

Linux Gryphon 5.15.167.4-microsoft-standard-WSL2 #1 SMP Tue Nov 5 00:21:55 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux

In the Windows environment:

Microsoft Windows NT 10.0.19045.0 x64

Subsystem

No response

What steps will reproduce the bug?

To reproduce, create a module hook which generates a synthetic file using a specific import attribute for a non-existent file path. When a file is imported with that attribute, but imports an non-existent symbol, Node re-resolve the file without the import condition and errors because the file doesn't exist.

We can reproduce this with the following minimal program:

// package.json

{
  "name": "module-hooks",
  "version": "0.0.0",
  "type": "module"
}
// index.js

// `dep.js` does not exist on the file system.
// File content is generated by module hook.

// Importing `foo` works.
// import { foo as data } from './dep.js' with { type: 'hook' };

// Importing `bar` fails. It causes a re-resolve which fails to find the file.
import { bar as data } from './dep.js' with { type: 'hook' };

console.log(data);
// module-hook.js

import { register } from 'module';

register('./hooks.js', import.meta.url);
// hooks.js

export async function resolve(specifier, context, nextResolve) {
  const url = new URL(specifier, context.parentURL).href;
  console.error('Resolve', { specifier, context, url });

  // Ignore anything that's not `with { type: 'hook' }`.
  if (context.importAttributes.type !== 'hook') return await nextResolve(specifier, context);

  return {
    url,
    format: 'module',
    shortCircuit: true,
  };
}

export async function load(url, context, nextLoad) {
  console.error('Load', { url, context });

  // Ignore anything that's not `with { type: 'hook' }`.
  if (context.importAttributes.type !== 'hook') return await nextLoad(url, context);

  return {
    format: 'module',
    shortCircuit: true,
    source: `
// Generate synthetic file which exports only `foo`.
export const foo = 'foo';
    `.trim(),
  };
}

Then execute with module-hook.js imported first to set up the hooks correctly:

node --import ./module-hook.js ./index.js

How often does it reproduce? Is there a required condition?

100% consistent reproduction.

What is the expected behavior? Why is that the expected behavior?

I expected a typical "module does not provide export" error, given that the dynamically generated deps.js exports only foo, not bar.

SyntaxError: The requested module './dep.js' does not provide an export named 'bar'

What do you see instead?

I actually get a module resolution error, claiming the file wasn't found at all.

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/doug/Source/module-hooks/dep.js' imported from /home/doug/Source/module-hooks/index.js

Looking at the logs from the program, we can see that resolve was called on dep.js twice. Once with type: 'hook' and once without.

$ node --import ./module-hook.js ./index.js
Resolve {
  specifier: 'file:///home/doug/Source/module-hooks/index.js',
  context: {
    conditions: [ 'node', 'import', 'module-sync', 'node-addons' ],
    importAttributes: {},
    parentURL: undefined
  },
  url: 'file:///home/doug/Source/module-hooks/index.js'
}
Load {
  url: 'file:///home/doug/Source/module-hooks/index.js',
  context: { format: 'module', importAttributes: {} }
}
Resolve {
  specifier: './dep.js',
  context: {
    conditions: [ 'node', 'import', 'module-sync', 'node-addons' ],
    importAttributes: { type: 'hook' },
    parentURL: 'file:///home/doug/Source/module-hooks/index.js'
  },
  url: 'file:///home/doug/Source/module-hooks/dep.js'
}
Load {
  url: 'file:///home/doug/Source/module-hooks/dep.js',
  context: { format: 'module', importAttributes: { type: 'hook' } }
}
Resolve {
  specifier: './dep.js',
  context: {
    conditions: [ 'node', 'import', 'module-sync', 'node-addons' ],
    importAttributes: {},
    parentURL: 'file:///home/doug/Source/module-hooks/index.js'
  },
  url: 'file:///home/doug/Source/module-hooks/dep.js'
}

node:internal/modules/run_main:104
    triggerUncaughtException(
    ^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/doug/Source/module-hooks/dep.js' imported from /home/doug/Source/module-hooks/index.js
    at finalizeResolution (node:internal/modules/esm/resolve:275:11)
    at moduleResolve (node:internal/modules/esm/resolve:860:10)
    at defaultResolve (node:internal/modules/esm/resolve:984:11)
    at nextResolve (node:internal/modules/esm/hooks:748:28)
    at resolve (file:///home/doug/Source/module-hooks/hooks.js:6:62)
    at nextResolve (node:internal/modules/esm/hooks:748:28)
    at Hooks.resolve (node:internal/modules/esm/hooks:240:30)
    at MessagePort.handleMessage (node:internal/modules/esm/worker:199:24)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:827:20)
    at MessagePort.<anonymous> (node:internal/per_context/messageport:23:28) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///home/doug/Source/module-hooks/dep.js'
}

Node.js v23.6.0

What's failing here is that resolving deps.js without type: 'hook' causes the custom resolve function to ignore the module altogether.

It's possible I'm misunderstanding the expected behavior here, but there is exactly one import of deps.js and it contains type: 'hook', therefore I think the program should never resolve deps.js without that import attribute.

Additional information

I suspect the problem is that importing an invalid symbol causes Node to generate an error message which itself requires resolving the module, and doesn't propagate import attributes correctly.

import * as mod from './deps.js' with { type: 'hook' } and import('./deps.js', { with: { type: 'hook' } }) both seem to work as expected and don't trigger the double resolution. This is maybe because they don't emit the error which cause Node to re-resolve the specifier.

You can update the statement to import { foo as data } from './deps.js' with { type: 'hook' }; and the module loader works as expected, with only one resolution which includes the import attribute. So this definitely seems to be limited to the "importing a wrong symbol" case.

I can maybe see an argument that at least as a best practice, resolve should return consistent behavior regardless of the given import attribute, or at least be consistent about whether the module exists, even if the location changes based on import attributes. I'm not sure if what I'm doing necessarily aligns with the intended use of module hooks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant