From 1562df7006ba78244558d2e2ea71e4f3f294fc43 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:21:21 -0700 Subject: [PATCH] feat(runtime): backport --experimental-detect-module for ambiguous ESM .js Add a feature-matrix row that injects --experimental-detect-module on the Node versions where the flag exists and is still required, so an ambiguous ESM .js (ES-module syntax, no package.json "type" field) runs as an ES module under nub on older Node where bare Node refuses it with "To load an ES module, set type: module". The flag was introduced at 21.1.0 (#50096) and backported to 20.10.0; it never existed on the 18.x/19.x lines and 21.0.0 predates the 21.1.0 landing (a one-version hole, like eventsource's 21.x). It became default-on at 20.19.0 and 22.7.0 (#53619). Inject only where the flag both exists and is still required: [20.10.0, 20.19.0) u [21.1.0, 22.7.0). The flag survives as an accepted no-op past the cutover, but the bands are kept tight (matching sqlite/wasm) so nub never open-ended-injects a flag a future Node could remove. Purely additive: a previously-failing file now runs; a CJS .js has no ESM syntax to detect, so nothing that worked breaks, and --node/NODE_COMPAT stay unaugmented. Adds unit tests (matrix boundaries + injection), an e2e integration test on Node 20.11.0 with a hermetic ambiguous fixture, and a docs note on the TypeScript page's plain-JavaScript section. Claude-Session: https://claude.ai/code/session_01KD1nceRRAquHtFbCV8azon --- crates/nub-cli/tests/version_tiers.rs | 27 ++++++++++++ crates/nub-core/src/node/feature_matrix.rs | 48 ++++++++++++++++++++++ crates/nub-core/src/node/flags.rs | 28 +++++++++++++ site/content/docs/runtime/typescript.mdx | 2 + tests/fixtures/detect-module/amb.js | 8 ++++ tests/fixtures/detect-module/package.json | 4 ++ 6 files changed, 117 insertions(+) create mode 100644 tests/fixtures/detect-module/amb.js create mode 100644 tests/fixtures/detect-module/package.json diff --git a/crates/nub-cli/tests/version_tiers.rs b/crates/nub-cli/tests/version_tiers.rs index 73ada992f..ae836340c 100644 --- a/crates/nub-cli/tests/version_tiers.rs +++ b/crates/nub-cli/tests/version_tiers.rs @@ -258,6 +258,33 @@ fn import_text_works_via_native_on_26_5() { ); } +/// Module syntax detection backport on the COMPAT tier. Node 20.11.0 sits in the +/// `--experimental-detect-module` injected band ([20.10, 20.19), before the 20.19 +/// default-on cutover), so an ambiguous ESM `.js` — ES-module syntax, `.js` +/// extension, a `package.json` with no `"type"` field — that BARE Node 20.11 +/// refuses ("To load an ES module, set type: module", exit 1) must run as ESM +/// under nub, which injects the flag. This is the differential the backport +/// exists to close; a fast/native-tier version needs no injection (Node defaults +/// it on) so it is not the interesting case. +#[test] +fn detect_module_backport_runs_ambiguous_esm_on_compat_tier() { + let Some((stdout, stderr, code)) = run_nub_against_node((20, 11, 0), "detect-module", "amb.js") + else { + eprintln!( + "skipping: Node 20.11.0 not installed (set TEST_NODE_BIN_20_11_0 or nvm install)" + ); + return; + }; + assert_eq!( + code, 0, + "compat-tier ambiguous ESM .js must run as ESM under nub (bare Node 20.11 refuses it): stderr={stderr}" + ); + assert!( + stdout.contains("detect-module:ran-as-esm:true"), + "ambiguous .js must be detected + run as an ES module: stdout={stdout:?}" + ); +} + /// 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 39026fd8b..96302cffb 100644 --- a/crates/nub-core/src/node/feature_matrix.rs +++ b/crates/nub-core/src/node/feature_matrix.rs @@ -263,6 +263,38 @@ 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", }, + // ── Module syntax detection (ambiguous ESM `.js` "just works") ─────────── + // `--experimental-detect-module` makes Node parse an ambiguous file — a `.js` + // (or extensionless) file with no `package.json` `"type"` field — and run it as + // ESM when ES-module syntax is detected, else CommonJS. WITHOUT it an ambiguous + // ESM `.js` aborts ("To load an ES module, set type: module"); nub injecting it + // closes that gap so the file runs like it would on a newer Node (the core + // "future Node on old Node" mission). Flag introduced on the 21.x line at 21.1.0 + // (#50096) and backported to the 20.x line at 20.10.0; the 18.x and 19.x lines + // NEVER received it (injecting there is a "bad option" abort), and 21.0.0 + // predates the 21.1.0 landing — the same one-version hole as eventsource's 21.x. + // Unflagged (default-on, flag → no-op) at 20.19.0 on the 20.x line and 22.7.0 on + // the 22.x line (both #53619); the 21.x line reached EOL before 22.7.0, so it + // stays flag-gated its whole length. Inject only where the flag both EXISTS and + // is still REQUIRED, tight-banded like sqlite/wasm (the flag survives as an + // accepted no-op past the cutover, so over-injection would be harmless — verified + // rc 0 on 20.19/22.11+/24/26 — but the bands are kept tight so nub never + // open-ended-injects a flag a future Node could remove): + // [20.10.0, 20.19.0) ∪ [21.1.0, 22.7.0). + Feature { + name: "detect-module", + mitigations: &[ + ( + band((20, 10, 0), Some((20, 19, 0))), + Mitigation::Unflag("--experimental-detect-module"), + ), + ( + band((21, 1, 0), Some((22, 7, 0))), + Mitigation::Unflag("--experimental-detect-module"), + ), + ], + evidence: "flag added 21.1.0 (#50096) / 20.10.0 (20.x backport); never on 18.x/19.x or 21.0.x; default-on 20.19.0 & 22.7.0 (#53619)", + }, // ── import-text (importing source as text via import attributes) ───────── // `import txt from './x.txt' with { type: 'text' }` — the module's default export // is the file's string contents. Node gained this behind `--experimental-import-text` @@ -798,6 +830,22 @@ mod tests { assert!(!unflag_flags_for(&v(23, 5, 0)).contains(&addon)); assert!(unflag_flags_for(&v(23, 6, 0)).contains(&addon)); assert!(unflag_flags_for(&v(26, 2, 0)).contains(&addon)); + // detect-module: [20.10, 20.19) ∪ [21.1, 22.7). Never on 18.x/19.x/21.0.x + // (flag absent → "bad option" crash); off in the default-on ranges. + let dm = "--experimental-detect-module"; + assert!(!unflag_flags_for(&v(18, 19, 0)).contains(&dm)); // never on 18.x + assert!(!unflag_flags_for(&v(19, 3, 0)).contains(&dm)); // never on 19.x + assert!(!unflag_flags_for(&v(20, 9, 0)).contains(&dm)); // below the 20.10 floor + assert!(unflag_flags_for(&v(20, 10, 0)).contains(&dm)); // 20.x floor + assert!(unflag_flags_for(&v(20, 18, 0)).contains(&dm)); + assert!(!unflag_flags_for(&v(20, 19, 0)).contains(&dm)); // default-on (20.x) + assert!(!unflag_flags_for(&v(21, 0, 0)).contains(&dm)); // the 21.0.x hole (flag absent → crash) + assert!(unflag_flags_for(&v(21, 1, 0)).contains(&dm)); // 21.x floor + assert!(unflag_flags_for(&v(22, 0, 0)).contains(&dm)); + assert!(unflag_flags_for(&v(22, 6, 0)).contains(&dm)); + assert!(!unflag_flags_for(&v(22, 7, 0)).contains(&dm)); // default-on (22.x) + assert!(!unflag_flags_for(&v(24, 0, 0)).contains(&dm)); // default-on everywhere after + assert!(!unflag_flags_for(&v(26, 5, 0)).contains(&dm)); // websocket: [20.10, 22.0). assert!(!unflag_flags_for(&v(20, 9, 0)).contains(&"--experimental-websocket")); assert!(unflag_flags_for(&v(20, 10, 0)).contains(&"--experimental-websocket")); diff --git a/crates/nub-core/src/node/flags.rs b/crates/nub-core/src/node/flags.rs index 0cf5a037f..9b63f862f 100644 --- a/crates/nub-core/src/node/flags.rs +++ b/crates/nub-core/src/node/flags.rs @@ -413,6 +413,34 @@ mod tests { assert_eq!(strip_unsupported_node_options(it, &v(26, 5, 0)), it); } + #[test] + fn detect_module_injected_only_in_the_two_flagged_bands() { + // Module syntax detection: flag added 21.1.0 (#50096) / 20.10.0 (20.x + // backport), default-on at 20.19.0 & 22.7.0 (#53619). Never on 18.x/19.x, + // and 21.0.x predates the 21.1.0 landing — injecting where the flag is + // absent is a "bad option" crash. Inject on [20.10, 20.19) ∪ [21.1, 22.7). + let dm = "--experimental-detect-module"; + assert!(!compute_inject_flags(v(18, 19, 0), &[], None, false).contains(&dm)); // never 18.x + assert!(!compute_inject_flags(v(20, 9, 0), &[], None, false).contains(&dm)); // below floor + assert!(compute_inject_flags(v(20, 10, 0), &[], None, false).contains(&dm)); // 20.x floor + assert!(compute_inject_flags(v(20, 18, 0), &[], None, false).contains(&dm)); + assert!(!compute_inject_flags(v(20, 19, 0), &[], None, false).contains(&dm)); // default-on (20.x) + assert!( + !compute_inject_flags(v(21, 0, 0), &[], None, false).contains(&dm), + "must NOT inject --experimental-detect-module on 21.0 (flag landed at 21.1.0 → crash)" + ); + assert!(compute_inject_flags(v(21, 1, 0), &[], None, false).contains(&dm)); // 21.x floor + assert!(compute_inject_flags(v(22, 6, 0), &[], None, false).contains(&dm)); + assert!(!compute_inject_flags(v(22, 7, 0), &[], None, false).contains(&dm)); // default-on (22.x) + assert!(!compute_inject_flags(v(26, 5, 0), &[], None, false).contains(&dm)); + // A user opt-out subtracts it. + let argv = vec!["--no-experimental-detect-module".to_string()]; + assert!(!compute_inject_flags(v(20, 10, 0), &argv, None, false).contains(&dm)); + // Inherited NODE_OPTIONS: stripped below the 20.10 existence floor, kept above. + assert_eq!(strip_unsupported_node_options(dm, &v(20, 9, 0)), ""); + assert_eq!(strip_unsupported_node_options(dm, &v(20, 10, 0)), dm); + } + #[test] fn shadow_realm_never_injected() { // ShadowRealm is DELIBERATELY not auto-unflagged (the harmony-flag policy): diff --git a/site/content/docs/runtime/typescript.mdx b/site/content/docs/runtime/typescript.mdx index 20ccc3873..af1bb88e8 100644 --- a/site/content/docs/runtime/typescript.mdx +++ b/site/content/docs/runtime/typescript.mdx @@ -50,6 +50,8 @@ const letters = /\p{Letter}/v; A file with nothing to lower is handed to Node untouched — byte for byte, no reformatting and no source-map footer — so a plain `.js` that needs no transform is exactly the file you wrote. Files inside `node_modules` are never transpiled; they load as the package shipped them, on every tier. +An ambiguous `.js` — ES-module syntax with no `"type"` field in `package.json` — runs as an ES module on Node 20.10 and up, matching a current Node. A bare older Node refuses the same file with `To load an ES module, set "type": "module"`; under Nub it just runs. Add a `"type"` field, or use an `.mjs` / `.cjs` extension, to set the format explicitly. + JSX in a `.js` file is out of scope — use `.jsx`. Nub parses `.js` as JavaScript, not JSX, so a `<` is a comparison, not an element. ## Explicit resource management diff --git a/tests/fixtures/detect-module/amb.js b/tests/fixtures/detect-module/amb.js new file mode 100644 index 000000000..c7db4044e --- /dev/null +++ b/tests/fixtures/detect-module/amb.js @@ -0,0 +1,8 @@ +// Ambiguous module: ES-module syntax, .js extension, and a package.json with +// NO "type" field — so Node treats it as ambiguous and needs module syntax +// detection (--experimental-detect-module, injected by nub below the default-on +// line) to run it as ESM. Bare old Node (< the detect-module default-on cutover) +// refuses this file. +import { fileURLToPath } from "node:url"; +const here = fileURLToPath(import.meta.url); +console.log("detect-module:ran-as-esm:" + here.endsWith("amb.js")); diff --git a/tests/fixtures/detect-module/package.json b/tests/fixtures/detect-module/package.json new file mode 100644 index 000000000..63d20fb32 --- /dev/null +++ b/tests/fixtures/detect-module/package.json @@ -0,0 +1,4 @@ +{ + "name": "detect-module-fixture", + "private": true +}