#36841·deno

`Module.registerHooks` never populates `format`, so hooks cannot tell CommonJS from ESM

Author: isaacsCreated Sep 14, 2026Updated Sep 15, 2026

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 throws ReferenceError: require is not defined
  • import injected into a CommonJS module throws ReferenceError: 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:

  1. the file name ends in .cjs
  2. the closest package.json has "type": "commonjs"
  3. --unstable-detect-cjs is passed
  4. 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:

  1. .mjs/.mts is ESM, .cjs/.cts is CommonJS
  2. otherwise, walk up to the nearest package.json and read "type"
  3. 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 resolve hook is not called for the entry module. Node calls it for main.mjs; Deno's output above starts at load.
  • TypeScript under node_modules always fails with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, but the hooks see it inconsistently. For .mts, and for .ts in a "type": "module" package, the load hook runs first and is handed source: null and format: undefined. For .cts, and for .ts in a "type": "commonjs" package, Deno throws before the load hook runs at all.