diff --git a/crates/nub-cli/src/cli.rs b/crates/nub-cli/src/cli.rs index 8b44035e7..f4526c710 100644 --- a/crates/nub-cli/src/cli.rs +++ b/crates/nub-cli/src/cli.rs @@ -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 @@ -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); } diff --git a/crates/nub-cli/src/pm_engine/mod.rs b/crates/nub-cli/src/pm_engine/mod.rs index fd732da55..54d2bbef8 100644 --- a/crates/nub-cli/src/pm_engine/mod.rs +++ b/crates/nub-cli/src/pm_engine/mod.rs @@ -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: @@ -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"); @@ -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 @@ -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()); diff --git a/crates/nub-cli/tests/version_tiers.rs b/crates/nub-cli/tests/version_tiers.rs index 3413bc4a2..1610c7f2e 100644 --- a/crates/nub-cli/tests/version_tiers.rs +++ b/crates/nub-cli/tests/version_tiers.rs @@ -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 diff --git a/crates/nub-core/src/node/feature_matrix.rs b/crates/nub-core/src/node/feature_matrix.rs index ed6315fe9..97e594aa9 100644 --- a/crates/nub-core/src/node/feature_matrix.rs +++ b/crates/nub-core/src/node/feature_matrix.rs @@ -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 diff --git a/crates/nub-core/src/node/flags.rs b/crates/nub-core/src/node/flags.rs index da156b449..0340ab82e 100644 --- a/crates/nub-core/src/node/flags.rs +++ b/crates/nub-core/src/node/flags.rs @@ -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()]; diff --git a/crates/nub-core/src/node/spawn.rs b/crates/nub-core/src/node/spawn.rs index 6b559db4f..52be90a65 100644 --- a/crates/nub-core/src/node/spawn.rs +++ b/crates/nub-core/src/node/spawn.rs @@ -478,6 +478,20 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result { 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 @@ -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 @@ -1111,6 +1130,7 @@ pub fn compute_augmentation_env( shim_dir, node_path: vendored_node_path(Some(&preload)), neutralize_localstorage, + native_import_text, }) } @@ -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 { @@ -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 { self.shim_dir.as_deref().map(|dir| { #[cfg(windows)] @@ -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 diff --git a/runtime/preload-async-hooks.mjs b/runtime/preload-async-hooks.mjs index 3c2803fc1..463fc8765 100644 --- a/runtime/preload-async-hooks.mjs +++ b/runtime/preload-async-hooks.mjs @@ -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 diff --git a/runtime/preload-common.cjs b/runtime/preload-common.cjs index 6607a9f1f..32c6586e5 100644 --- a/runtime/preload-common.cjs +++ b/runtime/preload-common.cjs @@ -422,6 +422,23 @@ function makeHooks(core, watchReporting) { installUserHookDetector(); installUserAsyncLoaderDetector(); + // Import Text: on Node >= 26.5.0 nub injects `--experimental-import-text` and signals + // here (via NATIVE_IMPORT_TEXT_ENV, set in spawn.rs iff that flag is in the final inject + // set) to DEFER `with { type: "text" }` to Node's native text translator rather than + // serving it with nub's own `loadTextImport`. Read ONCE at hook construction (like + // __NUB_FORCE_ASYNC_TIER, NOT deleted) so a re-entrant child that skips the augment + // block but inherits the flag via NODE_OPTIONS still defers. TWO gates, BOTH required: + // (1) the signal — coupled to the flag injection, so a user `--no-experimental-import-text` + // or a Node with no flag falls back to nub's byte-identical short-circuit; (2) a hard + // version floor (>= 26.5.0) — a SAFETY net, since the signal is never deleted and thus + // can be inherited across a version DOWNGRADE (an ancestor on 26.5+ set it, this child + // runs an older Node with the flag stripped from NODE_OPTIONS). Both gates fail SAFE to + // nub's own impl, so we never defer to a native translator that isn't there. + const [__nodeMaj, __nodeMin] = process.versions.node.split(".").map((n) => parseInt(n, 10)); + const nativeImportText = + process.env.__NUB_NATIVE_IMPORT_TEXT === "1" && + (__nodeMaj > 26 || (__nodeMaj === 26 && __nodeMin >= 5)); + function resolve(specifier, context, nextResolve) { const r = core.resolveSpec(specifier, context.parentURL); if (r) return r; @@ -507,10 +524,15 @@ function makeHooks(core, watchReporting) { // returns raw text, not parsed YAML. Placed after watch reporting (so a text file // gets the same watch treatment as any other), and before the extension/data // dispatch (so the attribute wins over the `.txt`/`.yaml`/… data loaders and - // Node-native JSON). shortCircuits, so Node's own unknown-'text'-attribute - // validation never runs. Mirror of the compat-tier hook in preload-async-hooks.mjs. - // (Node 18.20+ parses the `with` syntax; the 18.19.x floor cannot.) - if (context?.importAttributes?.type === "text") return core.loadTextImport(url); + // Node-native JSON). On Node >= 26.5.0 (nativeImportText) defer to `nextLoad` — Node's + // native text translator, enabled by nub's injected `--experimental-import-text`; + // keeping the check ahead of extension dispatch means the attribute still wins over + // nub's data loaders in BOTH cases. Below that, nub's `loadTextImport` shortCircuits, + // so Node's own unknown-'text'-attribute validation never runs. (Node 18.20+ parses + // the `with` syntax; the 18.19.x floor cannot.) + if (context?.importAttributes?.type === "text") { + return nativeImportText ? nextLoad(url, context) : core.loadTextImport(url); + } // A USER resolve hook (a ts-node/tsx-style transpiler registered AFTER nub's // own preload hook) claimed this file with the bare 'typescript' format: defer