Skip to content
Open
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: 8 additions & 3 deletions packages/core/esm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
122 changes: 122 additions & 0 deletions packages/core/hooks.mjs
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"files": [
"browser.js",
"esm.mjs",
"hooks.mjs",
"index.d.ts",
"index.js",
"register.mjs",
Expand Down
12 changes: 10 additions & 2 deletions packages/core/register.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 });
Expand Down
80 changes: 70 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,10 +390,13 @@ fn oxc_transform<S: TryAsStr>(
#[napi(object)]
#[derive(Debug)]
pub struct ResolveContext {
/// Export conditions of the relevant `package.json`
pub conditions: Vec<String>,
/// An object whose key-value pairs represent the assertions for the module to import
pub import_attributes: HashMap<String, String>,
/// 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<Vec<String>>,
/// 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<HashMap<String, String>>,

#[napi(js_name = "parentURL")]
pub parent_url: Option<String>,
Expand Down Expand Up @@ -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);
Expand All @@ -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()));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand All @@ -566,15 +588,53 @@ 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<String>) -> Option<String> {
#[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 {
/// Export conditions of the relevant `package.json`
pub conditions: Option<Vec<String>>,
/// The format optionally supplied by the `resolve` hook chain
pub format: Either<String, Null>,
/// An object whose key-value pairs represent the assertions for the module to import
pub import_attributes: HashMap<String, String>,
/// 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<HashMap<String, String>>,
}

#[napi(object)]
Expand Down