Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
131 changes: 131 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,37 @@ jobs:
- name: Build
run: ${{ matrix.settings.build }}
shell: bash
# The napi-rs generated JS/TS bindings are committed. Only the wasm target
# regenerates all of them (the native targets reuse the export list recorded in
# `oxc-node.wasi.cjs`), so this is the one job that can detect drift.
- name: Check the committed napi bindings are up to date
if: matrix.settings.target == 'wasm32-wasip1-threads'
shell: bash
run: |
BINDINGS="packages/core/browser.js
packages/core/index.d.ts
packages/core/index.js
packages/core/oxc-node.wasi-browser.js
packages/core/oxc-node.wasi.cjs
packages/core/oxc-node.wasi.d.cts
packages/core/wasi-worker-browser.mjs
packages/core/wasi-worker.mjs"
# napi-rs emits unformatted code; the repository stores it formatted.
pnpm fmt
FAILED=""
# shellcheck disable=SC2086
git diff --exit-code -- $BINDINGS || FAILED="stale"
# A new napi-rs release can add a generated file, which `git diff` cannot see.
UNTRACKED="$(git ls-files --others --exclude-standard -- packages/core)"
if [ -n "$UNTRACKED" ]; then
echo "generated but not committed:"
echo "$UNTRACKED"
FAILED="stale"
fi
if [ -n "$FAILED" ]; then
echo "::error::The committed napi bindings are stale. Run \`just build\` and \`pnpm fmt\`, then commit the result."
exit 1
fi
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand Down Expand Up @@ -434,6 +465,104 @@ jobs:
run: pnpm test
env:
NAPI_RS_FORCE_WASI: "true"
# `register.mjs` only uses `module.registerHooks()` from Node.js v22.22.3 / v24.8.0 and
# falls back to `module.register()` below that. Every other test job pins
# `check-latest`/`node:<major>-slim`, so without this job the `module.register()` fallback
# and `esm.mjs` would not be exercised at all.
#
# 22.18.0 and 24.7.0 are the last releases in their lines that still need the fallback,
# so they pin the lower side of the version gate; the `node@22`/`node@24` jobs above run
# 22.22.3 and 24.8.0 or later and therefore pin the upper side.
test-register-fallback:
name: Test module.register() fallback - node@${{ matrix.node }}
needs:
- build
- build-cli
strategy:
fail-fast: false
matrix:
node:
- "22.18.0"
- "24.7.0"
runs-on: ubuntu-latest
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- name: setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Setup node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli
path: ./packages/cli/dist
- name: Install dependencies
run: pnpm install
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bindings-x86_64-unknown-linux-gnu
path: ./packages/core
- name: Copy OXC Runtime
run: pnpm --filter=@oxc-node/core export-oxc-runtime
- name: Assert the fallback is the loader under test
run: |
node --input-type=module -e "
import assert from 'node:assert/strict';
import { supportsRegisterHooks } from './packages/core/hooks.mjs';
assert.equal(supportsRegisterHooks(process.versions.node), false);
"
- name: Test bindings
run: pnpm test
# `engines` declares Node.js >= 20.19.0 — the release where `require(esm)` became
# available by default in the 20 line, which oxc-node needs because oxc does not lower ES
# modules to CommonJS. Nothing else in CI can reach it: pnpm itself requires >= 22.13, so
# the suite is installed under Node.js 22 and then run under 20.19.0 directly.
test-engines-floor:
name: Test the engines floor - node@20.19.0
needs:
- build
- build-cli
runs-on: ubuntu-latest
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- name: setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Setup node for pnpm
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli
path: ./packages/cli/dist
- name: Install dependencies
run: pnpm install
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bindings-x86_64-unknown-linux-gnu
path: ./packages/core
- name: Copy OXC Runtime
run: pnpm --filter=@oxc-node/core export-oxc-runtime
- name: Setup the oldest supported node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20.19.0
package-manager-cache: false
- name: Assert the fallback is the loader under test
run: |
node --input-type=module -e "
import assert from 'node:assert/strict';
import { supportsRegisterHooks } from './packages/core/hooks.mjs';
assert.equal(supportsRegisterHooks(process.versions.node), false);
"
- name: Test bindings
working-directory: packages/integrate-module
run: node --import @oxc-node/core/register ./src/index.ts
publish:
name: Publish
runs-on: ubuntu-latest
Expand All @@ -443,6 +572,8 @@ jobs:
- test-linux-binding
- build-freebsd
- test-wasi
- test-register-fallback
- test-engines-floor
permissions:
contents: write
id-token: write
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ oxc = { version = "0.148.0", features = [
] }
oxc_resolver = { version = "11.24.3" }
oxc_sourcemap = "8"
percent-encoding = "2"
phf = { version = "0.14", features = ["macros"] }
serde_json = "1"
tracing = "0.1"
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,37 @@ node --import @oxc-node/core/register --test
The register entry point installs both the ESM loader hooks and a CommonJS
transform hook.

### Loader implementation

`@oxc-node/core` requires Node.js v20.19.0 or later — the release where
`require(esm)` became available by default in the 20 line.

The hooks are installed with the synchronous, in-thread
[`module.registerHooks()`][registerHooks] on Node.js v22.22.3 and later in the
22 line, and on v24.8.0 and later. Node.js 20, 23 and 25 never received the fixes
this needs, so they fall back to [`module.register()`][register], which runs the
hooks on a dedicated loader thread. `module.register()` is runtime deprecated as
DEP0205 from Node.js v26.0.0; every version that emits that warning uses the
synchronous hooks instead, so the warning never appears.

`module.registerHooks()` has a single hook chain and runs the most recently
registered hook first, so a hook registered _before_ `@oxc-node/core/register`
runs _after_ it. oxc-node still asks the rest of the chain about each specifier
as it was written, so those hooks observe every module under its original
specifier.

Replacing a module is the one thing that needs the other order: oxc-node
resolves first, so its URL wins over a redirect from a hook that runs after it.
Register hooks that rewrite specifiers — module mocking, import maps — after
oxc-node:

```bash
node --import @oxc-node/core/register --import ./my-hooks.mjs ./entry.ts
```

[registerHooks]: https://nodejs.org/api/module.html#moduleregisterhooksoptions
[register]: https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options

## Configuration

By default, `oxc-node` reads `tsconfig.json` from the current working directory.
Expand Down
12 changes: 12 additions & 0 deletions packages/core/hooks.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { LoadHook, ResolveHook } from "node:module";

/**
* Whether `module.registerHooks()` can be used on this Node.js version. See the
* implementation in `hooks.mjs` for the Node.js bugs that make older versions unusable.
*
* @param version the `x.y.z` version, e.g. `process.versions.node`
*/
export declare function supportsRegisterHooks(version: string | undefined): boolean;

export declare const resolve: ResolveHook;
export declare const load: LoadHook;
180 changes: 180 additions & 0 deletions packages/core/hooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { createResolve, load as oxcLoad } from "./index.js";

/**
* Whether `module.registerHooks()` can be used on this Node.js version.
*
* This is deliberately a version check and not a feature check: `registerHooks` itself
* exists from v22.15.0 / v23.5.0, long before the synchronous hook API became usable as a
* loader.
*
* The blocker is `require()`. When a CommonJS module is loaded through Node.js' ESM
* CommonJS translator — which is how the entry point and anything reached by `import` are
* loaded — a `require()` inside it is served by the ESM loader. Until v24.18.0 / v26.2.0
* that `require()` reached the hooks with the **`import`** condition set, so a loader could
* not tell it apart from a real `import`; and the module it produced then had to be
* executed as an ES module, which Node.js only supports on this path from v22.22.3 /
* v24.8.0 (before that it throws
* `TypeError: Cannot read properties of undefined (reading 'exports')`).
*
* oxc-node handles the first problem — it reports the format that matches the code it
* generates (see `transform_output` in `src/lib.rs`) — but not the second, so the floor is
* v22.22.3 / v24.8.0. Node.js 20, 21 and 23 never got either change; 23 is end of life.
*
* Earlier versions have a third problem: until v22.19.0 / v24.5.0 the export conditions
* were passed to hooks as a `SafeSet` rather than an array (`getCjsConditionsArray()`), so
* `conditions.includes("require")` could not detect the CommonJS branch at all. That is
* below the floor above, so it is covered.
*
* @param version the `x.y.z` version, e.g. `process.versions.node`
* @returns {boolean}
*/
export function supportsRegisterHooks(version) {
const parsed = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version ?? "");
if (parsed === null) {
return false;
}
const major = Number(parsed[1]);
const minor = Number(parsed[2]);
const patch = Number(parsed[3]);
if (major === 22) {
return minor > 22 || (minor === 22 && patch >= 3);
}
if (major === 23) {
return false;
}
return major > 24 || (major === 24 && minor >= 8);
}

/**
* Whether the hook was called for a CommonJS `require()` rather than an `import`.
*
* A `Set` is still accepted even though {@link supportsRegisterHooks} keeps us off the
* versions that pass one, so that a mistake there cannot silently turn every `require()`
* into an `import`.
*
* @param {readonly string[] | Set<string> | undefined} conditions
* @returns {boolean}
*/
function isCommonJs(conditions) {
if (Array.isArray(conditions)) {
return conditions.includes("require");
}
return typeof conditions?.has === "function" && conditions.has("require");
}

/**
* The native binding types `conditions` as an array. See {@link isCommonJs} for why a
* `Set` may still turn up.
*
* @template {{ conditions?: readonly string[] | Set<string> }} T
* @param {T} context
* @returns {T}
*/
function withArrayConditions(context) {
const { conditions } = context;
if (conditions === undefined || Array.isArray(conditions)) {
return context;
}
return { ...context, conditions: Array.from(conditions) };
}

function getCurrentDirectory() {
return process.cwd();
}

const RESOLVE_OPTIONS = { getCurrentDirectory };

/**
* @type {import('node:module').ResolveHook}
*/
function resolve(specifier, context, nextResolve) {
if (isCommonJs(context.conditions)) {
// Leave the CommonJS `require()` path to Node.js, which is where the asynchronous
// `module.register()` loader left it too: its hooks cannot serve a synchronous
// `require()`, so `require()` never reached them. Node.js' CommonJS resolution honours
// `Module._extensions`, where the `pirates` hook installed by `register.mjs` registers
// every extension oxc-node transpiles, so `require("./foo")` still finds `foo.ts` — and
// still prefers `foo.json` over `foo.ts`.
return nextResolve(specifier, context);
}
return createResolve(
RESOLVE_OPTIONS,
specifier,
withArrayConditions(context),
withOriginalSpecifier(specifier, nextResolve),
);
}

/**
* Wrap `nextResolve` so the rest of the hook chain is asked about the specifier as it was
* written, not about the URL oxc-node resolved it to.
*
* The native resolver resolves the specifier itself and then calls `nextResolve` with the
* result, to have Node.js validate it and fill in the resolution metadata. Under
* `module.register()` that was invisible: oxc-node's hooks ran on a separate loader thread,
* after every in-thread hook, so those hooks always saw the original specifier. With
* `module.registerHooks()` there is a single chain and oxc-node runs first, so passing the
* resolved URL down would hide the specifier from module mocking and policy hooks.
*
* Node.js cannot resolve everything oxc-node can — tsconfig `paths` aliases, extensionless
* TypeScript — so when it fails, fall back to asking about the resolved URL.
*
* @param {string} specifier
* @param {import('node:module').ResolveHook} nextResolve
* @returns {import('node:module').ResolveHook}
*/
function withOriginalSpecifier(specifier, nextResolve) {
return (resolved, context) => {
if (resolved !== specifier) {
let output;
try {
output = nextResolve(specifier, context);
} catch {
// Only oxc-node can resolve this one.
return nextResolve(resolved, context);
}
// These hooks only ever run under the synchronous `module.registerHooks()`, but the
// hook signature allows a promise, and spreading one would silently produce garbage.
if (output !== null && typeof output === "object" && !("then" in output)) {
return { ...output, url: resolved };
}
}
return nextResolve(resolved, context);
};
}

/**
* @type {import('node:module').LoadHook}
*/
function load(url, context, nextLoad) {
if (isCommonJs(context.conditions)) {
// Same reasoning as in `resolve`: `pirates` transpiles these, emitting a source map for
// the code Node.js actually compiles, and Node.js keeps performing its own CommonJS
// named-export detection on the result — including transitive
// `__export(require("./src"))` re-exports.
return nextLoad(url, context);
}
const loadContext = withArrayConditions(context);
if (typeof loadContext.format !== "string" || !loadContext.format.startsWith("commonjs")) {
return oxcLoad(url, loadContext, nextLoad);
}
// Node.js classified this module as CommonJS. Read it once and let the native loader
// decide what it is:
//
// * Oxc does not lower ES modules to CommonJS, so its output is often still an ES module.
// The native loader reports `module` for it and Node.js executes that source — the only
// way such a module can be loaded on this path, and the reason `require()` of a
// transpiled file works at all.
// * When the output really is CommonJS, Node.js compiles the file through
// `Module._extensions` — where `register.mjs` installs `pirates` — and ignores the
// source returned here. Returning the untouched original then matters: keeping our copy
// would register a second, conflicting source map for the same file and make stack
// traces point at generated positions.
const original = nextLoad(url, loadContext);
const transformed = oxcLoad(url, loadContext, () => original);
return typeof transformed.format === "string" && transformed.format.startsWith("commonjs")
? original
: transformed;
}

export { load, resolve };
Loading