Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ jobs:
node:
- "22"
- "24"
# The synchronous `module.registerHooks()` path only runs from v26.2.0 up, so
# without a 26 job nothing here exercises it.
- "26"
transform_all:
- "true"
- "false"
Expand Down Expand Up @@ -317,9 +320,15 @@ jobs:
node:
- "22"
- "24"
# The synchronous `module.registerHooks()` path only runs from v26.2.0 up, so
# without a 26 job no Linux target exercises it.
- "26"
exclude:
- target: armv7-unknown-linux-gnueabihf
node: "24"
# `node:26-slim` ships no arm/v7 image either.
- target: armv7-unknown-linux-gnueabihf
node: "26"
# Node.js on qemu segfaults on s390x and arm64v8 when using 24.04
# See also https://github.com/actions/runner-images/issues/11471
runs-on: ${{ contains(matrix.target, 'aarch64') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
Expand Down
10 changes: 8 additions & 2 deletions packages/core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ export declare function load(url: string, context: LoadContext, nextLoad: (arg0:
export interface LoadContext {
/** Export conditions of the relevant `package.json` */
conditions?: Array<string>
/** The format optionally supplied by the `resolve` hook chain */
format: string | null
/**
* The format optionally supplied by the `resolve` hook chain. Node.js passes it as
* `undefined`, not `null`, when the chain reported none — a `.node` or `.wasm` file
* resolved without its flag, any extension Node.js does not know — and a required
* field would reject the whole context with "Missing field `format`" instead of
* letting Node.js raise its own `ERR_UNKNOWN_FILE_EXTENSION`.
*/
format?: string | null
/** An object whose key-value pairs represent the assertions for the module to import */
importAttributes: Record<string, string>
}
Expand Down
117 changes: 113 additions & 4 deletions packages/core/register.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import * as NodeModule from "node:module";

import { addHook } from "pirates";

import { OxcTransformer } from "./index.js";
import { OxcTransformer, createResolve, initTracing, load as oxcLoad } 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,8 +20,6 @@ const DEFAULT_EXTENSIONS = new Set([
".es",
]);

register("@oxc-node/core/esm", import.meta.url);

if (typeof setSourceMapsSupport === "function") {
setSourceMapsSupport(true, { nodeModules: true, generatedCode: true });
} else if (typeof process.setSourceMapsEnabled === "function") {
Expand Down Expand Up @@ -49,3 +47,114 @@ addHook(
ext: Array.from(DEFAULT_EXTENSIONS),
},
);

/**
* Whether this request comes from `require()`.
*
* `module.register()` never showed `require()` to the hooks; `module.registerHooks()`
* does. Those requests stay on Node.js' own CommonJS resolution and the `pirates` hook
* above — exactly where they were before. Node.js resolves them correctly on its own:
* the `pirates` hook registers the TypeScript extensions in `Module._extensions`, which
* is what lets its CommonJS resolver complete `require('./foo')` to `./foo.ts`.
*
* The discriminator is `importAttributes`, the field that decides whether the native
* hooks can run at all: `createResolve` and `load` both take it as a required property
* and reject a context without it. A CommonJS context never carries it, an ESM context
* always does — even when empty. `conditions` cannot be used for this: `--conditions`
* appends its values to *every* request, so `--conditions=require` would send imports
* down the CommonJS path (`ERR_MODULE_NOT_FOUND` for an extensionless specifier) and
* `--conditions=import` would send `require()` down the ESM one.
*
* @param {{ importAttributes?: Record<string, string> } | undefined} context
* @returns {boolean}
*/
function isCommonJsRequire(context) {
return context?.importAttributes === undefined;
}

/**
* @type {import('node:module').ResolveHook}
*/
function resolve(specifier, context, nextResolve) {
if (isCommonJsRequire(context)) {
return nextResolve(specifier, context);
}
return createResolve(
{
getCurrentDirectory: () => process.cwd(),
},
specifier,
context,
nextResolve,
);
}

/**
* @type {import('node:module').LoadHook}
*/
function load(url, context, nextLoad) {
if (isCommonJsRequire(context)) {
return nextLoad(url, context);
}
const result = oxcLoad(url, context, nextLoad);
// Anything oxc-node itself settles on as CommonJS is left to the CommonJS machinery,
// which compiles it through the `pirates` hook above and its accurate inline source
// map. Returning source from here instead costs stack trace precision: a throw in a
// `.cts` entry gets reported at the transformed position rather than the original one.
// Asking `oxcLoad` first is what keeps a CommonJS-reported file that actually contains
// ESM syntax running as an ES module. `commonjs-typescript` — Node.js' own format for a
// `.ts` file it strips types from — is not deferred, because that translator needs the
// source and rejects `null`; it is passed through untouched so Node.js reports its own
// error (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`) for a `.ts` dependency instead
// of one blaming the hook.
if (result.format === "commonjs") {
// A null source is what `module.register()`'s asynchronous default load returned for
// every CommonJS module, and it is the one shape that keeps `require()` inside such a
// module working on every runtime: a source-bearing result made Node.js short-circuit
// it incorrectly until https://github.com/nodejs/node/pull/62920.
return { format: result.format, source: null, responseURL: result.responseURL ?? url };
}
return result;
}

/**
* Whether `module.registerHooks()` can be relied on for everything this loader does.
*
* `registerHooks` itself landed in v22.15.0 and v23.5.0, but two defects kept it from
* being a drop-in replacement for far longer, both verified against release binaries:
*
* - Until https://github.com/nodejs/node/pull/59011 a synchronous resolve hook had its
* `conditions` overridden, which breaks CommonJS named-export detection for a package
* imported from ESM: `import { jsx } from 'react/jsx-runtime'` fails with "does not
* provide an export named 'jsx'". Fixed in v24.5.0, backported to v22.19.0, and never
* backported to the end-of-life 23.x line.
* - Until https://github.com/nodejs/node/pull/62920 `require()` inside an imported
* CommonJS module short-circuited incorrectly whenever a synchronous load hook handed
* back source for it, so a `.ts` entry point in a CommonJS package could not
* `require()` its own files. Fixed in v26.2.0. The `load` hook above never returns
* source for CommonJS, so this defect does not reach it — v26.2.0 is kept as the floor
* anyway, because it is the first release where the synchronous hooks are complete
* regardless of what a hook returns, and every runtime below it keeps exactly the
* behaviour it has today.
*
* Below v26.2.0 `module.register()` therefore stays in use, deprecation warning included.
*
* @returns {boolean}
*/
function canRegisterSyncHooks() {
if (typeof registerHooks !== "function") {
return false;
}
const [major, minor] = process.versions.node.split(".", 2).map(Number);
return major > 26 || (major === 26 && minor >= 2);
}

// `module.register()` is deprecated — DEP0205, runtime-deprecated since v25.9.0 — and
// runs the hooks on a separate thread. Prefer the synchronous, in-thread
// `module.registerHooks()` on every runtime that implements it completely.
if (canRegisterSyncHooks()) {
initTracing();
registerHooks({ load, resolve });
} else {
register("@oxc-node/core/esm", import.meta.url);
}
21 changes: 21 additions & 0 deletions packages/integrate-vitest/__tests__/cjs-esm-syntax.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,27 @@ describe("a CommonJS package", () => {
expect(run(root, "./entry.ts")).toContain("type-only: true true");
});

test("`require()` completes an extensionless TypeScript specifier", () => {
// `module.registerHooks()` routes `require()` through the resolve hook, where
// `nextResolve` is Node.js' CommonJS resolver — and that resolver only completes
// `./dep` to `./dep.ts`, or `./sub` to `./sub/index.ts`, for extensions present in
// `Module._extensions`. The `pirates` hook is what puts them there, so dropping it
// makes both requires below fail with MODULE_NOT_FOUND, which nothing else here
// would catch.
const root = fixture({
"package.json": COMMONJS,
// Type annotations, so the files cannot run at all unless they were transformed.
"dep.ts": 'const value: string = "dep-ok";\nexports.dep = value;\n',
"sub/index.ts": 'const value: string = "sub-ok";\nexports.sub = value;\n',
"entry.ts": [
'const { dep } = require("./dep");',
'const { sub } = require("./sub");',
'console.log("require:", dep, sub);',
].join("\n"),
});
expect(run(root, "./entry.ts")).toContain("require: dep-ok sub-ok");
});

test("a .cts file is CommonJS by contract and never flips", () => {
const root = fixture({
"package.json": COMMONJS,
Expand Down
Loading