Repository navigation
Unexpected behavior in module customization hooks / loader, for require #55808
Description
Activity
Maybe I should mention my use case here:
I'm trying to use the module hooks/loader with SEA, to distribute my project, in 1+n files.
Where the node.js binary will be injected with a custom hook/loader, and then load and run code in one or more ASAR archives.This requires the hooks to resolve and load paths that do not really exist on the hard drive.
- addedloadersIssues and PRs related to ES module loaders.Issues and PRs related to ES module loaders.
on Nov 16, 2024 @nodejs/loaders
I feel that the approach taken in #47999 is just broken and we should just go back to using
Module._loadto loadimport(cjs)instead of trying to re-invent a ESM-loader specificrequireandrequire.resolve. That approach just cannot cover all the corner cases and there will always be some holes (like what's mentioned in this issue, and randomcreateRequire(), and the re-exports detection for the cjs-module-lexer). Going back meansmodule.register()will not work for CJS, but then if it's already having all these edge cases in CJS, it's not really reliable anyway, and at least by going back we can make suremodule.registerHooks()is always reliable.@joyeecheung I generally liked the idea of having a dedicated loader thread.
I agree with you about reusing cjs loader code, but not
Module._loaddirectly. I believe the resolving can happen in the loader thread and module.register() can still work. It's just the code loading part, that needs to be left in the caller thread, similar to how internal modules work.Mixing the cjs and module loader is probably inevitable because cjs and es modules may load each other in user code.
For this specific issue, it's just a bug-level thing, no need to overturn the whole approach.
For context the overturn has already been discussed before: I even have to push back against deprecating or just removing module.register() now when I was adding module.registerHooks() because the off-thread approach have many bugs and edge cases that have not been fixed and there's generally a lack of interest in fixing them. But I was just not in favor in removing the async hooks immediately due to not wanting to disrupt existing usage, not that I think it's a path worth pursuing further (I for one, am not interested in fixing the off-thread code because it just doesn't fit well internally IMO, and provides no good story for CJS monkey patching migration. I would not oppose against others trying to fix it, but you can probably see a lack of interest in fixing it from the radio silence of this issue).
Reacted by Yanlong Wang- added a commit that references this issue
on Feb 2, 2026 - added a commit that references this issue
on Feb 8, 2026 FWIW if you use
module.registerHooksnow, resolve hooks will only get invoked once with the specifier if you do not propagate it to the default next steps in your hook i.e. it will not read from the file system if you override early. Formodule.registerthis would be much harder given its off-thread design (for example, ifModule._resolveFilenamegets invoked in the loader thread, it could only work with the cache in the loader thread, which could then lead to a mismatch with the cache in the main thread, which is accessible by users fromModule._cacheand widely relied upon by various popular packages), and I am not sure if anyone is invested in trying to fix that harder problem...- added 2 commits that reference this issue
on Feb 10, 2026 - added a commit that references this issue
on Feb 22, 2026 Not sure if this is the right issue to report on, or if this effort is already being tracked elsewhere, but I think I ran into this (or something very similar?) when trying out https://github.com/platformatic/vfs, but it seems to be a core bug in
Module.registerHooks(not just the "async off thread" version).Basically, after
importing a module which goes throughModule.registerHooks, no nested imports or requires branched from that module end up going through the hooks.e.g
VFS -> import("virtual-file-1.js") # the require or import of this inner dep doesn't go through the `registerHooks` -> require / import("virtual-dep-1.js")If you use
requireat the top level, and all the way down, it works fine.Minimal reproduction (tested on latest node 25 and node 24):
import module, { createRequire } from "node:module"; const require = createRequire(import.meta.url); const VIRTUAL_MODULES = new Map([ ["virtual:shared", `module.exports.greet = (name) => "hello " + name;`], [ "virtual:entry", `const { greet } = require("virtual:shared");\nmodule.exports.default = greet("world");`, ], ]); // Register hooks that resolve and load "virtual:" specifiers entirely in-memory. module.registerHooks({ resolve(specifier, context, nextResolve) { if (specifier.startsWith("virtual:")) { console.log(` [resolve hook] ${specifier}`); return { url: specifier, format: "commonjs", shortCircuit: true }; } return nextResolve(specifier, context); }, load(url, context, nextLoad) { if (url.startsWith("virtual:")) { console.log(` [load hook] ${url}`); return { format: "commonjs", source: VIRTUAL_MODULES.get(url), shortCircuit: true }; } return nextLoad(url, context); }, }); function clearCache() { for (const key of Object.keys(require.cache)) { if (key.startsWith("virtual:")) delete require.cache[key]; } } async function run(label, loadFn) { try { clearCache(); console.log(`[${label}] OK:`, await loadFn()); } catch (e) { console.error(`[${label}] FAIL:`, e.message.split("\n")[0]); } } console.log("=== require(virtual:entry) ==="); await run("require", () => require("virtual:entry").default); console.log("\n=== import(virtual:entry) ==="); await run("import ", async () => (await import("virtual:entry")).default);
Output:
$ node register-hooks-repro.mjs === require(virtual:entry) === [resolve hook] virtual:entry [load hook] virtual:entry [resolve hook] virtual:shared [load hook] virtual:shared [require] OK: hello world === import(virtual:entry) === [resolve hook] virtual:entry [load hook] virtual:entry [import ] FAIL: Cannot find module 'virtual:shared'@johnpyp I think it's a different issue i.e. for virtual resolution, we currently still have to call
Module._resolveFilenameagain for compatbility if it's imported CJS - this is why you don't see it reproduced when you try to load the entrypoint with require, it's an "imported CJS" issue. Can you open a separate issue for this?- added a commit that references this issue
on Apr 27, 2026 github-actions commented
on Jul 20, 2026 on Jul 20, 2026 – with GitHub ActionsContributorMore actionsThis issue has been marked as stale due to 90 days of inactivity.
It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.- addedstaleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.Issues and PRs marked stale due to inactivity and scheduled for automatic closure.
on Jul 20, 2026 github-actions commented
on Aug 20, 2026 on Aug 20, 2026 – with GitHub ActionsContributorMore actionsThis issue has been automatically closed after 30 days of inactivity following its stale status (no activity for a total of 120 days).
If this is still relevant, feel free to reopen it or leave a comment with additional details so we can continue the discussion.
It seems I am one of the early adopters of module hooks.
I liked the idea of the module customization hooks, and tried to use it in my project.
However, I believe the current behavior of
requirehandling in the esm loader is buggy.Relevant source code, here
node/lib/internal/modules/esm/translators.js
Lines 130 to 159 in 69f8794
The resolving of the
specifieris done in the main thread, usingModule._resolveFilename, before being sent to the customization hooks.The resolver hook is then receiving a resolved
file://URL, instead of the realspecifier.These are not expected according to the documentation of the module customization hooks.
And for my use case, it has made the hooks useless.
To make it clear there are two bugs:
The hooks, therefore, cannot really work, because if the "expected" file does not really exist on the hard drive. The cjs
Module._resolveFilenamewill throw an error in the first place.specifier, if the first bug would not trigger.The root of this issue might be the default resolver in customization hooks does not support resolving CJS modules.
An easy fix would be to move the
Module._resolveFilenameinto the default resolver.The loading of
.nodenative add-ons may also get in the way.But IMO it should also go through the hooks, and eventually be loaded as
{type: 'commonjs', source: undefined, url: 'file://... .node'}