Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion crates/nub-core/src/node/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ fn highest_store_node() -> Option<ResolvedNode> {
/// `internal/research/supported-node-versions.md`). At or above 18.19,
/// the spawn path proceeds and the JS preload picks the
/// hook-registration shape based on the version tier (sync
/// `registerHooks` at 22.15+, async `register()` at 18.19-22.14).
/// `registerHooks` at 22.15+ except 23.0-23.4, async `register()` at
/// 18.19-22.14 and 23.0-23.4 — see [`NodeVersion::supports_augmentation`]).
///
/// Name kept as `check_min_version` to minimize churn at call sites;
/// the semantics changed (floor moved from 22.15 to 18.19) but the
Expand Down
22 changes: 22 additions & 0 deletions crates/nub-core/src/node/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5739,6 +5739,28 @@ mod tests {
// The 22.14.x boundary stays on the compat (import) channel.
let boundary = preload_injection_for(mjs, &NodeVersion::new(22, 14, 99), false);
assert_eq!(boundary.flag, "--import");

// 23.0–23.4 sorts above 22.15 but predates `registerHooks` on the 23.x line
// (which got it at 23.5.0), so it MUST take the compat channel. Routing it to
// `--require preload.cjs` crashed every run at startup with
// `module_.registerHooks is not a function`.
for pre in [
NodeVersion::new(23, 0, 0),
NodeVersion::new(23, 4, 0),
NodeVersion::new(23, 4, 99),
] {
let injection = preload_injection_for(mjs, &pre, false);
assert_eq!(
injection.flag, "--import",
"Node {pre} has no sync registerHooks and must use the compat preload"
);
assert_eq!(injection.value, "file:///opt/nub/runtime/preload.mjs");
}

// 23.5.0 is the 23.x line's fast floor — the release that added registerHooks.
let fast235 = preload_injection_for(mjs, &NodeVersion::new(23, 5, 0), false);
assert_eq!(fast235.flag, "--require");
assert_eq!(fast235.value, "/opt/nub/runtime/preload.cjs");
}

fn tokens(specs: &[&str], version: &NodeVersion) -> Vec<String> {
Expand Down
80 changes: 65 additions & 15 deletions crates/nub-core/src/node/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,49 @@ impl NodeVersion {
const MIN_SUPPORTED: Self = Self::new(18, 19, 0);

/// The minimum Node version for Nub's fast-path augmented mode
/// (sync `module.registerHooks`). Versions in
/// `MIN_SUPPORTED..MIN_AUGMENTED` run in compatibility mode
/// (async `module.register()`); the JS preload picks the
/// registration shape based on `process.versions.node`.
/// (sync `module.registerHooks`) on every line except 23.x — see
/// [`Self::MIN_AUGMENTED_23`]. Supported versions below the fast tier
/// run in compatibility mode (async `module.register()`); the JS
/// preload picks the registration shape based on `process.versions.node`.
const MIN_AUGMENTED: Self = Self::new(22, 15, 0);

/// The 23.x line's own fast-path floor. `module.registerHooks` is a
/// SEMVER-MINOR that shipped independently on two lines — 23.5.0 on the
/// 23.x Current line (2024-12-19) and 22.15.0 on the 22.x LTS line
/// (2025-04-23) — so version ordering alone puts 23.0.0–23.4.x above
/// [`Self::MIN_AUGMENTED`] while they carry no sync hook API at all.
/// A plain `>= 22.15.0` gate sent those releases down the `--require
/// preload.cjs` fast path, whose unconditional `module.registerHooks(...)`
/// threw `TypeError: module_.registerHooks is not a function` before any
/// user code ran. Verified against real 23.0.0/23.3.0/23.4.0 (absent) and
/// 23.5.0/23.6.0 (present), and against Node's own
/// `doc/changelogs/CHANGELOG_V23.md` (nodejs/node#55698).
const MIN_AUGMENTED_23: Self = Self::new(23, 5, 0);

/// True if this Node version is at or above the hard floor.
pub(crate) fn is_supported(&self) -> bool {
*self >= Self::MIN_SUPPORTED
}

/// Whether sync `module.registerHooks` exists — which is the entire
/// meaning of the fast tier. Every downstream gate (which preload file
/// is injected and on which flag, how user preloads and the preload
/// chainer are routed) keys on exactly that capability, so the band
/// exclusion belongs here rather than at each call site.
pub fn supports_augmentation(&self) -> bool {
*self >= Self::MIN_AUGMENTED
if self.major() == Self::MIN_AUGMENTED_23.major() {
*self >= Self::MIN_AUGMENTED_23
} else {
*self >= Self::MIN_AUGMENTED
}
}

/// Classify the Node version into one of the three support tiers.
///
/// - `FastPath` (>= 22.15.0): sync `module.registerHooks()` in-thread.
/// - `Compat` (18.19.0 ..= 22.14.x): async `module.register()` loader worker.
/// - `FastPath` (>= 22.15.0, except 23.0.0–23.4.x): sync
/// `module.registerHooks()` in-thread.
/// - `Compat` (18.19.0 ..= 22.14.x, plus 23.0.0 ..= 23.4.x): async
/// `module.register()` loader worker.
/// - `Unsupported` (< 18.19.0): no hook API capable of carrying the
/// Nub feature surface; the spawn path refuses.
///
Expand All @@ -60,9 +84,9 @@ impl NodeVersion {
/// directly; this classifier exists so the tier-boundary tests read as the model.
#[cfg(test)]
fn tier(&self) -> SupportTier {
if *self >= Self::MIN_AUGMENTED {
if self.supports_augmentation() {
SupportTier::FastPath
} else if *self >= Self::MIN_SUPPORTED {
} else if self.is_supported() {
SupportTier::Compat
} else {
SupportTier::Unsupported
Expand Down Expand Up @@ -102,12 +126,14 @@ impl NodeVersion {
// @lat: [[architecture#Architecture#Two tiers]]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SupportTier {
/// Node >= 22.15.0. Sync `module.registerHooks()` is available;
/// hooks run in-thread with no IPC overhead.
/// Node >= 22.15.0, excluding 23.0.0–23.4.x. Sync
/// `module.registerHooks()` is available; hooks run in-thread with
/// no IPC overhead.
FastPath,
/// Node 18.19.0 through 22.14.x. Only async `module.register()`
/// is available; hooks run in a loader worker thread. Carries
/// the non-silenceable compat-mode notice.
/// Node 18.19.0 through 22.14.x, plus 23.0.0 through 23.4.x (the
/// slice of the 23.x line that predates `registerHooks`). Only async
/// `module.register()` is available; hooks run in a loader worker
/// thread. Carries the non-silenceable compat-mode notice.
Compat,
/// Node < 18.19.0. No usable hook API; Nub refuses to spawn.
Unsupported,
Expand Down Expand Up @@ -294,9 +320,13 @@ mod tests {
#[test]
fn supports_augmentation() {
assert!(NodeVersion::new(22, 15, 0).supports_augmentation());
assert!(NodeVersion::new(23, 0, 0).supports_augmentation());
assert!(NodeVersion::new(24, 0, 0).supports_augmentation());
assert!(!NodeVersion::new(22, 14, 0).supports_augmentation());
assert!(!NodeVersion::new(20, 0, 0).supports_augmentation());
// `registerHooks` reached 23.x only at 23.5.0, so the 23.0–23.4
// slice sorts above the 22.x floor without having the API.
assert!(!NodeVersion::new(23, 0, 0).supports_augmentation());
assert!(NodeVersion::new(23, 5, 0).supports_augmentation());
}

#[test]
Expand Down Expand Up @@ -449,6 +479,26 @@ mod tests {
assert_eq!(NodeVersion::new(24, 14, 0).tier(), SupportTier::FastPath);
}

#[test]
fn tier_23_0_through_23_4_is_compat() {
// `module.registerHooks` reached the 23.x Current line at 23.5.0,
// months AFTER these releases and before it reached 22.x at 22.15.0.
// Sorting above the 22.x fast floor is not the same as having the API:
// classifying this slice fast crashed the `--require` preload at
// startup with `module_.registerHooks is not a function`.
assert_eq!(NodeVersion::new(23, 0, 0).tier(), SupportTier::Compat);
assert_eq!(NodeVersion::new(23, 4, 0).tier(), SupportTier::Compat);
assert_eq!(NodeVersion::new(23, 4, 99).tier(), SupportTier::Compat);
}

#[test]
fn tier_23_5_0_is_fast_path() {
// Exact 23.x fast-path floor — the release that introduced
// `module.registerHooks` on that line (nodejs/node#55698).
assert_eq!(NodeVersion::new(23, 5, 0).tier(), SupportTier::FastPath);
assert_eq!(NodeVersion::new(23, 11, 1).tier(), SupportTier::FastPath);
}

#[test]
fn is_supported_false_for_18_18_x() {
assert!(!NodeVersion::new(18, 18, 0).is_supported());
Expand Down
6 changes: 3 additions & 3 deletions runtime/preload-common.cjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Shared preload machinery for BOTH tiers — CommonJS, zero top-level await.
//
// The fast tier (Node 22.15+) loads this from a `--require` CJS preload
// (preload.cjs) so Node keeps its synchronous `Module.runMain` CJS entry path
// The fast tier (Node 22.15+, minus 23.0–23.4) loads this from a `--require` CJS
// preload (preload.cjs) so Node keeps its synchronous `Module.runMain` CJS entry path
// (top-level `executionAsyncId()===1`, sync exception origin, `require.main.id`
// `'.'`, `module.parent` `null`) — all of which the old `--import` ESM preload
// broke by forcing eager ESM-loader init that routed even a CJS entry through the
// async ESM module-job (R1). The compat tier (18.19–22.14) loads this from its
// async ESM module-job (R1). The compat tier (18.19–22.14 and 23.0–23.4) loads this from its
// async `--import` preload.mjs and reuses the same hook/require/watch/Temporal
// logic; only hook REGISTRATION differs (sync `module.registerHooks` on the fast
// tier vs async `module.register` loader worker on compat), which each entry owns.
Expand Down
24 changes: 18 additions & 6 deletions runtime/preload.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Nub fast-tier preload — Node 22.15+, injected via `--require` (CommonJS).
// Nub fast-tier preload — Node 22.15+ excluding 23.0–23.4 (which have no sync
// `module.registerHooks`), injected via `--require` (CommonJS).
//
// WHY CJS / `--require` (not the `.mjs` `--import` the compat tier uses): the mere
// presence of `--import` forces Node to eagerly initialize the ESM loader, which
Expand Down Expand Up @@ -109,7 +110,17 @@ const { installSyncPolyfills } = __require("./polyfills.cjs");
// load-bearing half of the nub#460 fix.
const forceAsyncTier = !!process.env.__NUB_FORCE_ASYNC_TIER || common.shouldAutoAsyncTierAtPreload();

if (!requireEsmDisabled && !forceAsyncTier) {
// Sync `module.registerHooks` shipped on the 23.x line at 23.5.0 and on the 22.x LTS
// line only later, at 22.15.0 — so 23.0–23.4 sorts ABOVE the 22.x fast floor while
// carrying no sync hook API. The spawn path keeps that band on preload.mjs, but
// NODE_OPTIONS is inherited by the WHOLE subtree: a grandchild `node` nub never saw
// spawn can resolve to a 23.0–23.4 binary and pick this `--require` token up anyway.
// Without this probe the registration below throws `registerHooks is not a function`
// before any user code runs. The async branch is the correct home for it — that is
// the same loader-worker registration the compat tier uses on exactly this band.
const hasRegisterHooks = typeof module_.registerHooks === "function";

if (hasRegisterHooks && !requireEsmDisabled && !forceAsyncTier) {
// ── Fast tier (sync require(esm) available) ───────────────────────

// ── Watch-mode dependency reporting + hooks ───────────────────────
Expand Down Expand Up @@ -163,10 +174,11 @@ if (!requireEsmDisabled && !forceAsyncTier) {
common.requireUserPreloadChain();
} else {
// ── Async loader-worker tier ──────────────────────────────────────
// Entered when EITHER require(esm) is disabled (`--no-experimental-require-module`,
// so the in-thread sync core can't load) OR `forceAsyncTier` is set (nub composes
// with a foreign async loader on a broken-compose Node — see above). Register the
// SAME hooks the compat tier uses, run in a dedicated loader worker via
// Entered when require(esm) is disabled (`--no-experimental-require-module`, so the
// in-thread sync core can't load), OR `forceAsyncTier` is set (nub composes with a
// foreign async loader on a broken-compose Node — see above), OR sync
// `registerHooks` is simply absent (an inherited-NODE_OPTIONS 23.0–23.4 grandchild).
// Register the SAME hooks the compat tier uses, run in a dedicated loader worker via
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
// `module.register`; that worker imports
// transform-core.mjs as a static ESM import (not gated by the flag). The
// main-thread CJS require() transpile shim, which would need the core
Expand Down
43 changes: 26 additions & 17 deletions runtime/preload.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
// Nub compat-tier preload — Node 18.19–22.14, injected via `--import` (ESM).
// Nub compat-tier preload — Node 18.19–22.14 and 23.0–23.4, injected via
// `--import` (ESM).
//
// The FAST tier (Node 22.15+) is loaded separately, as a `--require` CommonJS
// preload (runtime/preload.cjs), so Node keeps its synchronous `Module.runMain`
// CJS entry path (top-level `executionAsyncId()===1`, sync exception origin,
// The FAST tier (Node 22.15+, minus 23.0–23.4) is loaded separately, as a
// `--require` CommonJS preload (runtime/preload.cjs), so Node keeps its synchronous
// `Module.runMain` CJS entry path (top-level `executionAsyncId()===1`, sync exception origin,
// `require.main.id` `'.'`, `module.parent` `null`). The mere presence of an
// `--import` ESM preload forces eager ESM-loader init that routes even a CJS entry
// through the async ESM module-job (R1) — so the fast tier must NOT use `--import`.
//
// THIS file stays the compat path: on 18.19–22.14, `module.registerHooks` does not
// exist and `require(esm)` is unreliable, so hooks run async in a dedicated loader
// THIS file stays the compat path: on 18.19–22.14 and on 23.0–23.4 (the 23.x line
// got `module.registerHooks` only at 23.5.0), the sync hook API does not exist and
// `require(esm)` is unreliable, so hooks run async in a dedicated loader
// worker via `module.register`. That async machinery is why the compat tier keeps
// `--import` — its top-level `await` is accepted here (an `--import` ESM module may
// be async), and the < 22.15 floor has no equivalent sync surface. (Module-format
// be async), and the compat tier has no equivalent sync surface. (Module-format
// + decorator detection no longer needs a preloaded JS parser: it is a synchronous
// native addon call, so there is nothing to `await`-warm-up before hooks run.)
//
Expand Down Expand Up @@ -52,14 +54,20 @@ common.installVersionMarker();

// ── Tier detection ──────────────────────────────────────────────────
// This `.mjs` preload should only ever be `--import`ed for the compat tier (the
// Rust spawn path chooses `--require preload.cjs` for 22.15+). But guard anyway: if
// someone `--import`s it directly on an unsupported Node, emit a clear message and
// skip hook registration rather than throw (throwing breaks user-invoked --import
// flows). The fast-tier branch is intentionally absent here — 22.15+ goes through
// preload.cjs.
// Rust spawn path chooses `--require preload.cjs` for the fast tier). But guard
// anyway: if someone `--import`s it directly on an unsupported Node, emit a clear
// message and skip hook registration rather than throw (throwing breaks
// user-invoked --import flows).
//
// The fast-tier test is the CAPABILITY, not a version band. `module.registerHooks`
// shipped on the 23.x line at 23.5.0 and only later on 22.x at 22.15.0, so the old
// `major > 22 || (major === 22 && minor >= 15)` band claimed the API on 23.0–23.4,
// where it does not exist — the same off-by-one-line defect that crashed the Rust
// spawn path's tier choice. A `typeof` probe cannot drift from Node's release
// history the way a hand-maintained band can.
const [__major = 0, __minor = 0] = process.versions.node.split(".").map((n) => parseInt(n, 10));
const __isCompatTier = __major > 18 || (__major === 18 && __minor >= 19);
const __isFastTier = __major > 22 || (__major === 22 && __minor >= 15);
const __isFastTier = typeof module.registerHooks === "function";

// Native TypeScript support (`process.features.typescript`). Where absent (the
// whole compat tier ≤ 22.17), Node can't load a required `.ts` on its own, so the
Expand All @@ -71,8 +79,8 @@ const __hasNativeTs = !!process.features?.typescript;
common.installWatchReporting(core);

if (__isFastTier) {
// Defensive only — the Rust path uses preload.cjs for 22.15+. If reached, the
// sync registerHooks API is available; register synchronously to stay correct.
// Defensive only — the Rust path uses preload.cjs for the fast tier. If reached,
// the sync registerHooks API is available; register synchronously to stay correct.
// Match preload.cjs: NO classic require.extensions shim on the fast tier — the
// sync registerHooks load hook + native require(esm) cover require()'d `.ts`
// (incl. ES modules); the classic shim would shadow require(esm) and throw a
Expand All @@ -86,8 +94,9 @@ if (__isFastTier) {
// hooks.mjs), so no Yarn `.pnp.loader.mjs` registration is needed here either.
// Via the shared helper so any DEP0205 from nub's own register() call (Node 26+,
// if this compat path is ever reached there) is not leaked onto the user's stderr.
// On the compat tier proper (18.19–22.14) registerHooks doesn't exist, so the
// loader-worker is the only hook surface; the user has no action to take.
// On the compat tier proper (18.19–22.14, and 23.0–23.4) registerHooks doesn't
// exist, so the loader-worker is the only hook surface; the user has no action
// to take.
common.registerLoaderWorker("./preload-async-hooks.mjs", import.meta.url);
// (The main-thread require() shim's module-format + decorator detection is a
// synchronous native addon call now — no parser warm-up; the old
Expand Down
4 changes: 3 additions & 1 deletion wiki/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ The runtime exists in two shapes, chosen by the availability of the synchronous

| | Fast tier | Compat tier |
| --- | --- | --- |
| Node | 22.15 and above | 18.19 to 22.14 |
| Node | 22.15 and above, except 23.0 to 23.4 | 18.19 to 22.14, and 23.0 to 23.4 |
| Preload channel | `--require` | `--import` |
| Hooks | `module.registerHooks`, synchronous, in-thread | `module.register`, in a loader worker |
| Polyfills | Lazy getters | Eager import |

The 23.x exclusion is not a special case, it is the tier definition applied correctly. `module.registerHooks` is a semver-minor that reached the 23.x Current line at 23.5.0 and the 22.x LTS line only later, at 22.15.0, so 23.0 to 23.4 sorts above the 22.x floor while carrying no synchronous hooks API. A version comparison against 22.15 alone therefore claims a capability those releases do not have.

Using `--require` on the fast tier is a correctness mechanism, not an optimization. An `--import` preload forces eager ESM loader initialization, which routes even a CommonJS entry point through the async module job and breaks `executionAsyncId`, sync exception origin, `require.main.id` and `module.parent`. Coverage and composition behavior of the hooks API is measured in [[research/registerhooks-coverage-matrix]].

## TypeScript and resolution
Expand Down
Loading
Loading