fix: register module hooks synchronously on Node.js >= 26.2.0 - #764
Conversation
`register.mjs` calls `module.register()`, which Node.js runtime-deprecated in v25.9.0. Every `node --import @oxc-node/core/register` therefore prints DEP0205 on a current runtime, and because four tests in this repository assert an empty stderr, `pnpm test` already fails on Node.js 26 — CI only runs 22 and 24, so it has not been visible. Use the synchronous, in-thread `module.registerHooks()` where it is complete, and keep `module.register()` everywhere else. Three things make this more than a one-line swap, each verified against release binaries: - `registerHooks()` shows `require()` to the hooks, which `register()` never did. The CommonJS resolve/load context carries no `importAttributes`, and both `createResolve` and `load` reject a context without it, so those requests are routed to `nextResolve`/`nextLoad` — Node.js resolves them itself, since the `pirates` hook has registered the TypeScript extensions in `Module._extensions`. - A file that oxc-node settles on as CommonJS goes back to `nextLoad`, so the `pirates` hook keeps producing its inline source map. Returning the transformed source instead reported a throw in a `.cts` entry at 5:10 rather than 6:12, which `cli.spec.ts` covers. - `registerHooks` exists from v22.15.0/v23.5.0, but nodejs/node#59011 (v24.5.0, backported to v22.19.0, never to the end-of-life 23.x line) and nodejs/node#62920 (v26.2.0) had to land first. Below v26.2.0 the fallback runs, so nothing changes for those runtimes. Verified on 34 release binaries from v22.14.0 to v26.8.2 — every 22.x, 23.x, 24.x, 25.x and 26.x minor available — against a CommonJS `require()` fixture, an ESM fixture covering tsconfig paths/`.mts`/`.tsx`, and a `createRequire` fixture: no failures anywhere, and DEP0205 gone from v26.2.0 up. The suite goes from 4 failed | 70 passed to 75 passed. Adds a test for `require()` completing an extensionless TypeScript specifier, which nothing covered: it fails with MODULE_NOT_FOUND if the `pirates` hook stops registering those extensions.
… invalidated vitest 4 → 5, pnpm 11 → 12, @oxc-node/core 0.1.0 → 0.1.2, oxfmt 0.64 → 0.68, oxlint 1.79 → 1.83, @rspack/core 2.1 → 2.2, rolldown 1.2.5 → 1.2.8, playwright 1.62 → 1.63, plus @types/node and fast-xml-parser. `@oxc-node/core` 0.1.2 does not remove the need for the patch: `register.mjs` is byte-identical from 0.1.0 through 0.1.2 and on upstream `main` — verified with `cmp`, both at blob 9658531 — and the 0.1.1/0.1.2 changelogs only cover transform, resolve and UNC-path fixes. Dropping the patch brought DEP0205 back to every `node --import @oxc-node/core/register`, which is every gate script here. The patch is rebuilt rather than re-keyed, because the old one was wrong in a way this package never exercised: it routed a CommonJS `require()` into oxc's resolver, which rejects a context without `importAttributes`, so any `.ts` entry in a `"type": "commonjs"` scope died with ``Missing field `importAttributes` ``. Nothing here is CommonJS, so it never showed. The replacement keeps `require()` on Node's own resolution, hands a file oxc reports as CommonJS back to the `pirates` hook so its source map stays accurate, and gates `registerHooks` on Node >= 26.2 — it exists from 22.15 but only became a complete replacement once nodejs/node#59011 (24.5.0, backported to 22.19.0) and nodejs/node#62920 (26.2.0) landed. Below that the deprecated path still runs, so nothing regresses. Measured across 34 release binaries from 22.14.0 to 26.8.2. Submitted upstream as oxc-project/oxc-node#764. The satellite's shared devDependencies move to a pnpm catalog. It had been left on vitest 4 while the root went to 5, which put two vitest majors in the tree; declaring the version once makes that drift structurally impossible, and `pnpm pack` rewrites `catalog:` to a real range so a consumer never sees it. `allowBuilds.esbuild` is now false. esbuild is still used — `scripts/treeshake- verify.ts` is one of its three bundlers — but its postinstall only speeds up the `esbuild` CLI, which nothing here invokes; the JS API spawns the platform binary directly. `buildSync` and all 111 treeshake scenarios pass without it. The two stream tests are reformatted by oxfmt 0.68, which breaks a trailing function expression's parameters differently. No behaviour change. Verified: 20397 node tests, 14454 browser tests, 592 satellite tests, `pnpm check`, and `pnpm verify:treeshake` 111/111.
Brooooooklyn
left a comment
There was a problem hiding this comment.
Excellent work — the design is right and the version-floor research is thorough. Verified end-to-end on Node 26.9.0 (75/75 tests, no DEP0205) and 24.11.1 (fallback identical). Two real regressions vs main to fix before merge, both with small fixes.
1. import of .node addons / non-UTF-8 commonjs files crashes in oxcLoad
nextLoad is not equivalent across the two APIs:
- async
defaultLoad(the oldregister()path) returnssource: nullforcommonjs→transform_outputtakes theNonebranch →load_commonjs_esm'sSourceType::from_path/from_utf8guards returnOk(None)→ Node's CJS machinery handles it. - sync
defaultLoadSyncalways reads the file →oxcLoadreceives{format: "commonjs", source: <raw bytes>}→transform_output→oxc_transform→try_as_str→ UTF-8 decode throws.
resolve reports "commonjs" for .node ("cjs" | "cts" | "node" => None → unwrap_or("commonjs") in create_resolve), so the throw happens inside oxcLoad before the format === "commonjs" deferral can run.
Repro (fixture: any dir + symlinked @oxc-node/core):
// entry.mts — a real .node binary next to it
import addon from "./addon.node";
console.log("addon:", typeof addon);| result | |
|---|---|
Node 24.x (register()) |
addon: object |
Node 26.9 (registerHooks) |
Error: Failed to convert Uint8Array to Vec<u8> at register.mjs:93 |
Same crash for a latin-1-encoded .js in a "type": "commonjs" package (prints caf� on the old path). import of .node is a legitimate napi pattern — this will hit real users on 26.2+.
Suggested fix: in transform_output, treat an undecodable source on a non-JSON commonjs result like source: null (return {format, source: null, response_url} → CJS machinery) instead of propagating the decode error. load_commonjs_esm already reads the file itself, so the #759 flip still works for real JS/TS.
2. isCommonJsRequire misroutes imports under --conditions=require
--conditions appends custom conditions to every request's condition set. Verified on 26.9:
node --import @oxc-node/core/register --conditions=require ./entry.mts
| Scenario | Old path | This PR |
|---|---|---|
import "./dyn" (extensionless .ts) |
extensionless: 5 |
ERR_MODULE_NOT_FOUND |
import "./modpkg/flip.ts" (ESM syntax in CJS pkg — the #759 case) |
format: module |
ERR_REQUIRE_CYCLE_MODULE |
--conditions=import is the mirror hazard: it injects "import" into require contexts, so an includes("import") check would misfire the other way and crash napi on the missing field.
Suggested fix: discriminate on the field that actually crashes — context.importAttributes === undefined. Verified via probes on 26.9: require contexts never carry importAttributes; import contexts always have it ({}), under both poisoned flags.
Non-blocking notes
- The v26.2.0 floor is conservative. nodejs/node#62920 only bites when a sync
loadhook customizes imported CJS (returnssource) — this implementation never does, it always defers. ForcingregisterHookson 24.11.1/22.23.2 passes the whole suite including therequire()fixtures; the real binding constraint is #59011 (24.5.0, backported 22.19.0, never 23.x). A lower floor would additionally silence DEP0205 on 26.0/26.1. Keeping 26.2 is a defensible safety margin — your call. - Double
nextLoadis legal but wasteful — no once-guard innextStep; each call re-reads the file (~3 reads + 2 transforms per imported CJS file).{format: "commonjs", source: null}would be equivalent (null source →loadCJSModuleWithModuleLoad) with one less pass. Correct as-is. result.format === "commonjs"missescommonjs-typescript(Node-detected format for node_modules.tsresolved withformat: None). Harmless today — the passthrough is equivalent — but the invariant is narrower than the comment implies.- Test comment nit:
Module._extensionsis consulted per-require(), so reorderingaddHook/registerHooksinsideregister.mjswouldn't actually fail — removingaddHookis what breaks. Comment overstates the ordering requirement.
Verified working (no action needed)
require() inside imported CJS (.ts/.json/.cjs, dirs, builtins) · require(esm) · createRequire · import() in CJS · worker threads · eval/stdin · .cjs vs .mts/.ts entry condition assignment · stack-trace precision (cli.spec.ts 6:12) · JSON modules · esm.mjs/--loader fallback · promise paths (sync hooks never return Either::B).
Branch synced with main (merge e9f8cd7); suite re-verified green on 26.9 post-merge.
Two requests the asynchronous `module.register()` never showed the hooks reach them
under `module.registerHooks()`, and both regressed:
- Its default load is synchronous and always reads the file, so a `commonjs` result
arrives with its bytes attached — including for a `.node` addon, which resolves as
`commonjs`, and for a latin-1 CommonJS file. `transform_output` then failed to decode
them ("Failed to convert Uint8Array to Vec<u8>"), so `import addon from './x.node'`
crashed where it printed an object before. Hand such a source back the way the
asynchronous default load did — no source, CommonJS machinery reads the file — instead
of propagating the decode error.
- `require()` was told apart by the `require` export condition, but `--conditions`
appends its values to *every* request: under `--conditions=require` an extensionless
`import` failed with ERR_MODULE_NOT_FOUND and a CommonJS-reported file with ESM syntax
with ERR_REQUIRE_CYCLE_MODULE, and `--conditions=import` is the mirror hazard.
Discriminate on `importAttributes` instead — the field the native hooks require anyway,
absent on every CommonJS context and present, if empty, on every ESM one.
The load hook now returns `{ source: null }` for a CommonJS result rather than calling
`nextLoad` a second time: same outcome, one file read and one transform less, and it is
the shape that never trips nodejs/node#62920, so that defect no longer bears on the
version floor — v26.2.0 is kept as a deliberate margin. CI gains a Node.js 26 job, since
nothing in the matrix exercised the synchronous path.
|
Thanks — both regressions confirmed locally on v26.8.1 (identical repros) and both fixed in 5125e3c. Verified: the whole suite passes on v26.8.1 (sync path) and v24.16.0 (fallback), 83 passed, and the four new tests fail on the previous commit for exactly the four reported reasons. 1. Binary / non-UTF-8 2. Non-blocking notes
|
Brooooooklyn
left a comment
There was a problem hiding this comment.
Re-verified 5125e3c end-to-end — both findings are fixed and confirmed working on real binaries.
Verified
.node / non-UTF-8 commonjs deferral — the try_as_str guard in transform_output sits in the Some(source) arm before the node_modules skip, so .node and latin-1 defer correctly whether local or under node_modules:
| Scenario | Before (26.9) | Now (26.9) |
|---|---|---|
import './addon.node' |
Failed to convert Uint8Array |
addon: object |
import './latin.js' (0xe9) |
same crash | caf� (4 chars, matches old path) |
JSON correctly keeps its decode error (invalid UTF-8 there is a real failure).
importAttributes discriminator — re-probed both directions:
| Flag | import (extensionless .ts) |
require() |
|---|---|---|
--conditions=require |
dep-ok |
dep-ok |
--conditions=import |
dep-ok |
dep-ok |
require() contexts never carry importAttributes; import contexts always do — including under the flags. Neither direction can be poisoned now.
Suite — 83/83 on v26.9.0 (sync path) and v24.11.1 (fallback) with a fresh binding. Note: pnpm run test resolves node through Vite+'s bundled runtime (24.x here), so it exercises the fallback — the direct vitest binary under fnm is what actually runs the sync path. vp check clean; 61 CI checks green, and the new node: 26 matrix entry means CI now covers the synchronous path for the first time.
The four new spec tests are the right ones — extensionless import, CJS→ESM flip, and require() of .ts under both injected conditions, plus .node and latin-1. Using the package's own binding as the .node fixture is a nice touch.
Remaining notes (non-blocking)
commonjs-typescript+source: nullis still a contract violation in the abstract — it would produceERR_INVALID_RETURN_PROPERTY_VALUE— but it's only reachable viaimportof.ts/.ctsinsidenode_modules, which Node refuses on both paths anyway (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPINGon the old one). Dead-in-practice; if you ever want the shape airtight, exact-match=== "commonjs"in the JS deferral (and== "commonjs"in the Rust guard) makes the invalid shape unreachable while losing nothing — a node_modules.tswould then get the informative node error instead of one blaming the hook. Not worth churning on..nodeas a depmainfails on both paths (addonformat quirks) — pre-existing, unchanged by this PR.
LGTM.
|
Picked up the two remaining notes myself — "allow edits by maintainers" is off on this branch, so they're in cjnoname#1 against (1) surfaced while probing the sync path but reproduces on v22/v24 with Verified: 85/85 on v26.9.0 (sync, direct vitest — |
|
Opened #767 on the main repo: your three commits unchanged plus the two follow-ups (cjnoname#1), so the full matrix — including the new Linux node@26 jobs — runs on it. Either merge cjnoname#1 into this branch and I'll close #767, or we land #767 and it closes this one; your call, authorship stays yours in both. |
…pendency and addon edge cases (#767) Follow-up to #764 (now on `main`), for the notes left in its review. Two commits. ## Summary ``` transform_output register.mjs no-source arm ─► source: None ─► undefined startsWith("commonjs") also matched addon translator asserts === null ─► throws commonjs-typescript ─► null ─► throws fix: Some(Either4::D(Null)) fix: === "commonjs" (JS + Rust guard) LoadContext.format: Either<String, Null> Node passes format: undefined for an unknown extension (.node under node_modules, flag off) ─► napi "Missing field `format`" fix: Option<…> so Node's own ERR_UNKNOWN_FILE_EXTENSION shows ``` The first and third are pre-existing on both hook paths (shared code); only the `startsWith` came in with #764. **Tests** — `register-hooks.spec.ts`, 20 cases: local `.node` import (with and without `--experimental-addon-modules`), `require()` of a `.node`, latin-1; dependencies × `OXC_TRANSFORM_ALL` false/true set in-spec: addon with the flag, addon following Node's default (loads on ≥ 24.19 / ≥ 26.5, `ERR_UNKNOWN_FILE_EXTENSION` otherwise), latin-1, `.cts` and `.mts` → Node's own `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`; `--conditions=require|import` in both directions. Failing cases also assert neither `ERR_INVALID_RETURN_PROPERTY_VALUE` nor `Missing field` appears. **CI** — `test-linux-binding` gains Node 26 (armv7 excluded: `node:26-slim` ships no arm/v7 image, same as 24), so the synchronous path runs on every OS in the matrix. #### Test plan - [x] Full suite 95/95 on v22.23.2, v24.11.1, v26.9.0 (direct `vitest`; local `pnpm test` resolves Vite+'s bundled 24.x and only exercises the fallback) - [x] Spec alone on v24.21.0 (default-on addon branch on the 24 line) and with an outer `OXC_TRANSFORM_ALL=true` - [x] Each new test red before its fix, green after - [x] Rebased onto `main` after #764 landed; `cargo clippy -D warnings`, `cargo fmt --check`, `cargo test`, `vp check` - [ ] CI green across the 22/24/26 × transform-all matrix on all hosts Generated with [Devin](https://devin.ai) --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Problem
packages/core/register.mjscallsmodule.register(). Node.js runtime-deprecated thatAPI in v25.9.0 (DEP0205), so every
node --import @oxc-node/core/registernow prints:This is not only cosmetic —
pnpm testalready fails on Node.js 26 onmain, becausefour tests assert an empty stderr and the warning lands there. CI runs Node 22 and 24
only, so it has stayed invisible:
All four are the same diff:
+ (node:1427) [DEP0205] DeprecationWarning: ...Change
Use the synchronous, in-thread
module.registerHooks()where it is complete, and keepmodule.register()everywhere else. The ESM path is otherwise unchanged —resolvestillhands everything to
createResolveandloadtooxcLoad, exactly asesm.mjsdoes.Three things make this more than a one-line swap. Each was measured against release
binaries rather than reasoned about, and a naive replacement fails on all three.
1.
registerHooks()showsrequire()to the hooks;register()never didThe CommonJS resolve/load context carries no
importAttributes, and bothcreateResolveandloadreject a context without it:So a request whose
conditionscontainrequiregoes tonextResolve/nextLoad.Node.js resolves those correctly by itself, because the
pirateshook has put theTypeScript extensions into
Module._extensions— that is what lets its CommonJS resolvercomplete
require('./dep')to./dep.tsandrequire('./sub')to./sub/index.ts.2. A file that oxc-node settles on as CommonJS must go back to
nextLoadOtherwise the
pirateshook no longer produces the inline source map, and stack traceprecision regresses.
cli.spec.tscovers exactly this:module.register()stacktrace-cjs.cts:6:12✅registerHooks()stacktrace-cjs.cts:5:10❌Calling
oxcLoadfirst and only then deferring is what keeps #759 intact — aCommonJS-reported file that actually contains ESM syntax still runs as an ES module.
3.
registerHooksneeds a version floor, not atypeofcheckIt exists from v22.15.0/v23.5.0, but two defects had to be fixed first. Bisected over
every available minor:
require()in an imported CJS entryregisterHooks)Those boundaries are not arbitrary — they match the Node.js fixes exactly:
override in synchronous resolve hooks" — v24.5.0 (
fe0195fdcc), backported to v22.19.0(
0eec5cc492), never backported to the end-of-life 23.x line. Without it,import { jsx } from 'react/jsx-runtime'fails with does not provide an export named 'jsx'.short-circuit in require() in imported CJS" — v26.2.0 (
96f19a16d0). Without it, a.tsentry in a CommonJS package cannot
require()its own files.Hence the floor is v26.2.0. Below it the fallback runs and nothing changes for those
runtimes; v26.0/v26.1 keep the warning, which is the honest outcome given the defect.
Verification
34 release binaries, v22.14.0 → v26.8.2 (every available 22.x/23.x/24.x/25.x/26.x
minor), against three fixtures: a CommonJS
require()tree (extensionless.ts,directory
index.ts, explicit.cts), an ESM tree (extensionless,.mts, tsconfigpaths,.tsx+react/jsx-runtime), and acreateRequiretree.Upstream suite, same machine, Node v26.8.1:
main75 passedvp fmtandvp lintare clean.Test added
require()completing an extensionless TypeScript specifier had no coverage. The new casein
cjs-esm-syntax.spec.tsfails withCannot find module './dep'if thepirateshookstops registering those extensions — verified by removing it.