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
27 changes: 27 additions & 0 deletions crates/nub-cli/tests/version_tiers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 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,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`
Expand Down Expand Up @@ -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"));
Expand Down
28 changes: 28 additions & 0 deletions crates/nub-core/src/node/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions site/content/docs/runtime/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/detect-module/amb.js
Original file line number Diff line number Diff line change
@@ -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"));
4 changes: 4 additions & 0 deletions tests/fixtures/detect-module/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "detect-module-fixture",
"private": true
}
Loading