From 2458083e842ba53b6c397cd2f0bcc71fdeb57d39 Mon Sep 17 00:00:00 2001 From: CJ Date: Sat, 13 Jun 2026 12:26:23 +1000 Subject: [PATCH 1/6] fix: migrate to module.registerHooks() and fix CommonJS interop regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit module.register() has been runtime-deprecated as DEP0205 since Node.js v25.9.0, emitting a deprecation warning on every invocation. Switch to the synchronous, in-thread module.registerHooks() (Node.js >= 23.5.0 / 22.15.0) when available, falling back to module.register() on older runtimes. The synchronous loader routes CommonJS require() and named-export detection through the customization hooks, which surfaced several interop differences that the old worker-thread loader hid. Fix them so behaviour is unchanged: - Defer modules oxc-node does not transform (plain .js/.cjs/.json, native addons, …) and anything Node classifies as commonjs back to Node.js, so its built-in CommonJS named-export detection (incl. transitive __export(require()) re-exports) and pirates-based transpilation/source maps keep working. - Complete missing TypeScript/JSX extensions for CommonJS require() specifiers, which the ESM-style resolve hook's nextResolve does not resolve via Module._extensions where pirates installs its handler. - Make ResolveContext/LoadContext conditions and importAttributes optional, as the CommonJS require() path does not always provide them. - Move the loader-thread keep-alive MessageChannel out of the shared hook module so it only runs on the dedicated register() loader thread, instead of pinning unrelated worker threads (e.g. test runners) and preventing process exit. Verified on Node.js 24 and 26 with the integrate-module, integrate-module-bundler and integrate-ava suites all green and no DEP0205 warning. --- packages/core/esm.mjs | 26 +++----- packages/core/hooks.mjs | 132 +++++++++++++++++++++++++++++++++++++ packages/core/package.json | 1 + packages/core/register.mjs | 12 +++- src/lib.rs | 31 ++++++--- 5 files changed, 174 insertions(+), 28 deletions(-) create mode 100644 packages/core/hooks.mjs diff --git a/packages/core/esm.mjs b/packages/core/esm.mjs index 6f3efbb9..63ccba65 100644 --- a/packages/core/esm.mjs +++ b/packages/core/esm.mjs @@ -1,26 +1,18 @@ import { isMainThread, MessageChannel } from "node:worker_threads"; -import { createResolve, initTracing, load } from "./index.js"; - -initTracing(); +import { load, resolve } from "./hooks.mjs"; +// This module is the entry point used by the asynchronous `module.register()` loader, +// which evaluates it on a dedicated loader thread. Keep that thread alive for the +// lifetime of the loader by holding a referenced port; otherwise it may exit early. +// +// The synchronous `module.registerHooks()` loader does not use this module — it imports +// the hooks directly from `./hooks.mjs` — so this keep-alive (which would otherwise +// pin unrelated worker threads, e.g. test runners, and prevent them from exiting) +// only ever runs on the dedicated loader thread. if (!isMainThread) { const mc = new MessageChannel(); mc.port1.ref(); } -/** - * @type {import('node:module').ResolveHook} - */ -function resolve(specifier, context, nextResolve) { - return createResolve( - { - getCurrentDirectory: () => process.cwd(), - }, - specifier, - context, - nextResolve, - ); -} - export { load, resolve }; diff --git a/packages/core/hooks.mjs b/packages/core/hooks.mjs new file mode 100644 index 00000000..e7af9c7d --- /dev/null +++ b/packages/core/hooks.mjs @@ -0,0 +1,132 @@ +import { existsSync } from "node:fs"; +import { dirname, extname, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createResolve, initTracing, load as oxcLoad } from "./index.js"; + +initTracing(); + +// Extensions that oxc-node is responsible for transforming (TypeScript / JSX / etc.). +// Anything that resolves to a different kind of file (plain `.js`, `.cjs`, `.json`, +// native addons, …) is handed straight back to Node.js untouched. +// +// This is required for the synchronous `module.registerHooks()` loader: when a +// CommonJS module is resolved and loaded through a customization hook, Node.js can +// no longer follow transitive `require()` re-exports (e.g. `__export(require("./src"))`) +// while detecting named exports, so `import { foo } from "cjs-pkg"` would fail with +// "does not provide an export named 'foo'". The asynchronous `module.register()` loader +// (which ran hooks on a worker thread) did not have this limitation. By leaving such +// modules entirely to Node.js, its built-in CommonJS named-export detection keeps working. +const OXC_TRANSFORM_EXTENSIONS = /\.(?:[mc]?tsx?|jsx|es6?|es)$/; + +// Extensions that the CommonJS loader cannot resolve on its own but that +// oxc-node's `pirates` hook can compile (registered in `register.mjs`). +const CJS_RESOLVABLE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".jsx"]; + +function isOxcTransformable(url) { + if (typeof url !== "string") { + return false; + } + try { + return OXC_TRANSFORM_EXTENSIONS.test(new URL(url).pathname); + } catch { + return false; + } +} + +function isCommonJsRequire(context) { + return Array.isArray(context?.conditions) && context.conditions.includes("require"); +} + +// Best-effort extensionless completion for relative/absolute `require()` specifiers +// that point at TypeScript/JSX files. Under the synchronous `module.registerHooks()` +// loader, CommonJS `require()` calls are routed through this ESM-style resolve hook, +// whose `nextResolve` does not consult `Module._extensions` (where `pirates` installs +// its `.ts` handler). Returning a specifier that already carries an extension lets the +// CommonJS loader find the file and `pirates` transpile it, matching the behaviour of +// the legacy `module.register()` worker-thread loader. +function completeCjsExtension(specifier, parentURL) { + if (extname(specifier) !== "" || (!specifier.startsWith(".") && !specifier.startsWith("/"))) { + return undefined; + } + let basedir; + try { + basedir = typeof parentURL === "string" ? dirname(fileURLToPath(parentURL)) : process.cwd(); + } catch { + return undefined; + } + const absolute = resolvePath(basedir, specifier); + for (const ext of CJS_RESOLVABLE_EXTENSIONS) { + if (existsSync(absolute + ext)) { + return specifier + ext; + } + } + for (const ext of CJS_RESOLVABLE_EXTENSIONS) { + if (existsSync(resolvePath(absolute, `index${ext}`))) { + return `${specifier}/index${ext}`; + } + } + return undefined; +} + +/** + * @type {import('node:module').ResolveHook} + */ +function resolve(specifier, context, nextResolve) { + // CommonJS `require()` (only reachable under `module.registerHooks()`). Keep these on + // Node's built-in CommonJS resolution + `pirates` transpilation instead of oxc-node's + // ESM resolver, which would hand the CommonJS loader an ESM `file:` URL it cannot load. + // We only step in to complete a missing TypeScript/JSX extension that Node's resolver + // would otherwise reject. + if (isCommonJsRequire(context)) { + const completed = completeCjsExtension(specifier, context.parentURL); + return nextResolve(completed ?? specifier, context); + } + + // Let Node.js resolve first. If the target is not something oxc-node needs to + // transform, return Node's own result verbatim — this keeps the resolution + // metadata Node.js relies on (notably for CommonJS named-export detection in the + // synchronous hooks loader) instead of round-tripping it through the native binding. + // + // Node's default resolver does not understand TypeScript-only constructs such as + // tsconfig `paths` aliases or extensionless `.ts` imports, so a failure here simply + // means the specifier needs oxc-node's resolver. + try { + const nodeResolved = nextResolve(specifier, context); + if (nodeResolved && !isOxcTransformable(nodeResolved.url)) { + return nodeResolved; + } + } catch { + // Fall through to oxc-node's resolver below. + } + return createResolve( + { + getCurrentDirectory: () => process.cwd(), + }, + specifier, + context, + nextResolve, + ); +} + +/** + * @type {import('node:module').LoadHook} + */ +function load(url, context, nextLoad) { + // Files that oxc-node is not responsible for are deferred to Node.js so its native + // CommonJS handling (including transitive re-export detection) keeps working. + if (!isOxcTransformable(url)) { + return nextLoad(url, context); + } + // CommonJS sources (e.g. `.cts`, or `.ts` inside a `"type": "commonjs"` scope) are + // transpiled by the `pirates` CommonJS hook installed in `register.mjs`, which emits + // CommonJS output with an accurate inline source map. Routing them through the ESM + // `load` binding instead would yield ESM output and a mismatched source map, so defer + // to Node.js (and thus `pirates`) for anything Node has classified as CommonJS. + if (context?.format === "commonjs") { + return nextLoad(url, context); + } + return oxcLoad(url, context, nextLoad); +} + +export { load, resolve }; diff --git a/packages/core/package.json b/packages/core/package.json index 546ec99d..87857fe2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "files": [ "browser.js", "esm.mjs", + "hooks.mjs", "index.d.ts", "index.js", "register.mjs", diff --git a/packages/core/register.mjs b/packages/core/register.mjs index 96585315..ed806560 100644 --- a/packages/core/register.mjs +++ b/packages/core/register.mjs @@ -5,7 +5,7 @@ import { addHook } from "pirates"; import { OxcTransformer } from "./index.js"; // Destructure from NodeModule namespace to support older Node.js versions -const { register, setSourceMapsSupport } = NodeModule; +const { register, registerHooks, setSourceMapsSupport } = NodeModule; const DEFAULT_EXTENSIONS = new Set([ ".js", @@ -20,7 +20,15 @@ const DEFAULT_EXTENSIONS = new Set([ ".es", ]); -register("@oxc-node/core/esm", import.meta.url); +// Prefer the synchronous, in-thread `module.registerHooks()` (Node.js >= 23.5.0 / 22.15.0). +// It avoids the DEP0205 deprecation warning emitted by `module.register()`, which has been +// runtime-deprecated since Node.js v25.9.0. Fall back to `module.register()` on older runtimes. +if (typeof registerHooks === "function") { + const { load, resolve } = await import("./hooks.mjs"); + registerHooks({ load, resolve }); +} else { + register("@oxc-node/core/esm", import.meta.url); +} if (typeof setSourceMapsSupport === "function") { setSourceMapsSupport(true, { nodeModules: true, generatedCode: true }); diff --git a/src/lib.rs b/src/lib.rs index 9a0d777a..9e7ebf58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -390,10 +390,13 @@ fn oxc_transform( #[napi(object)] #[derive(Debug)] pub struct ResolveContext { - /// Export conditions of the relevant `package.json` - pub conditions: Vec, - /// An object whose key-value pairs represent the assertions for the module to import - pub import_attributes: HashMap, + /// Export conditions of the relevant `package.json`. + /// Optional because the CommonJS `require()` path of the synchronous + /// `module.registerHooks()` loader does not always provide it. + pub conditions: Option>, + /// An object whose key-value pairs represent the assertions for the module to import. + /// Optional for the same reason as `conditions`. + pub import_attributes: Option>, #[napi(js_name = "parentURL")] pub parent_url: Option, @@ -454,7 +457,11 @@ pub fn create_resolve<'env>( } if specifier.ends_with(".json") { tracing::debug!("short-circuiting JSON resolve: {}", specifier); - if context.import_attributes.contains_key("type") { + if context + .import_attributes + .as_ref() + .is_some_and(|attrs| attrs.contains_key("type")) + { return add_short_circuit(specifier, Some("json"), context, next_resolve); } return add_short_circuit(specifier, Some("module"), context, next_resolve); @@ -472,7 +479,7 @@ pub fn create_resolve<'env>( #[cfg(not(target_family = "wasm"))] let cwd = env::current_dir()?; - let conditions = context.conditions.as_slice(); + let conditions = context.conditions.as_deref().unwrap_or(&[]); let (resolver, tsconfig, default_module_resolved_from_tsconfig) = RESOLVER_AND_TSCONFIG.get_or_init(|| init_resolver(cwd.clone(), conditions.to_vec())); @@ -512,7 +519,11 @@ pub fn create_resolve<'env>( ); // import attributes - if !context.import_attributes.is_empty() { + if context + .import_attributes + .as_ref() + .is_some_and(|attrs| !attrs.is_empty()) + { tracing::debug!( "short-circuiting import attributes resolve: {}, attributes: {:?}", specifier, @@ -573,8 +584,10 @@ pub struct LoadContext { pub conditions: Option>, /// The format optionally supplied by the `resolve` hook chain pub format: Either, - /// An object whose key-value pairs represent the assertions for the module to import - pub import_attributes: HashMap, + /// An object whose key-value pairs represent the assertions for the module to import. + /// Optional because the CommonJS `require()` path of the synchronous + /// `module.registerHooks()` loader does not always provide it. + pub import_attributes: Option>, } #[napi(object)] From bbe02f398abf8c2fb73d5909a2e60189854d7e2c Mon Sep 17 00:00:00 2001 From: CJ Date: Sat, 13 Jun 2026 12:35:06 +1000 Subject: [PATCH 2/6] refactor: resolve CommonJS require() extensions via oxc resolver Replace the filesystem-probing (existsSync) extension completion for CommonJS require() of TypeScript files with a dedicated native resolver entry, resolveCjsSpecifier(). It reuses oxc-node's resolver so tsconfig `paths`, package `exports`, conditions and symlinks are all honoured (the previous best-effort probing only handled relative/absolute specifiers), and removes the synchronous filesystem stat loop from the hot resolve path. --- packages/core/hooks.mjs | 64 +++++++++++++++++++---------------------- src/lib.rs | 36 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/packages/core/hooks.mjs b/packages/core/hooks.mjs index e7af9c7d..71dbb2fd 100644 --- a/packages/core/hooks.mjs +++ b/packages/core/hooks.mjs @@ -1,8 +1,7 @@ -import { existsSync } from "node:fs"; -import { dirname, extname, resolve as resolvePath } from "node:path"; -import { fileURLToPath } from "node:url"; +import { extname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; -import { createResolve, initTracing, load as oxcLoad } from "./index.js"; +import { createResolve, initTracing, load as oxcLoad, resolveCjsSpecifier } from "./index.js"; initTracing(); @@ -19,10 +18,6 @@ initTracing(); // modules entirely to Node.js, its built-in CommonJS named-export detection keeps working. const OXC_TRANSFORM_EXTENSIONS = /\.(?:[mc]?tsx?|jsx|es6?|es)$/; -// Extensions that the CommonJS loader cannot resolve on its own but that -// oxc-node's `pirates` hook can compile (registered in `register.mjs`). -const CJS_RESOLVABLE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".jsx"]; - function isOxcTransformable(url) { if (typeof url !== "string") { return false; @@ -38,33 +33,29 @@ function isCommonJsRequire(context) { return Array.isArray(context?.conditions) && context.conditions.includes("require"); } -// Best-effort extensionless completion for relative/absolute `require()` specifiers -// that point at TypeScript/JSX files. Under the synchronous `module.registerHooks()` -// loader, CommonJS `require()` calls are routed through this ESM-style resolve hook, -// whose `nextResolve` does not consult `Module._extensions` (where `pirates` installs -// its `.ts` handler). Returning a specifier that already carries an extension lets the -// CommonJS loader find the file and `pirates` transpile it, matching the behaviour of -// the legacy `module.register()` worker-thread loader. -function completeCjsExtension(specifier, parentURL) { - if (extname(specifier) !== "" || (!specifier.startsWith(".") && !specifier.startsWith("/"))) { +// Complete an extensionless CommonJS `require()` specifier that points at a file +// oxc-node transforms, using oxc-node's own resolver (which honours tsconfig `paths`, +// package `exports`, conditions and symlinks). Under the synchronous +// `module.registerHooks()` loader, CommonJS `require()` is routed through this +// ESM-style resolve hook, whose `nextResolve` does not consult `Module._extensions` +// (where `pirates` installs its TypeScript handler). Returning a concrete `file:` URL +// lets the CommonJS loader find the file and `pirates` transpile it, matching the +// behaviour of the legacy `module.register()` worker-thread loader. +function completeCommonJsTypeScript(specifier, parentURL) { + if (extname(specifier) !== "") { return undefined; } - let basedir; - try { - basedir = typeof parentURL === "string" ? dirname(fileURLToPath(parentURL)) : process.cwd(); - } catch { - return undefined; - } - const absolute = resolvePath(basedir, specifier); - for (const ext of CJS_RESOLVABLE_EXTENSIONS) { - if (existsSync(absolute + ext)) { - return specifier + ext; + let parentPath; + if (typeof parentURL === "string") { + try { + parentPath = fileURLToPath(parentURL); + } catch { + parentPath = undefined; } } - for (const ext of CJS_RESOLVABLE_EXTENSIONS) { - if (existsSync(resolvePath(absolute, `index${ext}`))) { - return `${specifier}/index${ext}`; - } + const resolved = resolveCjsSpecifier(specifier, parentPath); + if (typeof resolved === "string" && OXC_TRANSFORM_EXTENSIONS.test(resolved)) { + return pathToFileURL(resolved).href; } return undefined; } @@ -76,11 +67,14 @@ function resolve(specifier, context, nextResolve) { // CommonJS `require()` (only reachable under `module.registerHooks()`). Keep these on // Node's built-in CommonJS resolution + `pirates` transpilation instead of oxc-node's // ESM resolver, which would hand the CommonJS loader an ESM `file:` URL it cannot load. - // We only step in to complete a missing TypeScript/JSX extension that Node's resolver - // would otherwise reject. + // We only step in to complete an extensionless TypeScript/JSX specifier that Node's + // CommonJS resolver would otherwise reject. if (isCommonJsRequire(context)) { - const completed = completeCjsExtension(specifier, context.parentURL); - return nextResolve(completed ?? specifier, context); + const completed = completeCommonJsTypeScript(specifier, context.parentURL); + if (completed !== undefined) { + return { url: completed, shortCircuit: true }; + } + return nextResolve(specifier, context); } // Let Node.js resolve first. If the target is not something oxc-node needs to diff --git a/src/lib.rs b/src/lib.rs index 9e7ebf58..52561597 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -577,6 +577,42 @@ pub fn create_resolve<'env>( add_short_circuit(specifier, None, context, next_resolve) } +/// Resolve a CommonJS `require()` specifier to an absolute file path using oxc-node's +/// resolver (which honours tsconfig `paths`, package `exports`, conditions, symlinks, …). +/// +/// This exists for the synchronous `module.registerHooks()` loader: CommonJS `require()` +/// calls are routed through the ESM-style `resolve` hook, whose `nextResolve` does not +/// consult `Module._extensions` (where `pirates` installs its TypeScript handler). The +/// hook uses this to complete an extensionless specifier to a concrete file path that the +/// CommonJS loader can then hand to `pirates` for transpilation. Returns `None` when the +/// specifier cannot be resolved (so the caller can defer to Node.js' own resolver). +#[napi] +#[cfg_attr(target_family = "wasm", allow(unused_variables))] +pub fn resolve_cjs_specifier(specifier: String, parent_path: Option) -> Option { + #[cfg(target_family = "wasm")] + { + None + } + + #[cfg(not(target_family = "wasm"))] + { + let cwd = env::current_dir().ok()?; + let (resolver, _, _) = RESOLVER_AND_TSCONFIG.get_or_init(|| { + init_resolver(cwd.clone(), vec!["require".to_owned(), "node".to_owned()]) + }); + + let directory = parent_path + .as_deref() + .and_then(|parent| Path::new(parent).parent()) + .unwrap_or(cwd.as_path()); + + resolver + .resolve(directory, &specifier) + .ok() + .map(|resolution| resolution.full_path().to_string_lossy().into_owned()) + } +} + #[napi(object)] #[derive(Debug)] pub struct LoadContext { From 9ce253aab8705d4fa7c24a71d98eca52b221a210 Mon Sep 17 00:00:00 2001 From: CJ Date: Sat, 13 Jun 2026 12:46:53 +1000 Subject: [PATCH 3/6] perf: avoid URL allocation and harden native binding import in hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the per-resolve/per-load `new URL(url).pathname` transformability check with a substring regex on the raw string (~9.6x faster on the hot path; extension characters are never percent-encoded so this is safe), and check the cheap `format === 'commonjs'` branch before it in the load hook. - Load index.js via createRequire instead of a named ESM import. The native binding's exports are only statically detectable once oxc-node's hooks are active, so a named `import { … } from './index.js'` could fail to find exports under some bootstrap entrypoints (e.g. --eval). This also avoids ESM named-export detection overhead. --- packages/core/hooks.mjs | 127 ++++++++++++++++++---------------------- 1 file changed, 57 insertions(+), 70 deletions(-) diff --git a/packages/core/hooks.mjs b/packages/core/hooks.mjs index 71dbb2fd..cf1718d6 100644 --- a/packages/core/hooks.mjs +++ b/packages/core/hooks.mjs @@ -1,46 +1,46 @@ +import { createRequire } from "node:module"; import { extname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { createResolve, initTracing, load as oxcLoad, resolveCjsSpecifier } from "./index.js"; +// `index.js` is CommonJS. Load it with `createRequire` rather than a named ESM import: +// its native exports are only statically detectable once oxc-node's own hooks are active, +// so a top-level `import { … } from "./index.js"` can fail to resolve freshly added +// exports in some bootstrap scenarios (e.g. `--eval` entrypoints). +const { + createResolve, + initTracing, + load: oxcLoad, + resolveCjsSpecifier, +} = createRequire(import.meta.url)("./index.js"); initTracing(); -// Extensions that oxc-node is responsible for transforming (TypeScript / JSX / etc.). -// Anything that resolves to a different kind of file (plain `.js`, `.cjs`, `.json`, -// native addons, …) is handed straight back to Node.js untouched. -// -// This is required for the synchronous `module.registerHooks()` loader: when a -// CommonJS module is resolved and loaded through a customization hook, Node.js can -// no longer follow transitive `require()` re-exports (e.g. `__export(require("./src"))`) -// while detecting named exports, so `import { foo } from "cjs-pkg"` would fail with -// "does not provide an export named 'foo'". The asynchronous `module.register()` loader -// (which ran hooks on a worker thread) did not have this limitation. By leaving such -// modules entirely to Node.js, its built-in CommonJS named-export detection keeps working. -const OXC_TRANSFORM_EXTENSIONS = /\.(?:[mc]?tsx?|jsx|es6?|es)$/; +// File extensions oxc-node is responsible for transforming (TypeScript / JSX / etc.). +// Anything resolving to a different kind of file (plain `.js`, `.cjs`, `.json`, native +// addons, …) is handed straight back to Node.js untouched. This matters for the +// synchronous `module.registerHooks()` loader: when a CommonJS module is resolved and +// loaded through a customization hook, Node.js can no longer follow transitive +// `require()` re-exports (e.g. `__export(require("./src"))`) while detecting named +// exports, so `import { foo } from "cjs-pkg"` would fail. The legacy `module.register()` +// worker-thread loader did not have this limitation. By leaving such modules entirely to +// Node.js, its built-in CommonJS named-export detection keeps working. +// Extensions oxc-node actually transforms. Matched against the raw resolved string +// (no `new URL()` allocation, which dominated the hot path) — extension characters are +// never percent-encoded, so a substring match is safe. +const TRANSFORM_EXTENSION = /\.(?:[mc]?tsx?|jsx|es6?|es)(?:[?#]|$)/; -function isOxcTransformable(url) { - if (typeof url !== "string") { - return false; - } - try { - return OXC_TRANSFORM_EXTENSIONS.test(new URL(url).pathname); - } catch { - return false; - } -} - -function isCommonJsRequire(context) { - return Array.isArray(context?.conditions) && context.conditions.includes("require"); +// Whether a resolved URL/path points at a file oxc-node must transform. +function needsTransform(url) { + return typeof url === "string" && TRANSFORM_EXTENSION.test(url); } -// Complete an extensionless CommonJS `require()` specifier that points at a file -// oxc-node transforms, using oxc-node's own resolver (which honours tsconfig `paths`, -// package `exports`, conditions and symlinks). Under the synchronous -// `module.registerHooks()` loader, CommonJS `require()` is routed through this -// ESM-style resolve hook, whose `nextResolve` does not consult `Module._extensions` -// (where `pirates` installs its TypeScript handler). Returning a concrete `file:` URL -// lets the CommonJS loader find the file and `pirates` transpile it, matching the -// behaviour of the legacy `module.register()` worker-thread loader. +// Complete an extensionless CommonJS `require()` specifier that points at a file oxc-node +// transforms, using oxc-node's own resolver (which honours tsconfig `paths`, package +// `exports`, conditions and symlinks). Under `module.registerHooks()`, CommonJS +// `require()` is routed through this ESM-style resolve hook, whose `nextResolve` does not +// consult `Module._extensions` (where `pirates` installs its TypeScript handler). +// Returning a concrete `file:` URL lets the CommonJS loader find the file and `pirates` +// transpile it, matching the legacy `module.register()` worker-thread loader. function completeCommonJsTypeScript(specifier, parentURL) { if (extname(specifier) !== "") { return undefined; @@ -54,7 +54,7 @@ function completeCommonJsTypeScript(specifier, parentURL) { } } const resolved = resolveCjsSpecifier(specifier, parentPath); - if (typeof resolved === "string" && OXC_TRANSFORM_EXTENSIONS.test(resolved)) { + if (needsTransform(resolved)) { return pathToFileURL(resolved).href; } return undefined; @@ -67,57 +67,44 @@ function resolve(specifier, context, nextResolve) { // CommonJS `require()` (only reachable under `module.registerHooks()`). Keep these on // Node's built-in CommonJS resolution + `pirates` transpilation instead of oxc-node's // ESM resolver, which would hand the CommonJS loader an ESM `file:` URL it cannot load. - // We only step in to complete an extensionless TypeScript/JSX specifier that Node's - // CommonJS resolver would otherwise reject. - if (isCommonJsRequire(context)) { + // We only step in to complete an extensionless TypeScript/JSX specifier Node would reject. + if (context?.conditions?.includes("require")) { const completed = completeCommonJsTypeScript(specifier, context.parentURL); - if (completed !== undefined) { - return { url: completed, shortCircuit: true }; - } - return nextResolve(specifier, context); + return completed !== undefined + ? { url: completed, shortCircuit: true } + : nextResolve(specifier, context); } - // Let Node.js resolve first. If the target is not something oxc-node needs to - // transform, return Node's own result verbatim — this keeps the resolution - // metadata Node.js relies on (notably for CommonJS named-export detection in the - // synchronous hooks loader) instead of round-tripping it through the native binding. - // - // Node's default resolver does not understand TypeScript-only constructs such as - // tsconfig `paths` aliases or extensionless `.ts` imports, so a failure here simply - // means the specifier needs oxc-node's resolver. + // Let Node.js resolve first. If the target is not something oxc-node transforms, return + // Node's own result verbatim — preserving the resolution metadata Node relies on + // (notably for CommonJS named-export detection) instead of round-tripping it through the + // native binding. Node's resolver cannot handle TypeScript-only constructs (tsconfig + // `paths`, extensionless `.ts`, …), so a failure simply means oxc-node must resolve it. try { const nodeResolved = nextResolve(specifier, context); - if (nodeResolved && !isOxcTransformable(nodeResolved.url)) { + if (nodeResolved !== undefined && !needsTransform(nodeResolved.url)) { return nodeResolved; } } catch { // Fall through to oxc-node's resolver below. } - return createResolve( - { - getCurrentDirectory: () => process.cwd(), - }, - specifier, - context, - nextResolve, - ); + return createResolve({ getCurrentDirectory: getCwd }, specifier, context, nextResolve); +} + +function getCwd() { + return process.cwd(); } /** * @type {import('node:module').LoadHook} */ function load(url, context, nextLoad) { - // Files that oxc-node is not responsible for are deferred to Node.js so its native - // CommonJS handling (including transitive re-export detection) keeps working. - if (!isOxcTransformable(url)) { - return nextLoad(url, context); - } - // CommonJS sources (e.g. `.cts`, or `.ts` inside a `"type": "commonjs"` scope) are - // transpiled by the `pirates` CommonJS hook installed in `register.mjs`, which emits - // CommonJS output with an accurate inline source map. Routing them through the ESM - // `load` binding instead would yield ESM output and a mismatched source map, so defer - // to Node.js (and thus `pirates`) for anything Node has classified as CommonJS. - if (context?.format === "commonjs") { + // Cheap checks first: only transform when Node classified the module as ESM ("module") + // and it is a file oxc-node owns. CommonJS sources (`.cts`, or `.ts` in a + // `"type": "commonjs"` scope) are transpiled by the `pirates` CommonJS hook installed in + // `register.mjs`, which emits CommonJS output with an accurate inline source map; routing + // them through the ESM `load` binding would yield ESM output and a mismatched source map. + if (context?.format === "commonjs" || !needsTransform(url)) { return nextLoad(url, context); } return oxcLoad(url, context, nextLoad); From ac20072e9ab4a9b22622daa132ddb78beab9c398 Mon Sep 17 00:00:00 2001 From: CJ Date: Sat, 13 Jun 2026 13:00:07 +1000 Subject: [PATCH 4/6] perf: skip speculative nextResolve for explicit TypeScript specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A specifier that already carries a transformable extension (e.g. `./foo.ts`) is always oxc-node's to resolve, so the speculative Node `nextResolve` — whose result would only be discarded — can be skipped, resolving relative TypeScript imports once instead of twice. ~18% faster on a TypeScript-heavy module graph (200 modules: 36.5ms -> 29.9ms). --- packages/core/hooks.mjs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/core/hooks.mjs b/packages/core/hooks.mjs index cf1718d6..9366a443 100644 --- a/packages/core/hooks.mjs +++ b/packages/core/hooks.mjs @@ -75,11 +75,20 @@ function resolve(specifier, context, nextResolve) { : nextResolve(specifier, context); } - // Let Node.js resolve first. If the target is not something oxc-node transforms, return - // Node's own result verbatim — preserving the resolution metadata Node relies on - // (notably for CommonJS named-export detection) instead of round-tripping it through the - // native binding. Node's resolver cannot handle TypeScript-only constructs (tsconfig - // `paths`, extensionless `.ts`, …), so a failure simply means oxc-node must resolve it. + // Fast path: a specifier that already carries a transformable extension (e.g. + // `./foo.ts`) is unconditionally oxc-node's to resolve, so skip the speculative + // `nextResolve` below (whose result would only be discarded). This avoids resolving + // every relative TypeScript import twice. + if (needsTransform(specifier)) { + return createResolve({ getCurrentDirectory: getCwd }, specifier, context, nextResolve); + } + + // Otherwise let Node.js resolve first. If the target is not something oxc-node + // transforms, return Node's own result verbatim — preserving the resolution metadata + // Node relies on (notably for CommonJS named-export detection) instead of round-tripping + // it through the native binding. Node's resolver cannot handle TypeScript-only constructs + // (tsconfig `paths`, extensionless `.ts`, …), so a failure simply means oxc-node must + // resolve it. try { const nodeResolved = nextResolve(specifier, context); if (nodeResolved !== undefined && !needsTransform(nodeResolved.url)) { From a3a4f5ad6ddf97975bf906c7c16bde63557bf0c9 Mon Sep 17 00:00:00 2001 From: CJ Date: Sat, 13 Jun 2026 13:05:46 +1000 Subject: [PATCH 5/6] perf: return resolved output directly instead of re-resolving via Node For modules oxc-node has fully resolved outside node_modules (URL + concrete format already known), build the resolve hook output directly rather than calling nextResolve with the resolved URL. The latter made Node redundantly re-read package.json and stat the file for a path oxc-node had already resolved. node_modules modules still defer to Node so CommonJS named-export detection is unaffected. ~20% faster on a TypeScript-heavy module graph (500 modules: 70.5ms -> 56.2ms). --- src/lib.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 52561597..bcba7846 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -566,7 +566,18 @@ pub fn create_resolve<'env>( tracing::debug!(path = ?p, format = ?format); format }; - return add_short_circuit(url, Some(format), context, next_resolve); + // oxc-node has fully resolved this module (URL + format), so return the + // output directly instead of calling `next_resolve`. Re-resolving the already + // resolved URL through Node would redundantly re-read `package.json` and `stat` + // the file. This is only done for files oxc-node transforms (outside + // `node_modules`); `node_modules` modules still defer to Node below so its + // CommonJS named-export detection keeps working. + return Ok(Either::A(ResolveFnOutput { + format: Some(Either::A(format.to_owned())), + short_circuit: Some(true), + url, + import_attributes: context.import_attributes.map(Either::A), + })); } else { return add_short_circuit(url, None, context, next_resolve); } From f427b4ee59403a54f02042118b6214dcd5b79904 Mon Sep 17 00:00:00 2001 From: CJ Date: Sat, 13 Jun 2026 13:26:22 +1000 Subject: [PATCH 6/6] fix: use plain resolver for the register() worker fallback The synchronous-loader fast paths in hooks.mjs rely on a synchronous nextResolve (only true under module.registerHooks()). Under the module.register() fallback, hooks run on a worker thread where nextResolve is asynchronous, so the try/catch guarding the speculative resolve cannot catch its rejection and tsconfig paths aliases threw ERR_MODULE_NOT_FOUND. The async worker loader also does not have the CommonJS interop limitations hooks.mjs works around, so the register() entry (esm.mjs) now uses the plain resolver directly, while registerHooks keeps using the optimized hooks.mjs. --- packages/core/esm.mjs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/core/esm.mjs b/packages/core/esm.mjs index 63ccba65..44812e0a 100644 --- a/packages/core/esm.mjs +++ b/packages/core/esm.mjs @@ -1,18 +1,31 @@ import { isMainThread, MessageChannel } from "node:worker_threads"; -import { load, resolve } from "./hooks.mjs"; +import { createResolve, initTracing, load } from "./index.js"; -// This module is the entry point used by the asynchronous `module.register()` loader, -// which evaluates it on a dedicated loader thread. Keep that thread alive for the -// lifetime of the loader by holding a referenced port; otherwise it may exit early. -// -// The synchronous `module.registerHooks()` loader does not use this module — it imports -// the hooks directly from `./hooks.mjs` — so this keep-alive (which would otherwise -// pin unrelated worker threads, e.g. test runners, and prevent them from exiting) -// only ever runs on the dedicated loader thread. +initTracing(); + +// This module is the entry point used by the asynchronous `module.register()` loader +// (the fallback for Node.js versions without `module.registerHooks()`), which evaluates +// it on a dedicated loader thread. Keep that thread alive for the lifetime of the loader +// by holding a referenced port; otherwise it may exit early. if (!isMainThread) { const mc = new MessageChannel(); mc.port1.ref(); } +// The asynchronous worker-thread loader does not exhibit the synchronous-loader CommonJS +// interop issues that `hooks.mjs` works around (named-export detection, `require()` of +// TypeScript, source maps), and its `nextResolve` is async — so the synchronous-only +// fast paths in `hooks.mjs` do not apply here. Use the plain resolver directly. +function resolve(specifier, context, nextResolve) { + return createResolve( + { + getCurrentDirectory: () => process.cwd(), + }, + specifier, + context, + nextResolve, + ); +} + export { load, resolve };