`Module.registerHooks` never populates `format`, so hooks cannot tell CommonJS from ESM
Version: Deno 2.9.6
Deno's synchronous loader hooks (Module.registerHooks) always report format as undefined, in both the load hook's context and the result returned by nextLoad(). This holds for every module Deno loads, CommonJS and ESM alike, and whether the type comes from a .cjs/.mjs extension or from the nearest package.json "type" field.
Deno's own loader hooks documentation lists format as a string on both the load context and the load return value, with no note that it is optional or unimplemented:
| Property | Type | Description |
|---|---|---|
format |
string |
Module format hint (e.g., "module", "commonjs") |
Node populates it. Deno does not.
A loader hook that rewrites source has to know which module system it is writing into. Without format it has to guess, and a wrong guess produces code that throws at load time.
Versions
- Deno 2.9.2 (stable, aarch64-apple-darwin), and reported the same on 2.8.3
- Compared against Node v26.5.0
Reproduction
Four modules, one per rule Deno uses to decide the module type. No third-party dependencies.
mkdir -p denofmt/cjs-dir denofmt/esm-dir && cd denofmt
cat > hooks.mjs <<'EOF'
import { registerHooks } from 'node:module'
registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context)
console.log('resolve', specifier.padEnd(20), 'format =', result.format)
return result
},
load(url, context, nextLoad) {
const result = nextLoad(url, context)
console.log('load ', url.split('/').pop().padEnd(20),
'format =', String(result.format).padEnd(10),
'source =', result.source === null ? 'null' : typeof result.source)
return result
},
})
EOF
echo '{ "name": "repro", "version": "1.0.0" }' > package.json
echo "module.exports = 'dep.cjs'" > dep.cjs
echo "export default 'dep.mjs'" > dep.mjs
echo '{ "type": "commonjs" }' > cjs-dir/package.json
echo "module.exports = 'cjs-dir/dep.js'" > cjs-dir/dep.js
echo '{ "type": "module" }' > esm-dir/package.json
echo "export default 'esm-dir/dep.js'" > esm-dir/dep.js
cat > main.mjs <<'EOF'
await import('./dep.cjs')
await import('./dep.mjs')
await import('./cjs-dir/dep.js')
await import('./esm-dir/dep.js')
EOF
node --import ./hooks.mjs main.mjs
deno run -A --preload=./hooks.mjs main.mjs
Actual
===== deno 2.9.2 =====
load main.mjs format = undefined source = string
resolve ./dep.cjs format = undefined
load dep.cjs format = undefined source = string
resolve ./dep.mjs format = undefined
load dep.mjs format = undefined source = string
resolve ./cjs-dir/dep.js format = undefined
load dep.js format = undefined source = string
resolve ./esm-dir/dep.js format = undefined
load dep.js format = undefined source = string
All four modules execute correctly, so Deno knows the right answer internally. It just does not pass it to the hook.
Expected
What Node reports for the same four modules:
===== node v26.5.0 =====
resolve file:///private/tmp/denofmt/main.mjs format = module
load main.mjs format = module source = object
resolve ./dep.cjs format = commonjs
load dep.cjs format = commonjs source = object
resolve ./dep.mjs format = module
load dep.mjs format = module source = object
resolve ./cjs-dir/dep.js format = commonjs
load dep.js format = commonjs source = object
resolve ./esm-dir/dep.js format = module
load dep.js format = module source = object
Why this matters
A hook that transforms source must inject the right kind of import. Getting it wrong breaks the module at load time, in both directions:
require()injected into an ES module throwsReferenceError: require is not definedimportinjected into a CommonJS module throwsReferenceError: module is not defined
This is the root cause of https://github.com/apm-js-collab/tracing-hooks/issues/53. It's an APM package that instruments npm dependencies by rewriting their source. Under Node's hooks it reads format and works. Under Deno's it got undefined, assumed CommonJS, and crashed every ESM-only dependency it instrumented, starting with hono.
The gap is not only about ESM. Deno loads CommonJS in four situations, and a hook needs to distinguish all of them from ESM:
- the file name ends in
.cjs - the closest
package.jsonhas"type": "commonjs" --unstable-detect-cjsis passed- some cases of loading from
node_modules
format: undefined covers every one of them, so there is nothing in the hook payload to branch on.
Deno also does not implement the asynchronous module.register() API, so registerHooks is the only loader hook available. There is no second path to fall back to.
Suggested fix
Populate format on the object returned by nextLoad(), and on the load hook's context, with the value Deno already resolved: "module", "commonjs", or "json". That matches Deno's own documented types and Node's behavior.
Setting it on the resolve result as well would let a hook decide before it reaches load, which is what Node does.
Workaround
Until then, a hook has to re-derive the module type from the file, repeating work the runtime already did:
.mjs/.mtsis ESM,.cjs/.ctsis CommonJS- otherwise, walk up to the nearest
package.jsonand read"type" - otherwise, scan the source for a top-level
import/export
Step 3 is a guess. It is wrong for an ES module that happens to contain no static import or export, and it costs a synchronous readFileSync walk per directory on the module load path.
Additional observations
These turned up alongside the main report and are probably separate.
- The
resolvehook is not called for the entry module. Node calls it formain.mjs; Deno's output above starts atload. - TypeScript under
node_modulesalways fails withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, but the hooks see it inconsistently. For.mts, and for.tsin a"type": "module"package, theloadhook runs first and is handedsource: nullandformat: undefined. For.cts, and for.tsin a"type": "commonjs"package, Deno throws before theloadhook runs at all.
Source: denoland/deno