From 45f3ed27746e246be9bd1acaf3b1a65c296640f5 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:05:32 -0700 Subject: [PATCH 1/5] node: route Node 23.0-23.4 to the compat tier `module.registerHooks` is a semver-minor that shipped on the 23.x line at 23.5.0 and on the 22.x LTS line only later, at 22.15.0 (nodejs/node#55698). The tier gate compared against 22.15.0 alone, so 23.0.0-23.4.x sorted above the fast floor without having the API. Those releases were injected with `--require runtime/preload.cjs`, whose unconditional `registerHooks` call threw `TypeError: module_.registerHooks is not a function` before any user code ran. `supports_augmentation` now excludes the band, which is the single point every downstream gate keys on (preload channel, user-preload routing, chainer channel). The compat `--import` path already works on 23.x. Two JS gates carried the same defect and are fixed with capability probes rather than version arithmetic: `__isFastTier` in preload.mjs, which is the file the band now lands on, and the fast/async branch in preload.cjs, which a grandchild `node` can still reach through inherited NODE_OPTIONS. Verified on real 18.19.0, 22.14.0, 22.15.0, 23.0.0, 23.3.0, 23.4.0, 23.5.0, 23.6.0, 24.0.0 and 24.19.0. --- crates/nub-core/src/node/discovery.rs | 3 +- crates/nub-core/src/node/spawn.rs | 22 +++++ crates/nub-core/src/node/version.rs | 80 +++++++++++++++---- runtime/preload-common.cjs | 6 +- runtime/preload.cjs | 24 ++++-- runtime/preload.mjs | 43 ++++++---- wiki/design/architecture.md | 4 +- .../research/registerhooks-coverage-matrix.md | 15 ++++ 8 files changed, 154 insertions(+), 43 deletions(-) diff --git a/crates/nub-core/src/node/discovery.rs b/crates/nub-core/src/node/discovery.rs index 28e9ba4cb..c87781731 100644 --- a/crates/nub-core/src/node/discovery.rs +++ b/crates/nub-core/src/node/discovery.rs @@ -425,7 +425,8 @@ fn highest_store_node() -> Option { /// `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 diff --git a/crates/nub-core/src/node/spawn.rs b/crates/nub-core/src/node/spawn.rs index f1e6aec3c..35bc5cf4a 100644 --- a/crates/nub-core/src/node/spawn.rs +++ b/crates/nub-core/src/node/spawn.rs @@ -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 { diff --git a/crates/nub-core/src/node/version.rs b/crates/nub-core/src/node/version.rs index 9a25d25ef..1d129de30 100644 --- a/crates/nub-core/src/node/version.rs +++ b/crates/nub-core/src/node/version.rs @@ -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. /// @@ -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 @@ -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, @@ -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] @@ -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()); diff --git a/runtime/preload-common.cjs b/runtime/preload-common.cjs index 5d1b8ed95..396eb7fdf 100644 --- a/runtime/preload-common.cjs +++ b/runtime/preload-common.cjs @@ -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. diff --git a/runtime/preload.cjs b/runtime/preload.cjs index 54ef18947..5036fb265 100644 --- a/runtime/preload.cjs +++ b/runtime/preload.cjs @@ -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 @@ -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 ─────────────────────── @@ -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 // `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 diff --git a/runtime/preload.mjs b/runtime/preload.mjs index dc125756e..50883c735 100644 --- a/runtime/preload.mjs +++ b/runtime/preload.mjs @@ -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.) // @@ -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 @@ -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 @@ -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 diff --git a/wiki/design/architecture.md b/wiki/design/architecture.md index 8ec81e6d5..e7d5dd480 100644 --- a/wiki/design/architecture.md +++ b/wiki/design/architecture.md @@ -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 diff --git a/wiki/research/registerhooks-coverage-matrix.md b/wiki/research/registerhooks-coverage-matrix.md index 711efd926..974784f5d 100644 --- a/wiki/research/registerhooks-coverage-matrix.md +++ b/wiki/research/registerhooks-coverage-matrix.md @@ -12,6 +12,20 @@ Three probes: resolve-hook coverage across every require path, sync hooks compos - Paths probed: plain-chain `require()`, `require()` from a CJS parent loaded via the ESM CJS-translator (`import './x.cjs'`), `createRequire()(…)`, `require.resolve()` (both parents), and dynamic `import()` as a control. - Composition probe: sync passthrough `registerHooks` plus an async `module.register` loader whose customization is load-bearing (`virtual2`); plus the real Yarn PnP `.pnp.loader.mjs` registered on top of sync hooks in a real berry fixture (`--require .pnp.cjs --import ` with an ESM entry importing `ms`). +## Availability by release line + +Before any coverage question, the API has to exist. It does not exist on one band that a single version comparison reads as modern. + +`module.registerHooks` is a semver-minor ([#55698](https://github.com/nodejs/node/pull/55698), Joyee Cheung) that shipped on two release lines independently: 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, four months later). Node 23.0.0 through 23.4.x therefore sorts *above* 22.15.0 while having no synchronous hooks API at all. + +Probed on the installed toolchains, `typeof require('module').registerHooks`: + +| Node | 22.14.0 | 22.15.0 | 23.0.0 | 23.3.0 | 23.4.0 | 23.5.0 | 23.6.0 | 24.0.0 | +|---|---|---|---|---|---|---|---|---| +| `registerHooks` | undefined | function | undefined | undefined | undefined | function | function | function | + +Any tier gate written as a plain `>= 22.15.0` claims the API on 23.0–23.4. Nub's did, which sent those releases down the `--require preload.cjs` fast path and crashed every run at startup with `TypeError: module_.registerHooks is not a function`. The predicate has to exclude the band explicitly, or test the capability rather than the version. + ## Findings Five behaviors, each with the version range where it is broken, the range where it is fixed, and the upstream PR that fixed it. @@ -41,3 +55,4 @@ One upstream ask survives the matrix, a v22.x backport; everything else in nub's Each entry dates the probe run behind a change to the matrix. - 2026-07-14 — Initial write-up. +- 2026-08-27 — Added the availability-by-release-line section. The matrix had only ever asked what `registerHooks` does where it exists; it never recorded that 23.0–23.4 does not have it, which is what let a `>= 22.15.0` tier gate crash the preload on that band. From e9dbd3392b576d5c2b4e8efa7d0a00d42047abac Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:41:49 -0700 Subject: [PATCH 2/5] compile: refuse a shim build on the 23.0-23.4 registerHooks hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nub compile`'s external-shim gate kept its own `22.15.0` floor, so a `--external` / `--allow-dynamic-import` build targeting Node 23.0-23.4 was accepted and the generated `__nub_external.mjs` called `registerHooks` unconditionally — the artifact died at startup with the same TypeError the tier fix addresses. A bare `--target 23` floors at 23.0.0, landing in the band. The gate now calls `supports_augmentation`, so the band lives in one place. `fast_tier_floor_for_line` is new on NodeVersion and derives the floor for a version's own release line; the refusal uses it to suggest 23.5 to a 23.x target instead of 22.15, and drops the "which older Node does not have" ordering claim. A 22.15 suggestion now carves the band back out, since a bare "or newer" from there would re-admit it. The preload.cjs async fallback added for that band registered the loader worker but never installed the main-thread CJS require hooks, so a 23.0-23.4 grandchild reached through inherited NODE_OPTIONS had no transpile path at all for `require('./x.ts')`: no registerHooks until 23.5, no native type stripping until 23.6, no classic shim. It now installs them exactly as preload.mjs does on the same band, gated on the transform core being loaded — the require(esm)-disabled entry has none. The branch comment described only that entry's rationale and now covers all three. Also stop the native-deps harness from discarding verify-load.cjs output: a native addon that aborts rather than throws took the script down under `set -e` through a failed command substitution, leaving a bare "exit code 134" and no diagnostics. --- crates/nub-cli/src/compile/external.rs | 119 +++++++++++++++++++++---- crates/nub-core/src/node/version.rs | 26 ++++-- runtime/preload.cjs | 51 +++++++---- tests/native-deps/run.sh | 12 ++- 4 files changed, 167 insertions(+), 41 deletions(-) diff --git a/crates/nub-cli/src/compile/external.rs b/crates/nub-cli/src/compile/external.rs index 0abe6e932..7288a308d 100644 --- a/crates/nub-cli/src/compile/external.rs +++ b/crates/nub-cli/src/compile/external.rs @@ -59,9 +59,9 @@ impl ShimPlan<'_> { !self.external.is_empty() || self.dynamic } - /// The flag to name in a diagnostic. `--external` first: both share the 22.15 - /// floor, but `--external`'s applies unconditionally, so it is the one a user - /// can act on without first knowing whether a computed import survived. + /// The flag to name in a diagnostic. `--external` first: both share the same + /// requirement, but `--external`'s applies unconditionally, so it is the one a + /// user can act on without first knowing whether a computed import survived. fn flag(&self) -> &'static str { if self.external.is_empty() { "--allow-dynamic-import" @@ -76,21 +76,28 @@ const WRAPPER: &str = "__nub_entry.mjs"; /// The generated hook module. const HOOK: &str = "__nub_external.mjs"; -/// `module.registerHooks` — a synchronous resolve hook, no loader worker and no -/// experimental warning — landed in Node 22.15, the same floor nub's own fast -/// tier uses. Below it the shim has nothing to register with. -fn min_node() -> NodeVersion { - NodeVersion::new(22, 15, 0) -} - /// Reject a shim-needing build against a Node the shim cannot run on, BEFORE the /// ~100 MB runtime download. `version` is the exact embedded version, or (under /// `--smol`) the floor the launcher refuses to start below — so in both shapes /// it is a lower bound on what will actually run the artifact. `source` names /// where that version came from, because a bare major pin floors at `X.0.0` and /// the refusal is otherwise baffling on a machine running a newer X. +/// +/// The generated `__nub_external.mjs` calls `module.registerHooks` unconditionally, +/// so the gate is exactly "does that API exist" — [`NodeVersion::supports_augmentation`], +/// the same predicate nub's own fast tier uses. This function used to hold a private +/// `22.15.0` floor and compare against it, which accepted Node 23.0.0–23.4.x: those +/// sort above 22.15.0 but predate `registerHooks` on the 23.x line, which got it at +/// 23.5.0. The build succeeded and the ARTIFACT died at startup on `registerHooks is +/// not a function`. A bare `--target 23` floors at 23.0.0, landing in that band. +/// +/// KNOWN GAP, deliberate: a floor is a lower bound, so a range that STARTS below the +/// band and extends past it (`--target ">=22.15"` under `--smol`) still passes here +/// and can be run on 23.2. Closing that needs the gate to see the whole range rather +/// than its floor, which is a different change; the common shapes (an exact target, a +/// bare major, a `23.x` range) all floor inside the band and are caught. pub fn check_node_support(version: &NodeVersion, source: &str, plan: &ShimPlan<'_>) -> Result<()> { - if !plan.needed() || *version >= min_node() { + if !plan.needed() || version.supports_augmentation() { return Ok(()); } let flag = plan.flag(); @@ -99,14 +106,25 @@ pub fn check_node_support(version: &NodeVersion, source: &str, plan: &ShimPlan<' } else { "or drop --external and let the package be bundled" }; + // Suggest a floor on the line the user already targets. Telling someone on 23.4 + // to "pass --target 22.15 (or newer)" is both a cross-major downgrade and, taken + // literally, wrong — 23.4 IS newer than 22.15. + let floor = version.fast_tier_floor_for_line(); + // "or newer" is only safe from the 23.x floor up; from 22.15 it would sweep the + // 23.0–23.4 band right back in, which is the very fallacy this gate exists to fix. + let onward = if floor == NodeVersion::new(23, 5, 0) { + format!("Pass --target {floor} (or newer)") + } else { + format!("Pass --target {floor} or newer, other than 23.0 through 23.4") + }; bail!( - "{flag} needs Node {} or newer, and this build targets Node {version} \ + "{flag} needs module.registerHooks, and this build targets Node {version} \ (from {source}).\n\ - \x20\x20It is served by a hook the artifact installs at startup\n\ - \x20\x20(module.registerHooks), which older Node does not have.\n\ - \x20\x20Pass --target {} (or newer), {way_out}.", - min_node(), - min_node() + \x20\x20The artifact installs that hook at startup to resolve what it was\n\ + \x20\x20told to leave for run time.\n\ + \x20\x20module.registerHooks reached the 22.x line at 22.15.0 and the 23.x\n\ + \x20\x20line at 23.5.0, so Node {version} does not have it.\n\ + \x20\x20{onward}, {way_out}." ) } @@ -486,6 +504,73 @@ mod tests { ); } + /// The gate is "does `module.registerHooks` exist", not "is this newer than + /// 22.15.0". Node 23.0.0–23.4.x sorts above 22.15.0 and has no such API — the + /// 23.x line got it at 23.5.0 — so a shim build against that band produced a + /// binary that died at startup. A bare `--target 23` floors at 23.0.0, which is + /// how a user reaches this without naming a patch version. + #[test] + fn the_node_gate_refuses_the_23_0_to_23_4_registerhooks_hole() { + let prettier = pkgs(&["prettier"]); + + for refused in [ + NodeVersion::new(23, 0, 0), + NodeVersion::new(23, 4, 0), + NodeVersion::new(23, 4, 99), + ] { + let err = match check_node_support(&refused, "--target 23", &plan(&prettier, false)) { + Err(err) => err, + Ok(()) => panic!("Node {refused} has no registerHooks and must be refused"), + }; + let msg = format!("{err:#}"); + assert!( + msg.contains(&refused.to_string()), + "must name what was targeted: {msg}" + ); + // The suggestion has to stay on the line the user targets. Sending someone + // on 23.4 to 22.15 is a cross-major downgrade, and "22.15 or newer" would + // re-admit the very band being refused. + assert!( + msg.contains("--target 23.5.0"), + "must suggest the 23.x line's own floor, not 22.15.0: {msg}" + ); + assert!( + !msg.contains("--target 22.15.0"), + "must not suggest a cross-major downgrade: {msg}" + ); + } + + // Both real floors are accepted, and so is everything above the 23.x one. + for accepted in [ + NodeVersion::new(22, 15, 0), + NodeVersion::new(23, 5, 0), + NodeVersion::new(23, 11, 1), + NodeVersion::new(24, 0, 0), + ] { + assert!( + check_node_support(&accepted, "--target", &plan(&prettier, false)).is_ok(), + "Node {accepted} has registerHooks and must be accepted" + ); + } + } + + /// Below the 22.x floor the suggestion cannot say a bare "or newer": that phrase + /// sweeps 23.0–23.4 back in, which is the ordering fallacy the gate exists to fix. + #[test] + fn the_node_gate_excludes_the_hole_when_it_suggests_the_22_floor() { + let err = check_node_support( + &NodeVersion::new(20, 19, 0), + "--target", + &plan(&pkgs(&["prettier"]), false), + ) + .expect_err("must be rejected"); + let msg = format!("{err:#}"); + assert!( + msg.contains("other than 23.0 through 23.4"), + "a 22.15 suggestion must carve out the hole: {msg}" + ); + } + // The wrapper is what puts the hook in front of the bundle's own imports, so // it must reach the bundle through import() and not a static import. #[test] diff --git a/crates/nub-core/src/node/version.rs b/crates/nub-core/src/node/version.rs index 1d129de30..65e0f6ddd 100644 --- a/crates/nub-core/src/node/version.rs +++ b/crates/nub-core/src/node/version.rs @@ -57,17 +57,29 @@ impl NodeVersion { *self >= Self::MIN_SUPPORTED } + /// The fast-tier floor that governs THIS version's release line: 23.5.0 on + /// the 23.x line, 22.15.0 everywhere else. + /// + /// Public because a caller that has to tell a user which Node to move to + /// must name a version on the line they already target — suggesting 22.15.0 + /// to someone on 23.4 reads as a downgrade across a major. Deriving it here + /// keeps the two-line band in ONE place, so no caller re-encodes it. + pub fn fast_tier_floor_for_line(&self) -> Self { + if self.major() == Self::MIN_AUGMENTED_23.major() { + Self::MIN_AUGMENTED_23 + } else { + Self::MIN_AUGMENTED + } + } + /// 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. + /// chainer are routed, whether `nub compile` may emit its resolve-hook + /// shim) keys on exactly that capability, so the band exclusion belongs + /// here rather than at each call site. pub fn supports_augmentation(&self) -> bool { - if self.major() == Self::MIN_AUGMENTED_23.major() { - *self >= Self::MIN_AUGMENTED_23 - } else { - *self >= Self::MIN_AUGMENTED - } + *self >= self.fast_tier_floor_for_line() } /// Classify the Node version into one of the three support tiers. diff --git a/runtime/preload.cjs b/runtime/preload.cjs index 5036fb265..a1410588b 100644 --- a/runtime/preload.cjs +++ b/runtime/preload.cjs @@ -174,26 +174,45 @@ if (hasRegisterHooks && !requireEsmDisabled && !forceAsyncTier) { common.requireUserPreloadChain(); } else { // ── Async loader-worker tier ────────────────────────────────────── - // 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 - // `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 - // synchronously in-thread, is unavailable in this mode — an honest, additive - // degradation: the user opted out of require(esm), and nub's `.ts`-via-require() - // transpile rides on exactly that mechanism. `import`-side TS still transpiles - // through the registered loader-worker hooks. User require(esm) of THEIR own ES - // modules still gets Node's native ERR_REQUIRE_ESM, exactly as the flag promises. + // THREE independent entry conditions, and they do NOT share a rationale — the + // main-thread CJS require() shim below is available on two of them and impossible + // on the third, so each is named separately: + // + // 1. `requireEsmDisabled` — `--no-experimental-require-module`. `core` is null + // (the require(esm) of transform-core.mjs threw), so the in-thread shim has + // no transform core to call and genuinely cannot be installed. An honest, + // additive degradation: the user opted out of require(esm), and nub's + // `.ts`-via-require() transpile rides on exactly that mechanism. User + // require(esm) of THEIR own ES modules still gets Node's native + // ERR_REQUIRE_ESM, exactly as the flag promises. + // 2. `forceAsyncTier` — nub composes with a foreign async loader on a + // broken-compose Node (see above). Unchanged behavior. + // 3. No sync `registerHooks` — an inherited-NODE_OPTIONS 23.0–23.4 grandchild. + // `core` IS loaded here and require(esm) works, so the shim's prerequisite is + // present and it MUST be installed; see the call below. + // + // All three register the SAME hooks the compat tier uses, run in a dedicated loader + // worker via `module.register`; that worker imports transform-core.mjs as a static + // ESM import (not gated by the flag). `import`-side TS transpiles through those + // loader-worker hooks on every one of the three. const { pathToFileURL } = require("node:url"); // Via the shared helper so Node 26+'s DEP0205 (steering to module.registerHooks) is - // not leaked onto the user's stderr — nub is forced onto module.register here - // because require(esm) is off, so registerHooks' in-thread sync core load is - // impossible; the user has no action to take. See registerLoaderWorker. + // not leaked onto the user's stderr — on every entry above nub is forced onto + // module.register (require(esm) off, foreign-loader composition, or registerHooks + // absent outright), so the user has no action to take. See registerLoaderWorker. common.registerLoaderWorker("./preload-async-hooks.mjs", pathToFileURL(__filename).href); + // Entry 3 only. `module.register` is ESM-loader-only, so without this a + // `require('./x.ts')` on 23.0–23.4 reaches Node raw and dies on the first type + // annotation: that band has neither sync `registerHooks` (23.5) nor native type + // stripping (unflagged at 23.6). This is the same call preload.mjs makes on the + // same band, with the same classic-transpile argument, so a grandchild reached + // through inherited NODE_OPTIONS behaves like the compat tier rather than losing + // require()'d TS outright. Gated on `core` because entry 1 has none. + if (!hasRegisterHooks && core) { + common.installCjsRequireHooks(core, !process.features?.typescript); + } + // Sync, non-require(esm) polyfills still install (none of them require(esm)). // Clobbered-polyfill packages are CJS requires, unaffected by the flag. const __preloadedPolyfills = common.preloadPolyfillPackages(__require); diff --git a/tests/native-deps/run.sh b/tests/native-deps/run.sh index 05c37bb9c..eda296cb2 100755 --- a/tests/native-deps/run.sh +++ b/tests/native-deps/run.sh @@ -112,7 +112,17 @@ pass "esbuild postinstall ran: binary present" # gracefully on dev boxes where the toolchain can't compile node-gyp addons; # the esbuild check is authoritative on every platform. # Run from within $PROJ_ALLOW so require() resolves against that node_modules. -LOAD_OUT="$(cd "$PROJ_ALLOW" && node ./verify-load.cjs 2>&1)" +# +# Capture the status separately instead of letting `set -e` kill the script on the +# assignment. A native addon that aborts (SIGABRT/139/134) rather than throwing takes +# the whole node process down, and under `set -e` the failing command substitution +# ended run.sh with only the raw exit code — no output, because everything node wrote +# went into the variable the failed assignment discarded. That is how an abort here +# reads as an unexplained "exit code 134" with no diagnostics at all. +LOAD_RC=0 +LOAD_OUT="$(cd "$PROJ_ALLOW" && node ./verify-load.cjs 2>&1)" || LOAD_RC=$? +[ "$LOAD_RC" -eq 0 ] \ + || fail "verify-load.cjs exited $LOAD_RC (a signal death is 128+n, so 134=SIGABRT in a native addon). Output: $LOAD_OUT" echo "$LOAD_OUT" | grep -q "NATIVE-DEPS-OK" \ || fail "native modules not loadable after install. verify-load output: $LOAD_OUT" pass "native modules loadable: $LOAD_OUT" From 80476e090dffa583cbd0606f752ad4e83ff1a11b Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:04:07 -0700 Subject: [PATCH 3/5] node: state the smol major.minor gap and entry 2's shim status exactly --- crates/nub-cli/src/compile/external.rs | 13 ++++++++----- runtime/preload.cjs | 10 +++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/nub-cli/src/compile/external.rs b/crates/nub-cli/src/compile/external.rs index 7288a308d..d4251a11e 100644 --- a/crates/nub-cli/src/compile/external.rs +++ b/crates/nub-cli/src/compile/external.rs @@ -91,11 +91,14 @@ const HOOK: &str = "__nub_external.mjs"; /// 23.5.0. The build succeeded and the ARTIFACT died at startup on `registerHooks is /// not a function`. A bare `--target 23` floors at 23.0.0, landing in that band. /// -/// KNOWN GAP, deliberate: a floor is a lower bound, so a range that STARTS below the -/// band and extends past it (`--target ">=22.15"` under `--smol`) still passes here -/// and can be run on 23.2. Closing that needs the gate to see the whole range rather -/// than its floor, which is a different change; the common shapes (an exact target, a -/// bare major, a `23.x` range) all floor inside the band and are caught. +/// KNOWN GAP, deliberate: a floor is a lower bound, so any pin whose FLOOR sits below +/// the band while its run-time acceptance extends past it still passes here and can be +/// run on 23.2. Two shapes do that under `--smol`: a range (`--target ">=22.15"`), and +/// a major.minor pin (`--target 22.15`) — the latter carries no range into the +/// manifest, so `SmolTarget::matches` falls back to `candidate >= floor`. Closing +/// either needs the gate to see the whole acceptance set rather than its floor, which +/// is a different change; an exact three-part target, a bare major, and a `23.x` +/// range all floor inside the band and are caught. pub fn check_node_support(version: &NodeVersion, source: &str, plan: &ShimPlan<'_>) -> Result<()> { if !plan.needed() || version.supports_augmentation() { return Ok(()); diff --git a/runtime/preload.cjs b/runtime/preload.cjs index a1410588b..674006f05 100644 --- a/runtime/preload.cjs +++ b/runtime/preload.cjs @@ -175,8 +175,8 @@ if (hasRegisterHooks && !requireEsmDisabled && !forceAsyncTier) { } else { // ── Async loader-worker tier ────────────────────────────────────── // THREE independent entry conditions, and they do NOT share a rationale — the - // main-thread CJS require() shim below is available on two of them and impossible - // on the third, so each is named separately: + // main-thread CJS require() shim below is installed on exactly ONE of them, so + // each is named separately: // // 1. `requireEsmDisabled` — `--no-experimental-require-module`. `core` is null // (the require(esm) of transform-core.mjs threw), so the in-thread shim has @@ -186,7 +186,11 @@ if (hasRegisterHooks && !requireEsmDisabled && !forceAsyncTier) { // require(esm) of THEIR own ES modules still gets Node's native // ERR_REQUIRE_ESM, exactly as the flag promises. // 2. `forceAsyncTier` — nub composes with a foreign async loader on a - // broken-compose Node (see above). Unchanged behavior. + // broken-compose Node (see above). `registerHooks` EXISTS here, so the + // `!hasRegisterHooks` gate below skips the shim and this entry keeps its + // long-standing behavior: loader-worker `import` coverage, no main-thread + // `_resolveFilename` patch (so no tsconfig-`paths`/PnP/`.ts`-specifier + // resolution from CJS while composing with a foreign loader). // 3. No sync `registerHooks` — an inherited-NODE_OPTIONS 23.0–23.4 grandchild. // `core` IS loaded here and require(esm) works, so the shim's prerequisite is // present and it MUST be installed; see the call below. From aeb4a76f6af549d114c210178348614485269ac0 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:11:37 -0700 Subject: [PATCH 4/5] node: count the alias pin among the floor-only smol shapes --- crates/nub-cli/src/compile/external.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/nub-cli/src/compile/external.rs b/crates/nub-cli/src/compile/external.rs index d4251a11e..0049115f9 100644 --- a/crates/nub-cli/src/compile/external.rs +++ b/crates/nub-cli/src/compile/external.rs @@ -93,9 +93,10 @@ const HOOK: &str = "__nub_external.mjs"; /// /// KNOWN GAP, deliberate: a floor is a lower bound, so any pin whose FLOOR sits below /// the band while its run-time acceptance extends past it still passes here and can be -/// run on 23.2. Two shapes do that under `--smol`: a range (`--target ">=22.15"`), and -/// a major.minor pin (`--target 22.15`) — the latter carries no range into the -/// manifest, so `SmolTarget::matches` falls back to `candidate >= floor`. Closing +/// run on 23.2. Three shapes do that under `--smol`: a range (`--target ">=22.15"`), a +/// major.minor pin (`--target 22.15`), and a `lts/` alias resolving onto a +/// pre-23.5 line (`--target lts/jod`) — the latter two carry no range into the manifest, +/// so `SmolTarget::matches` falls back to `candidate >= floor`. Closing /// either needs the gate to see the whole acceptance set rather than its floor, which /// is a different change; an exact three-part target, a bare major, and a `23.x` /// range all floor inside the band and are caught. From f7db15da7ce0ba15fdf0961e7295652df77d9dc9 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:23:58 -0700 Subject: [PATCH 5/5] node: state the smol floor gap as the invariant, not a shape count --- crates/nub-cli/src/compile/external.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/nub-cli/src/compile/external.rs b/crates/nub-cli/src/compile/external.rs index 0049115f9..3e85d1e70 100644 --- a/crates/nub-cli/src/compile/external.rs +++ b/crates/nub-cli/src/compile/external.rs @@ -91,15 +91,17 @@ const HOOK: &str = "__nub_external.mjs"; /// 23.5.0. The build succeeded and the ARTIFACT died at startup on `registerHooks is /// not a function`. A bare `--target 23` floors at 23.0.0, landing in that band. /// -/// KNOWN GAP, deliberate: a floor is a lower bound, so any pin whose FLOOR sits below -/// the band while its run-time acceptance extends past it still passes here and can be -/// run on 23.2. Three shapes do that under `--smol`: a range (`--target ">=22.15"`), a -/// major.minor pin (`--target 22.15`), and a `lts/` alias resolving onto a -/// pre-23.5 line (`--target lts/jod`) — the latter two carry no range into the manifest, -/// so `SmolTarget::matches` falls back to `candidate >= floor`. Closing -/// either needs the gate to see the whole acceptance set rather than its floor, which -/// is a different change; an exact three-part target, a bare major, and a `23.x` -/// range all floor inside the band and are caught. +/// KNOWN GAP, deliberate: this gate sees a FLOOR, not the pin's whole acceptance set. +/// A `--smol` pin slips through iff its resolved floor lands in 22.15.0..23.0.0 AND +/// the manifest carries nothing narrower than that floor — narrower being only an +/// `Exact` target (`smol_requires_exact_target`) or a floor-bearing `Range` that +/// `range_minimum_is` carries (and a carried `">=22.15"` still admits the band). +/// `SmolTarget::matches` then falls back to `candidate >= floor` and accepts a 23.2 +/// at launch. Illustrative shapes, not a closed list: `--target ">=22.15"`, +/// `--target 22.15`, `--target lts/jod`, `--target "<23"`. Closing the class needs +/// the gate to see the whole acceptance set rather than its floor, which is a +/// different change; an exact three-part target, a bare major, and a `23.x` range +/// all floor inside the band and are caught. pub fn check_node_support(version: &NodeVersion, source: &str, plan: &ShimPlan<'_>) -> Result<()> { if !plan.needed() || version.supports_augmentation() { return Ok(());