Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
122 changes: 105 additions & 17 deletions crates/nub-cli/src/compile/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -76,21 +76,31 @@ 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 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
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
/// 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 >= min_node() {
if !plan.needed() || version.supports_augmentation() {
return Ok(());
}
let flag = plan.flag();
Expand All @@ -99,14 +109,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}."
)
}

Expand Down Expand Up @@ -486,6 +507,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]
Expand Down
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
92 changes: 77 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,61 @@ 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
}

/// 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, 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 {
*self >= Self::MIN_AUGMENTED
*self >= self.fast_tier_floor_for_line()
}

/// 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 +96,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 +138,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 +332,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 +491,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
Loading
Loading