Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/nub-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3723,6 +3723,10 @@ fn build_script_command(
aug.apply_localstorage_env(|k, v| {
command.env(k, v);
});
// Import Text native-defer signal (Node ≥ 26.5.0); inherited by the subtree.
aug.apply_native_import_text_env(|k, v| {
command.env(k, v);
});
}

// Force nub's async tier for a script that runs a foreign async loader
Expand Down Expand Up @@ -4941,6 +4945,11 @@ fn apply_exec_augmentation(cmd: &mut std::process::Command, cwd: &Path) {
aug.apply_localstorage_env(|k, v| {
cmd.env(k, v);
});
// Import Text native-defer signal (Node ≥ 26.5.0); applied before the partial
// moves of aug below.
aug.apply_native_import_text_env(|k, v| {
cmd.env(k, v);
});
if let Some((k, val)) = force_async_tier {
cmd.env(k, val);
}
Expand Down
11 changes: 11 additions & 0 deletions crates/nub-cli/src/pm_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,10 @@ fn augmentation_to_lifecycle_overlay(
aug.apply_localstorage_env(|k, v| {
overlay.push((OsString::from(k), OsString::from(v)));
});
// Import Text native-defer signal (Node ≥ 26.5.0); inherited by the subtree.
aug.apply_native_import_text_env(|k, v| {
overlay.push((OsString::from(k), OsString::from(v)));
});
// Pin npm_node_execpath to the provisioned Node — the ABI fix. Independent
// of the shim: it flows even on the no-shim path so node-gyp never falls
// back to ambient. (npm_node_execpath stays the REAL binary, not the shim:
Expand Down Expand Up @@ -3529,6 +3533,7 @@ mod tests {
shim_dir: Some("/shim".to_string()),
node_path: Some(OsString::from("/rt/node_path")),
neutralize_localstorage: true,
native_import_text: true,
};
let (overlay, prepends) = augmentation_to_lifecycle_overlay(&aug, "/pinned/bin/node");

Expand Down Expand Up @@ -3567,6 +3572,11 @@ mod tests {
Some("1"),
"neutralize signal must flow to build-script node children when set"
);
assert_eq!(
find("__NUB_NATIVE_IMPORT_TEXT").as_deref(),
Some("1"),
"import-text native-defer signal must flow to build-script node children when set"
);
}

/// No shim set up (re-entrant / broken install) → no NODE override and no
Expand All @@ -3581,6 +3591,7 @@ mod tests {
shim_dir: None,
node_path: None,
neutralize_localstorage: false,
native_import_text: false,
};
let (overlay, prepends) = augmentation_to_lifecycle_overlay(&aug, "/pinned/bin/node");
assert!(prepends.is_empty());
Expand Down
61 changes: 61 additions & 0 deletions crates/nub-cli/tests/version_tiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,67 @@ fn import_text_works_on_compat_tier() {
);
}

/// Import Text on the FAST tier BELOW 26.5 (Node 24.x): sync `module.registerHooks`,
/// but native `--experimental-import-text` does not exist yet, so nub serves text imports
/// via its own `loadTextImport` short-circuit (the `nativeImportText=false` arm of the
/// makeHooks load hook). Pins the 24.x fast-tier path deterministically — the host-Node
/// `integration.rs` cases silently migrate to the native-defer path once the host reaches
/// >= 26.5 (AGENTS.md "latest major"), leaving [22.15, 26.5) otherwise uncovered.
#[test]
fn import_text_works_on_fast_tier_below_26_5() {
let Some((stdout, stderr, code)) = run_nub_against_node((24, 4, 0), "import-text", "main.ts")
else {
eprintln!("skipping: Node 24.4.0 not installed (set TEST_NODE_BIN_24_4_0 or nvm install)");
return;
};
assert_eq!(
code, 0,
"fast-tier (<26.5) import-text must succeed via nub's short-circuit: stderr={stderr}"
);
assert!(
stdout.contains(r##"md:"# Release notes\n\n- first\n- second\n""##),
"fast tier <26.5: .md read as text via nub's loadTextImport: stdout={stdout:?}"
);
assert!(
stdout.contains("yaml-is-string:true") && stdout.contains("json-is-string:true"),
"fast tier <26.5: the attribute wins over data-loader parsing: stdout={stdout:?}"
);
}

/// Import Text on the NATIVE tier (Node >= 26.5.0). There nub injects
/// `--experimental-import-text` and its preload DEFERS `with { type: "text" }` to
/// Node's native text translator (signaled by `__NUB_NATIVE_IMPORT_TEXT`) instead of
/// serving it with nub's own `loadTextImport`. The user-visible result must be
/// byte-identical to the compat/host tiers — SAME fixture, SAME assertions — proving
/// the mechanism swap is transparent, and the experimental warning native emits must
/// stay suppressed by nub's injected `--disable-warning=ExperimentalWarning`.
#[test]
fn import_text_works_via_native_on_26_5() {
let Some((stdout, stderr, code)) = run_nub_against_node((26, 5, 0), "import-text", "main.ts")
else {
eprintln!("skipping: Node 26.5.0 not installed (set TEST_NODE_BIN_26_5_0 or nvm install)");
return;
};
assert_eq!(
code, 0,
"native-tier import-text must succeed: stderr={stderr}"
);
assert!(
stdout.contains(r##"md:"# Release notes\n\n- first\n- second\n""##),
"native tier: .md read as text via Node's native translator: stdout={stdout:?}"
);
assert!(
stdout.contains("yaml-is-string:true") && stdout.contains("json-is-string:true"),
"native tier: the attribute wins over data-loader parsing on the native path: stdout={stdout:?}"
);
// Native's textStrategy emits ExperimentalWarning('Text import'); nub's injected
// --disable-warning=ExperimentalWarning must keep it off the user's stderr.
assert!(
!stderr.contains("ExperimentalWarning"),
"native import-text experimental warning must stay suppressed: stderr={stderr:?}"
);
}

/// Node 18.18.0 is one patch below the 18.19 floor — the boundary case
/// for the hard-error tier. Contract: stderr carries the canonical
/// refusal text, exit is non-zero, and (implicitly) Node was never
Expand Down
23 changes: 23 additions & 0 deletions crates/nub-core/src/node/feature_matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,29 @@ pub static FEATURES: &[Feature] = &[
],
evidence: "flag added 23.6.0 (23.x) / 22.20.0 (22.x backport); Stability 1.0; never default-on through Node 27",
},
// ── Import Text (`import s from "./f" with { type: "text" }`) ────────────
// `--experimental-import-text` makes Node's ESM loader honor the `type: "text"`
// import attribute on ANY extension, returning the raw file contents as a
// default-export string (Node's `textStrategy`). Flag ADDED in Node 26.5.0
// (#62300); still experimental and not default-on through Node 27 nightly, so the
// band is open-ended. Below 26.5.0 the flag does not exist ("bad option"
// hard-abort, verified on 26.2.0), so nub serves text imports via its OWN runtime
// augmentation there (the load-hook short-circuit in runtime/preload-common.cjs +
// transform-core.mjs `loadTextImport`), which is byte-identical to native (both
// TextDecoder default-export, BOM stripped). On [26.5.0, ∞) nub injects the flag
// AND its preload DEFERS text imports to Node's native translator — signaled via
// the internal `__NUB_NATIVE_IMPORT_TEXT` env var, set in spawn.rs iff this flag
// is in the final inject set — riding Node's own surface (augmenter contract).
// The experimental warning native emits is already silenced by nub's injected
// `--disable-warning=ExperimentalWarning` (≥20.11).
Feature {
name: "import-text",
mitigations: &[(
band((26, 5, 0), None),
Mitigation::Unflag("--experimental-import-text"),
)],
evidence: "flag added Node 26.5.0 (#62300); experimental, still flagged through Node 27 nightly",
},
// ── WebSocket global ────────────────────────────────────────────────────
// Flag-gated on [20.10.0, 22.0.0) — the global exists on 20.10+ and all of the
// 21.x line behind `--experimental-websocket`, then becomes default-on at
Expand Down
39 changes: 39 additions & 0 deletions crates/nub-core/src/node/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,45 @@ mod tests {
assert!(!compute_inject_flags(v(26, 0, 0), &[], None, false).contains(&s));
}

#[test]
fn import_text_injected_from_26_5() {
// `--experimental-import-text` was ADDED in Node 26.5.0 (#62300) and is still
// experimental (open-ended band). It does NOT exist below 26.5.0 — injecting it
// there is a "bad option" hard-abort (verified on real 26.2.0). Inject on
// [26.5.0, ∞), never below; below 26.5 nub serves text imports via its own
// runtime augmentation (the preload short-circuit), so the flag's absence there
// is correct, not a gap.
let f = "--experimental-import-text";
assert!(!compute_inject_flags(v(22, 15, 0), &[], None, false).contains(&f));
assert!(!compute_inject_flags(v(26, 4, 0), &[], None, false).contains(&f)); // flag absent → would crash
assert!(compute_inject_flags(v(26, 5, 0), &[], None, false).contains(&f)); // floor
assert!(compute_inject_flags(v(27, 0, 0), &[], None, false).contains(&f)); // open-ended
// A user opt-out subtracts it — so spawn.rs won't set the native-defer signal
// and nub's own short-circuit serves the import (never a defer without the flag).
assert!(
!compute_inject_flags(
v(26, 5, 0),
&["--no-experimental-import-text".to_string()],
None,
false
)
.contains(&f)
);
}

#[test]
fn import_text_stripped_from_node_options_below_26_5() {
// A parent nub on 26.5+ injects `--experimental-import-text` into an inherited
// NODE_OPTIONS; a child Node below 26.5 can't parse it (exit 9), so the derived
// floor (the matrix band's 26.5.0 `lo`) must snip it. Neighbors survive.
let opts = "--experimental-import-text --max-old-space-size=4096";
assert_eq!(
strip_unsupported_node_options(opts, &v(26, 4, 0)),
"--max-old-space-size=4096"
);
assert_eq!(strip_unsupported_node_options(opts, &v(26, 5, 0)), opts);
}

#[test]
fn user_opt_out_via_argv() {
let argv = vec!["--no-experimental-vm-modules".to_string()];
Expand Down
60 changes: 60 additions & 0 deletions crates/nub-core/src/node/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,20 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result<SpawnResult> {
cmd.arg(flag);
}

// Import Text: when the injected set carries `--experimental-import-text`
// (Node ≥ 26.5.0, not user-negated), signal the preload to DEFER text imports
// to Node's native translator. Derived from the actual `inject` vec, not a
// version predicate, so the signal is present exactly when the flag is —
// never a defer without the flag (which native rejects). The `else` CLEARS a
// stale inherited signal (an ancestor on 26.5+ set it) when this spawn does NOT
// inject the flag — a user `--no-experimental-import-text`, or a version
// downgrade — so the signal tracks the flag authoritatively. See NATIVE_IMPORT_TEXT_ENV.
if inject.contains(&"--experimental-import-text") {
cmd.env(NATIVE_IMPORT_TEXT_ENV, "1");
} else {
cmd.env_remove(NATIVE_IMPORT_TEXT_ENV);
}

// Web Storage: nub ALWAYS injects `--experimental-webstorage` on the band
// where that flag is the enabling mechanism (Node 22.4 through <25, i.e.
// `webstorage_flag_needed`), regardless of whether the user opted into
Expand Down Expand Up @@ -1079,6 +1093,11 @@ pub fn compute_augmentation_env(
// `--localstorage-file`.
let neutralize_localstorage =
should_neutralize_localstorage(&node_version, &[], existing_node_options.as_deref());
// Import Text native-defer signal (mirror of the direct-spawn site): derived from
// the actual `inject` vec so it tracks `--experimental-import-text` exactly —
// present iff the flag is injected (Node ≥ 26.5.0, not user-negated). Consumers
// apply it via `AugmentationEnv::apply_native_import_text_env`.
let native_import_text = inject.contains(&"--experimental-import-text");
if let Some(existing) = existing_node_options {
// Snip below-floor version-gated flags out of the inherited NODE_OPTIONS
// before appending (mirror of the direct-spawn site above) — a gated flag
Expand Down Expand Up @@ -1111,6 +1130,7 @@ pub fn compute_augmentation_env(
shim_dir,
node_path: vendored_node_path(Some(&preload)),
neutralize_localstorage,
native_import_text,
})
}

Expand All @@ -1129,6 +1149,11 @@ pub struct AugmentationEnv {
/// apply it via [`AugmentationEnv::apply_localstorage_env`]. See
/// `should_neutralize_localstorage`.
pub neutralize_localstorage: bool,
/// Whether to set the internal `__NUB_NATIVE_IMPORT_TEXT` env var on the child so
/// nub's preload DEFERS `with { type: "text" }` imports to Node's native translator
/// (Node ≥ 26.5.0, where nub injected `--experimental-import-text`). Consumers apply
/// it via [`AugmentationEnv::apply_native_import_text_env`]. See [`NATIVE_IMPORT_TEXT_ENV`].
pub native_import_text: bool,
}

impl AugmentationEnv {
Expand All @@ -1154,6 +1179,17 @@ impl AugmentationEnv {
}
}

/// Apply the Import Text native-defer signal to a child command's environment when
/// this augmentation calls for it (sets [`NATIVE_IMPORT_TEXT_ENV`]). A no-op when
/// `native_import_text` is false, so consumers can call it unconditionally. Factored
/// here so the internal var name lives in exactly one place. Mirrors
/// [`AugmentationEnv::apply_localstorage_env`].
pub fn apply_native_import_text_env(&self, set_env: impl FnOnce(&str, &str)) {
if self.native_import_text {
set_env(NATIVE_IMPORT_TEXT_ENV, "1");
}
}

pub fn node_shim_exe(&self) -> Option<std::ffi::OsString> {
self.shim_dir.as_deref().map(|dir| {
#[cfg(windows)]
Expand Down Expand Up @@ -1297,6 +1333,30 @@ pub(crate) const VERSION_ENV: &str = "__NUB_VERSION";
/// boundary.
pub(crate) const FORCE_ASYNC_TIER_ENV: &str = "__NUB_FORCE_ASYNC_TIER";

/// Tells nub's fast-tier preload to DEFER `import … with { type: "text" }` to Node's
/// native text translator instead of serving it with nub's own `loadTextImport`
/// short-circuit. Set only inside the augment block, coupled to the flag injection:
/// iff `--experimental-import-text` is in the final inject set (Node ≥ 26.5.0, and the
/// user did not `--no-`negate it). That coupling is the safety invariant — the preload
/// must never defer without the flag present (native throws on an unknown `text`
/// attribute), and a user `--no-experimental-import-text` correctly falls back to nub's
/// byte-identical impl. Inherited by the augmented subtree (like [`VERSION_ENV`], NOT
/// deleted by the preload) so re-entrant children — which skip the augment block but
/// inherit the flag via NODE_OPTIONS — also defer. Under `--node`/`NODE_COMPAT` no flag
/// is injected and the var is unset, so text imports are unsupported (= plain Node
/// without the flag). An internal `__NUB_*` plumbing var, NOT a user knob — explicitly
/// permitted by the brand boundary.
///
/// Because the var is never deleted, a stale value can be inherited across a version
/// DOWNGRADE. Two things keep that safe: the direct-spawn augment block CLEARS it
/// (`env_remove`) whenever it does not inject the flag, and the preload applies a hard
/// `>= 26.5.0` floor before honoring it — so a stale signal on an older Node falls back
/// to nub's byte-identical impl rather than deferring to a translator that isn't there.
/// (The script-runner/overlay paths set-but-do-not-clear; the preload floor is the
/// backstop there, and a same-version 26.5 stale signal only surfaces via off-contract
/// manual `__NUB_*` manipulation — the same inheritance caveat as any high-floor flag.)
pub(crate) const NATIVE_IMPORT_TEXT_ENV: &str = "__NUB_NATIVE_IMPORT_TEXT";

/// Node versions where the async `module.register` loader's `resolveSync`/
/// `loadSync` are unimplemented stubs that throw `ERR_METHOD_NOT_IMPLEMENTED`.
/// nub's sync `module.registerHooks` fast tier forces resolution synchronous, so
Expand Down
6 changes: 5 additions & 1 deletion runtime/preload-async-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ export async function load(url, context, nextLoad) {
// checked BEFORE extension dispatch so `import s from "./c.yaml" with {type:"text"}`
// returns the raw text, not parsed YAML. shortCircuits, so Node never runs its own
// unknown-'text'-attribute validation. (Node 18.20+ parses the `with` syntax; the
// 18.19.x floor cannot parse it at all — see the import-text thread.)
// 18.19.x floor cannot parse it at all — see the import-text thread.) Unconditional
// (no native-defer): this async loader-worker path runs on the compat tier (18.19–
// 22.14, all below Node 26.5's native `--experimental-import-text`), reachable on a
// 26.5+ Node ONLY via a forced async tier (`--no-experimental-require-module`), where
// nub's short-circuit is byte-identical to native — so deferring here buys nothing.
if (context?.importAttributes?.type === "text") return loadTextImport(url);
const ext = extname(url);
// node_modules deps are NEVER transpiled (the byte-parity boundary). This guard is
Expand Down
Loading