Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,20 @@ jobs:
- name: Run matrix
run: node test/run.cjs "$RUNNER_TEMP/fixtures" --shard=${{ matrix.shard }}/8

# Node < 24.12 uses the loader-thread ESM hooks (src/hooks.cjs): the Vite, Rollup, Rolldown, nested, SvelteKit, rspack 2 and Rsbuild 2 cases on Wolfi's Node 22
# (e.g. GitLab and Coder build with it); wp5-apm: tracing agents next to the hooks
# The Vite, Rollup, Rolldown, nested, SvelteKit, rspack 2 and Rsbuild 2 cases on Wolfi's Node 22 (e.g. GitLab and Coder
# build with it): its latest, with the in-thread ESM hooks (22.22.3 and later, src/hooks.cjs syncHooksSafe), and 22.22.2,
# the last with the loader-thread hooks, which older Node.js (20, 22 before 22.22.3, 24 before 24.11.1) uses;
# wp5-apm: tracing agents next to the hooks
node22:
runs-on: ubuntu-latest
timeout-minutes: 30
container: cgr.dev/chainguard/wolfi-base:latest
strategy:
fail-fast: false
matrix:
node: ['22', '22~22.22.2']
shard: [1, 2, 3]
name: matrix (Node 22, Vite, ${{ matrix.shard }}/3)
name: matrix (Node ${{ matrix.node }}, Vite, ${{ matrix.shard }}/3)
env:
# the fixtures of the ESM-hooked bundlers (also rspack 2 and Rsbuild 2) and of webpack and rspack 1 under Yarn
# Plug'n'Play (whose CommonJS modules Node 22 loads without Module._load, see src/hooks.cjs), and their cases
Expand All @@ -99,7 +102,7 @@ jobs:
BLTEST_REQUIRE_SBOM: '1'
steps:
- name: Install tools
run: apk add --no-cache nodejs-22 npm yarn bun syft trivy
run: apk add --no-cache nodejs-${{ matrix.node }} npm yarn bun syft trivy && node -v
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # no token in .git/config for the packages' install scripts the matrix runs
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ syft scan dir:dist -q # or: trivy fs --format cyclonedx --output sbom
```

Every supported bundler in that process, or in a process it starts (which inherits `NODE_OPTIONS`), is hooked. On
Node.js before 24.12 (25.2 on 25), Vite, Rollup, Rolldown and rspack 2 are hooked only in some processes ([which
ones](docs/bundlers.md#which-processes-are-hooked)).
Node.js 20, 22 before 22.22.3 and 24 before 24.11.1, Vite, Rollup, Rolldown and rspack 2 are hooked only in some
processes ([which ones](docs/bundlers.md#which-processes-are-hooked)).

The test app `test/apps/webpack5` uses `lodash-es`, `debug` 2.6.9 (with its own `ms` 2.0.0), `ms` 2.1.3, `yallist`
and, in a lazily loaded chunk, `nanoid`; it imports `uuid` without using it. Built with webpack 5, syft reports:
Expand Down Expand Up @@ -283,9 +283,9 @@ Node.js versions and package managers (npm, pnpm, yarn 1–4 with Plug'n'Play, b
Bun's own runtime (`bun --bun`, `Bun.build`, and `bun run` where Node.js is not installed), and a bundler in a
process that `NODE_OPTIONS` (or the node shim) does not reach. Check in CI that every lockfile the package should
ship exists.
- **Older Node.js:** before 24.12 (25.2 on 25), ES-module bundlers (Vite, Rolldown, rspack 2, Rollup through
`import`) are hooked only in some processes, and through a loader thread that costs time (8.7 % on one Rsbuild
build; [which processes](docs/bundlers.md#which-processes-are-hooked)).
- **Older Node.js:** on 20, 22 before 22.22.3 and 24 before 24.11.1, ES-module bundlers (Vite, Rolldown, rspack 2,
Rollup through `import`) are hooked only in some processes, and through a loader thread that costs time (8.7 % on
one Rsbuild build; [which processes](docs/bundlers.md#which-processes-are-hooked)).
- **Not in the lockfile:** packages a server loads from `node_modules` at runtime, files put into the output without
the bundler (a shell `cp`, another process), vendored code without a `package.json`, and in webpack 5.108+ a
package of which only inlined constants are used ([what is listed](docs/coverage.md)).
Expand Down
2 changes: 1 addition & 1 deletion docs/bundlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The CommonJS bundlers — webpack (also Next.js' copy), rspack 1, Rollup's Commo
that preloads bundle-lockfile. The ES-module bundlers — Vite,
which imports Rollup and Rolldown as ES modules, Rolldown, rspack 2, Rollup through `import` — need a Node.js ESM
hook:
- Node.js 24.12, 25.2 and later: hooked in every process.
- Node.js 22.22.3, 24.11.1, 25.1 and later: hooked in every process.
- Older versions: hooked only in processes (their main thread and worker threads)
- whose main script belongs to a package that is or depends on `vite`, `rollup`, `rolldown`, `rolldown-vite`,
`@rspack/core`, `@rspack/cli` or `@rsbuild/core` (the `vite` command, a build script of a project using Vite), or
Expand Down
23 changes: 16 additions & 7 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ flowchart LR
end
nodeopts --> register["register.cjs"]
register --> cjs["CommonJS hook: Module.prototype._compile,<br/>Module._extensions (hooks.cjs)"]
register --> esm["ESM load hook (hooks.cjs)<br/>registerHooks, in-thread: Node ≥ 24.12 / 25.2<br/>(safe next to other loaders from there)<br/>module.register, loader thread (esm-loader.mjs):<br/>older, in bundler processes"]
register --> esm["ESM load hook (hooks.cjs)<br/>registerHooks, in-thread: Node ≥ 22.22.3 / 24.11.1 / 25.1<br/>(safe next to other loaders from there)<br/>module.register, loader thread (esm-loader.mjs):<br/>older, in bundler processes"]
register --> copies["fs copy functions wrapped<br/>(core/copies.cjs)"]

cjs -- "webpack/lib/Compiler.js<br/>(webpack-cli, webpack 4/5)" --> wpatch["Compiler.prototype.compile patched"]
Expand Down Expand Up @@ -250,16 +250,25 @@ directory (`node_modules/.vite`, or the config's `cacheDir`, also outside `node_

The wrapper of an entry module wraps every function the adapter names, whether the module's source shows it (the
namespace also has what an `export *` brings) or not, and reports whether the module had any of them: an entry with
none is a version the adapter does not know, which fails its import in strict mode (`hooks.cjs` `found`). Paths are
none is a version the adapter does not know, which fails its import in strict mode (`hooks.cjs` `found`). The real
module is imported as `<url>?bundle-lockfile-real`: its `import.meta.url` has that query, which Node drops when it
makes a path (`fileURLToPath`, `import.meta.filename`, `createRequire`, `new URL('./x', import.meta.url)`). A module
of the bundler's own graph that imported the entry back would get the wrapper, whose exports exist only once the real
module has run (read before, they throw); none of the wrapped entries has such a cycle (rollup 4.64, rolldown 1.2,
@rspack/core 2.2). Paths are
recognized as if every package were at `node_modules/<name>` (`core/layout.cjs`: Yarn's pnpm linker puts packages in
`node_modules/.store/<slug>/package/`).

### Which ESM hook

The ESM hook depends on the Node.js version (`BUNDLE_LOCKFILE_ESM_HOOKS` overrides it):
- Node.js 24.12, 25.2 and later: `module.registerHooks`, in the same thread, in every process. Before those versions,
in-thread hooks next to any loader-thread hook (Yarn Plug'n'Play's, tsx's) crash the process (fixed by
[nodejs/node#60380](https://github.com/nodejs/node/pull/60380)).
- Node.js 22.22.3, 24.11.1, 25.1 and later: `module.registerHooks`, in the same thread, in every process. Before
those versions, in-thread hooks fail next to a loader-thread hook (Yarn Plug'n'Play's, tsx's, OpenTelemetry's
`hook.mjs`) that provides no source for a CommonJS module, which Node then reads itself: they reject it
(`ERR_INVALID_RETURN_PROPERTY_VALUE`; fixed by [nodejs/node#59929](https://github.com/nodejs/node/pull/59929)).
`module.registerHooks` called in Node's own loader thread - by a `--require` preload, which Node 22 – 24.11 runs
there too - crashes the process ("this[#customizations].loadSync is not a function"): `register.cjs` does nothing
there.
- Older versions with `module.register` (18.19, 20.6 and later): a loader thread, only in processes whose main script
belongs to a package that is or depends on `vite`, `rollup`, `rolldown`, `rolldown-vite`, `@rspack/core`,
`@rspack/cli` or `@rsbuild/core` (the adapters' `esmPackages`) — the `vite`, `rspack` or `rsbuild` command, a build
Expand Down Expand Up @@ -321,8 +330,8 @@ rspack differs. `src/adapters/rspack.cjs` only hooks rspack:
- Its `Compiler.prototype.compile` is patched as webpack's; rspack's Compiler has no `newCompilation`, so the
webpack adapter does not take it (and refuses compilers that name an rspack version: Next.js with next-rspack
assigns rspack's exports to its vendored webpack). A process can load several copies (OHIF: rspack 1 for the config's
plugins, rspack 2 for the compiler) and see one copy on several paths (on Node.js 24.12+, the in-thread ESM hook
and the CommonJS hook): each class is patched once, marked on its prototype.
plugins, rspack 2 for the compiler) and see one copy on several paths (with the in-thread ESM hook, the ESM hook and
the CommonJS hook): each class is patched once, marked on its prototype.
- rspack 1.0 has no inner modules of concatenated modules (`ConcatenatedModule.modules`, from 1.1) nor a module
graph: the plugin, also one in the config, fails each of their compilations (`core/problems.cjs`) instead of
collecting. rspack 0.x is left alone, with a debug message only: no lockfile, no failure.
Expand Down
6 changes: 3 additions & 3 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ output is written is printed and the watcher goes on. A bundler that cannot be h
| `Node.js … has no ESM hooks (module.register: 20.6 and later)` | Node.js 20.0 – 20.5 in a process that builds with Vite, Rollup, Rolldown or rspack 2 | use a supported Node.js: 20.18 or later |
| `<file URL> is wrapped, but bundle-lockfile is not loaded in this thread` | an ES-module bundler's entry was replaced by bundle-lockfile's wrapper in a thread where its preload did not run | please report it, with the Node.js version and how the build starts its threads |
| `could not read <lockfile>: the packages of the build that wrote <dir> would be missing …`, `<lockfile> is not JSON: …`, `<lockfile> has a record of version …` | a bundled file is below the output of another build whose lockfile cannot be read ([nested bundles](format.md#nested-bundles)): permissions, an edited file, a lockfile of another bundle-lockfile version | fix the permissions, or rebuild that output with this bundle-lockfile |
| `<rollup\|rolldown\|rspack> is loaded as an ES module in a process without bundle-lockfile's ESM hooks (<file>) …` | on Node.js before 24.12, a process bundle-lockfile did not take for a bundler process ([which processes](bundlers.md#which-processes-are-hooked)) imports an ES-module bundler: a programmatic build started outside the project, a tool that loads Vite through a package that names none | `BUNDLE_LOCKFILE_ESM_HOOKS=async`, or Node.js 24.12 or later |
| `<rollup\|rolldown\|rspack> is loaded as an ES module in a process without bundle-lockfile's ESM hooks (<file>) …` | on older Node.js (20, 22 before 22.22.3, 24 before 24.11.1), a process bundle-lockfile did not take for a bundler process ([which processes](bundlers.md#which-processes-are-hooked)) imports an ES-module bundler: a programmatic build started outside the project, a tool that loads Vite through a package that names none | `BUNDLE_LOCKFILE_ESM_HOOKS=async`, or Node.js 22.22.3 / 24.11.1 or later |
| `Node.js … has no Module.prototype._compile` (or `Module._extensions['.js']`)`: CommonJS bundlers … are not hooked` | a Node.js whose module loader bundle-lockfile cannot hook, in a process that builds with webpack, Next.js, rspack, Rollup or esbuild | use a supported Node.js ([supported](bundlers.md#supported)) |
| `<file> ran past bundle-lockfile's CommonJS hook (a loader that compiles modules itself) …` | at exit: a bundler's module (webpack's `lib/Compiler.js`, Next.js' webpack, `@rspack/core`, Rollup's CommonJS build, esbuild's `lib/main.js`) is in `require.cache` but was loaded without Node's module handlers, e.g. by a test runner or a tool that compiles modules itself | run the build without that loader; if it is a common tool, please report it |
| `<adapter> adapter failed on <file>: its builds would write no lockfile` | recognizing a bundler's own module threw (a version whose module bundle-lockfile cannot read) | please report it, with the bundler's version |
Expand Down Expand Up @@ -72,8 +72,8 @@ They do not fail the build:
- The bundler is not supported: the `esbuild` command line (build with its JavaScript API instead), Angular's esbuild
builder, Bun's own runtime (with `BUNDLE_LOCKFILE_STRICT=0` also those that fail in strict mode: Turbopack, the
`rolldown` command line, ncc, webpack < 4, rspack 0.x).
- On Node.js before 24.12 (25.2 on 25), Vite, Rollup, Rolldown and rspack 2 are hooked only in some processes: a
programmatic build may need `BUNDLE_LOCKFILE_ESM_HOOKS=async` ([which
- On Node.js 20, 22 before 22.22.3 and 24 before 24.11.1, Vite, Rollup, Rolldown and rspack 2 are hooked only in
some processes: a programmatic build may need `BUNDLE_LOCKFILE_ESM_HOOKS=async` ([which
processes](bundlers.md#which-processes-are-hooked)).
- `bun run` without Node.js installed runs the build in Bun's own runtime, which nothing hooks.
- Vite, Rollup and Rolldown outputs below `node_modules`, builds that write nothing themselves, `vite dev`, `vite
Expand Down
6 changes: 3 additions & 3 deletions src/adapters/rspack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
// Rsbuild 2 import it: the ESM entry wrapper, esmWrap; require() of it, require(esm), goes through the CommonJS hook too).
// - Compiler.prototype.compile is patched, as webpack's (webpack.cjs): it runs for run(), watch() and child compilers;
// every top-level compiler gets the plugin on its first compile(). The prototype is marked, so a copy of @rspack/core
// seen on several paths (in-thread ESM hooks and the CommonJS hook on Node.js 24.12+) is patched once; every copy loaded
// (OHIF loads rspack 1 and 2) gets its own patch.
// seen on several paths (the in-thread ESM hooks and the CommonJS hook) is patched once; every copy loaded (OHIF
// loads rspack 1 and 2) gets its own patch.
// rspack 1.0 lacks what the plugin needs (the inner modules of concatenated modules): its builds fail (strict mode,
// core/problems.cjs). rspack 0.x fails its builds too.
const fs = require('fs');
Expand Down Expand Up @@ -73,7 +73,7 @@ module.exports = {
cjsFiles: [/[\\/]@rspack[\\/]core[\\/]dist[\\/]index\.js$/],
// rspack 2: only ES modules are wrapped (esm-wrap.cjs), so rspack 1's file at the same path is not
esmEntries: [{ id: 'rspack', suffixes: ['/@rspack/core/dist/index.js'], wrap: ['Compiler'] }],
// processes that get the loader-thread ESM hooks on Node.js before 24.12 (see hooks.cjs esmMode): @rspack/cli 2 and
// processes that get the loader-thread ESM hooks on older Node.js (see hooks.cjs esmMode): @rspack/cli 2 and
// Rsbuild 2 import rspack, as may a build script of a project using it
esmPackages: ['@rspack/core', '@rspack/cli', '@rsbuild/core'],
// the CommonJS file rspack 2's ES module loads (hooks.cjs esmCompanions): its binding, from dist/index.js - rspack 1's
Expand Down
6 changes: 3 additions & 3 deletions src/esm-wrap.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,9 @@ function unwrappable(url, e) {
// whose fstat fails on their file descriptors) do not go through Module._extensions' handlers: Node evaluates them with a require()
// of its own, also every module they require - the CommonJS hook (hooks.cjs) never sees them. For the files the adapters
// patch when they load (cjsFiles), a line appended to that source reports the module to the hook once it has run, as
// the CommonJS hook (hooks.cjs) would. Only when a hook provided the source. On Node.js 24.12+ the in-thread hook also gets the source of
// a CommonJS file an ES module imports (Rsbuild 1 importing rspack 1), which Node then also loads through Module._extensions:
// such a module is reported twice, and the adapters patch it once.
// the CommonJS hook (hooks.cjs) would. Only when a hook provided the source. The in-thread hook also gets the source
// of a CommonJS file an ES module imports (Rsbuild 1 importing rspack 1), which Node then also loads through
// Module._extensions: such a module is reported twice, and the adapters patch it once.
const CJS_LOADED = "\n;{ const f = globalThis[Symbol.for('bundle-lockfile.cjs-loaded')]; if (typeof f === 'function') f(module, __filename); }\n";
function cjsSource(url, result, files) {
if (!result || result.format !== 'commonjs' || result.source == null || typeof url !== 'string' || !url.startsWith('file:')) return null;
Expand Down
26 changes: 17 additions & 9 deletions src/hooks.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,16 @@ function companion(module, filename) {
failLoad(before);
}

// module.registerHooks (in-thread, synchronous) together with any loader-thread hook (Yarn PnP's .pnp.loader.mjs,
// tsx, ...) crashes the process ("this[#customizations].loadSync is not a function") before Node 24.12 / 25.2
// (nodejs/node#60380; not in 22.x). Removing our hooks again does not help (verified on 22.23). So it is used only
// from those versions on.
// module.registerHooks (in-thread, synchronous) next to a loader-thread hook (Yarn PnP's .pnp.loader.mjs, tsx,
// OpenTelemetry's hook.mjs, ...): a loader-thread hook may provide no source for a CommonJS module (null: Node reads
// the file), which in-thread hooks reject (ERR_INVALID_RETURN_PROPERTY_VALUE, the build fails) before
// nodejs/node#59929: Node 22.22.3, 24.11.1, 25.1. From those versions on they are used in every process. (A
// registerHooks call in Node's own loader thread - a --require preload that calls it, which Node 22-24.11 runs there
// too - crashes the process, "this[#customizations].loadSync is not a function"; register.cjs does nothing there.)
function syncHooksSafe(version) {
const [major, minor] = version.replace(/^v/, '').split('.').map(Number);
return major >= 26 || (major === 25 && minor >= 2) || (major === 24 && minor >= 12);
const [major, minor, patch] = version.replace(/^v/, '').split('.').map(Number);
return major >= 26 || (major === 25 && minor >= 1) || (major === 24 && (minor > 11 || (minor === 11 && patch >= 1)))
|| (major === 22 && (minor > 22 || (minor === 22 && patch >= 3)));
}

// The process may build with a bundler of `packages` (or a tool built on one). Loader-thread hooks cost a thread per
Expand Down Expand Up @@ -303,9 +306,14 @@ function installEsm(entries, cjsFiles) {
}
} catch (e) {
// Vite, Rollup, Rolldown builds in this process would write no lockfile: a problem in a bundler process (it fails
// at exit, core/problems.cjs); elsewhere (npm, any script) there is nothing it would miss
if (bundlerProcess(packages)) problems.problem('could not install the ESM hooks', e);
else config.warn('could not install the ESM hooks:', e);
// at exit, core/problems.cjs); elsewhere (npm, any script) there is nothing it would miss. module.register() starts
// Node's loader thread, which also runs the other loaders and the --require preloads: what breaks there (a loader
// file that fails, a preload that calls module.registerHooks, "loadSync is not a function") fails our call.
const msg = mode === 'async'
? 'could not install the ESM hooks: Node.js\'s loader thread did not start (a --loader or --import that fails there, or a --require preload that calls module.registerHooks, breaks it)'
: 'could not install the ESM hooks';
if (bundlerProcess(packages)) problems.problem(msg, e);
else config.warn(`${msg}:`, e);
}
}

Expand Down
Loading
Loading