diff --git a/packages/core/esm.mjs b/packages/core/esm.mjs index 6f3efbb..44812e0 100644 --- a/packages/core/esm.mjs +++ b/packages/core/esm.mjs @@ -4,14 +4,19 @@ import { createResolve, initTracing, load } from "./index.js"; 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(); } -/** - * @type {import('node:module').ResolveHook} - */ +// 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( { diff --git a/packages/core/hooks.mjs b/packages/core/hooks.mjs new file mode 100644 index 0000000..9366a44 --- /dev/null +++ b/packages/core/hooks.mjs @@ -0,0 +1,122 @@ +import { createRequire } from "node:module"; +import { extname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// `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(); + +// 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)(?:[?#]|$)/; + +// 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 `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; + } + let parentPath; + if (typeof parentURL === "string") { + try { + parentPath = fileURLToPath(parentURL); + } catch { + parentPath = undefined; + } + } + const resolved = resolveCjsSpecifier(specifier, parentPath); + if (needsTransform(resolved)) { + return pathToFileURL(resolved).href; + } + 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 an extensionless TypeScript/JSX specifier Node would reject. + if (context?.conditions?.includes("require")) { + const completed = completeCommonJsTypeScript(specifier, context.parentURL); + return completed !== undefined + ? { url: completed, shortCircuit: true } + : nextResolve(specifier, context); + } + + // 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)) { + return nodeResolved; + } + } catch { + // Fall through to oxc-node's resolver below. + } + return createResolve({ getCurrentDirectory: getCwd }, specifier, context, nextResolve); +} + +function getCwd() { + return process.cwd(); +} + +/** + * @type {import('node:module').LoadHook} + */ +function load(url, context, nextLoad) { + // 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); +} + +export { load, resolve }; diff --git a/packages/core/package.json b/packages/core/package.json index 546ec99..87857fe 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 9658531..ed80656 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 9a0d777..bcba784 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, @@ -555,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); } @@ -566,6 +588,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 { @@ -573,8 +631,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)]