Skip to content

Commit 06fde1d

Browse files
committed
esm: fix relative require in non-file CJS source
When a load hook provides the source of a CommonJS module, the require calls in that source are meant to go through the ESM loader so that the registered hooks apply to them, as documented for the load hook. That did not happen when the module had a URL that is not a file:. The require function built for those modules called Module._resolveFilename() before handing the specifier to the ESM resolver, only to look at the extension of the resulting path and decide whether to treat the request as JSON or as a native addon. Because the CJS resolver works on file paths, it cannot resolve a relative specifier against a referrer such as custom:cjs, so it threw MODULE_NOT_FOUND and the ESM resolution below it was never reached. The resolve hook was not called at all. Keep that lookup, since the JSON and .node branches depend on it, but let it fail when the referrer is not a file and leave the specifier for the ESM resolver to handle. A specifier that the hooks do not claim now fails with ERR_UNSUPPORTED_RESOLVE_REQUEST, which reports that the referrer scheme is not hierarchical, instead of MODULE_NOT_FOUND coming from the CJS resolver. Signed-off-by: Avocado <ujubongbong@gmail.com>
1 parent 7b6b21a commit 06fde1d

3 files changed

Lines changed: 67 additions & 12 deletions

File tree

lib/internal/modules/esm/translators.js

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,32 @@ function loadCJSModuleWithSpecialRequire(module, source, url, filename, isMain,
129129
let importAttributes = kEmptyObject;
130130
if (!StringPrototypeStartsWith(specifier, 'node:') && !BuiltinModule.normalizeRequirableId(specifier)) {
131131
// TODO: do not depend on the monkey-patchable CJS loader here.
132-
const path = CJSModule._resolveFilename(specifier, module);
133-
switch (extname(path)) {
134-
case '.json':
135-
importAttributes = { __proto__: null, type: 'json' };
136-
break;
137-
case '.node':
138-
// If it gets here in the translators, the hooks must have already been invoked
139-
// in the loader. Skip them in the synthetic module evaluation step.
140-
return wrapModuleLoad(specifier, module, false, kShouldSkipModuleHooks);
141-
default:
142-
// fall through
132+
// The CJS resolver works on file paths, so it cannot resolve a specifier whose
133+
// referrer is not a file - that happens when the source of this module comes from
134+
// a load hook that gave it a non-file URL. Leave the specifier alone in that case
135+
// and let the ESM resolver below run the hooks on it.
136+
let path;
137+
try {
138+
path = CJSModule._resolveFilename(specifier, module);
139+
} catch (err) {
140+
if (err?.code !== 'MODULE_NOT_FOUND' || StringPrototypeStartsWith(url, 'file:')) {
141+
throw err;
142+
}
143+
}
144+
if (path !== undefined) {
145+
switch (extname(path)) {
146+
case '.json':
147+
importAttributes = { __proto__: null, type: 'json' };
148+
break;
149+
case '.node':
150+
// If it gets here in the translators, the hooks must have already been invoked
151+
// in the loader. Skip them in the synthetic module evaluation step.
152+
return wrapModuleLoad(specifier, module, false, kShouldSkipModuleHooks);
153+
default:
154+
// fall through
155+
}
156+
specifier = `${pathToFileURL(path)}`;
143157
}
144-
specifier = `${pathToFileURL(path)}`;
145158
}
146159

147160
// NOTE: This re-invented require() is only used on the loader-hook worker thread.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Flags: --experimental-loader ./test/fixtures/es-module-loaders/non-file-cjs-source.mjs
2+
import '../common/index.mjs';
3+
import assert from 'node:assert';
4+
5+
// When a load hook provides the source of a CommonJS module, the `require` calls
6+
// that source makes are resolved by the ESM loader, so registered hooks apply to
7+
// them. That has to keep working when the module has a URL that is not a file:
8+
// the CJS resolver cannot resolve a relative specifier against such a referrer.
9+
10+
const { default: fromHookedRequire } = await import('custom:entry');
11+
assert.strictEqual(fromHookedRequire, 'loaded through the hooks');
12+
13+
// When the hooks do not claim the specifier either, the failure has to come from
14+
// the ESM resolver rather than from the CJS one.
15+
await assert.rejects(import('custom:missing-dep'), {
16+
code: 'ERR_UNSUPPORTED_RESOLVE_REQUEST',
17+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Serves CommonJS sources under a non-file URL scheme, so that the `require`
2+
// calls they contain have a referrer that the CJS resolver cannot handle.
3+
4+
const sources = {
5+
'custom:entry': 'module.exports = require("./dep");',
6+
'custom:dep': 'module.exports = "loaded through the hooks";',
7+
'custom:missing-dep': 'module.exports = require("./no-such-dep");',
8+
};
9+
10+
export function resolve(specifier, context, next) {
11+
if (specifier in sources) {
12+
return { shortCircuit: true, url: specifier };
13+
}
14+
if (specifier === './dep') {
15+
return { shortCircuit: true, url: 'custom:dep' };
16+
}
17+
return next(specifier, context);
18+
}
19+
20+
export function load(url, context, next) {
21+
if (url in sources) {
22+
return { shortCircuit: true, format: 'commonjs', source: sources[url] };
23+
}
24+
return next(url, context);
25+
}

0 commit comments

Comments
 (0)