@nubjs/loader 0.8.3 → 0.9.0-canary.20260909.509

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/loader-entry.mjs CHANGED
@@ -87,13 +87,16 @@ function isOwnLoaderToken(value) {
87
87
  // delivery channel (execArgv or NODE_OPTIONS). Same two-channel scan as
88
88
  // preload-common's computeForeignAsyncLoaderFlagPresent, but value-aware so our
89
89
  // own token is excluded.
90
- function foreignAsyncLoaderPresent() {
90
+ function foreignAsyncLoaderPresent(includeRequire = false) {
91
91
  const tokens = [];
92
+ const flags = ["--import", "--loader", "--experimental-loader", "--experimental_loader"];
93
+ if (includeRequire) flags.push("--require", "-r");
92
94
  const argv = Array.isArray(process.execArgv) ? process.execArgv : [];
93
95
  for (let i = 0; i < argv.length; i++) {
94
96
  const a = argv[i];
95
97
  if (typeof a !== "string") continue;
96
- for (const flag of ["--import", "--loader", "--experimental-loader"]) {
98
+ if (includeRequire && a.startsWith("-r") && a.length > 2) tokens.push(a.slice(2));
99
+ for (const flag of flags) {
97
100
  if (a === flag) {
98
101
  if (typeof argv[i + 1] === "string") tokens.push(argv[i + 1]);
99
102
  } else if (a.startsWith(`${flag}=`)) {
@@ -103,7 +106,9 @@ function foreignAsyncLoaderPresent() {
103
106
  }
104
107
  const opts = process.env.NODE_OPTIONS;
105
108
  if (typeof opts === "string" && opts !== "") {
106
- const re = /(?:^|\s)--(?:experimental-)?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
109
+ const re = includeRequire
110
+ ? /(?:^|\s)(?:--(?:(?:experimental[-_])?(?:import|loader)|require)(?:=|\s)|-r(?:\s|=)?)("[^"]*"|\S*)/g
111
+ : /(?:^|\s)--(?:experimental[-_])?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
107
112
  for (const match of opts.matchAll(re)) {
108
113
  tokens.push((match[1] || "").replace(/^"|"$/g, ""));
109
114
  }
@@ -153,11 +158,16 @@ export function arm({ esm = true, cjs = true } = {}) {
153
158
  // loader (tsx, ts-node, an OTel ESM attach) would crash resolution. Register
154
159
  // via the async path there instead so both loaders compose all-async — the same
155
160
  // tier decision the CLI preload makes, minus counting ourselves as foreign.
156
- const forceAsync = common.nodeHookComposeBroken() && foreignAsyncLoaderPresent();
161
+ const foreignLoaderFlagPresent = foreignAsyncLoaderPresent();
162
+ const forceAsync = common.nodeHookComposeBroken() && foreignLoaderFlagPresent;
157
163
 
158
164
  if (wantEsm) {
159
165
  if (hasSyncHooks && !forceAsync) {
160
- const { resolve, load } = common.makeHooks(core, watchReporting);
166
+ // The standalone --import is our own loader, not a foreign async hook.
167
+ // Earlier --require preloads may have registered hooks before our detectors
168
+ // were installed. Decline the cache repair for them too, without assuming
169
+ // their hooks are async when choosing the composition tier above.
170
+ const { resolve, load } = common.makeHooks(core, watchReporting, foreignAsyncLoaderPresent(true));
161
171
  module_.registerHooks({ resolve, load });
162
172
  armed.esmMode = "sync";
163
173
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubjs/loader",
3
- "version": "0.8.3",
3
+ "version": "0.9.0-canary.20260909.509",
4
4
  "description": "Standalone TypeScript loader for Node.js from the Nub project — TypeScript, JSX, tsconfig paths, and data-format imports through a native transform, registered the way tsx and ts-node are",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/nubjs/nub",
@@ -49,13 +49,13 @@
49
49
  "@oxc-project/runtime": "0.140.0"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nubjs/loader-darwin-arm64": "0.8.3",
53
- "@nubjs/loader-darwin-x64": "0.8.3",
54
- "@nubjs/loader-linux-x64": "0.8.3",
55
- "@nubjs/loader-linux-x64-musl": "0.8.3",
56
- "@nubjs/loader-linux-arm64": "0.8.3",
57
- "@nubjs/loader-linux-arm64-musl": "0.8.3",
58
- "@nubjs/loader-win32-x64": "0.8.3",
59
- "@nubjs/loader-win32-arm64": "0.8.3"
52
+ "@nubjs/loader-darwin-arm64": "0.9.0-canary.20260909.509",
53
+ "@nubjs/loader-darwin-x64": "0.9.0-canary.20260909.509",
54
+ "@nubjs/loader-linux-x64": "0.9.0-canary.20260909.509",
55
+ "@nubjs/loader-linux-x64-musl": "0.9.0-canary.20260909.509",
56
+ "@nubjs/loader-linux-arm64": "0.9.0-canary.20260909.509",
57
+ "@nubjs/loader-linux-arm64-musl": "0.9.0-canary.20260909.509",
58
+ "@nubjs/loader-win32-x64": "0.9.0-canary.20260909.509",
59
+ "@nubjs/loader-win32-arm64": "0.9.0-canary.20260909.509"
60
60
  }
61
61
  }
@@ -30,6 +30,7 @@ import "./floor-builtin.mjs";
30
30
  import {
31
31
  TRANSPILE_EXTS, PLAIN_JS_EXTS, CLOBBER_MAP, dataExtsFor,
32
32
  extname, isFileUrl, resolveSpec, loadTranspile, maybeTranspilePlainJs, loadData, loadTextImport, isDependency,
33
+ noteRuntimeV8FlagSource, outerHookOwnsFormat,
33
34
  } from "./transform-core.mjs";
34
35
  import { createRequire, isBuiltin } from "node:module";
35
36
  import { existsSync } from "node:fs";
@@ -92,7 +93,14 @@ export async function resolve(specifier, context, nextResolve) {
92
93
  }
93
94
 
94
95
  // ── Load hook ───────────────────────────────────────────────────────
96
+ // Every result passes through the runtime-V8-flag scan before Node compiles it —
97
+ // see transform-core `noteRuntimeV8FlagSource`. Awaited here because the
98
+ // `nextLoad` branch of loadInner hands back a promise on this tier.
95
99
  export async function load(url, context, nextLoad) {
100
+ return noteRuntimeV8FlagSource(await loadInner(url, context, nextLoad));
101
+ }
102
+
103
+ async function loadInner(url, context, nextLoad) {
96
104
  // Import Text (attribute-keyed): honor `with { type: "text" }` on ANY extension,
97
105
  // checked BEFORE extension dispatch so `import s from "./c.yaml" with {type:"text"}`
98
106
  // returns the raw text, not parsed YAML. shortCircuits, so Node never runs its own
@@ -106,6 +114,11 @@ export async function load(url, context, nextLoad) {
106
114
  // fast-tier hook.
107
115
  if (context?.importAttributes?.type === "text" && isFileUrl(url)) return loadTextImport(url);
108
116
  const ext = extname(url);
117
+ // A user loader above nub's assigned this TypeScript file a bare module format
118
+ // and will transform it itself from the raw source (tsx's pattern; see
119
+ // `outerHookOwnsFormat`). This tier has no registration counter like the fast
120
+ // tier's and needs none: nothing but such a hook produces the bare form here.
121
+ if (outerHookOwnsFormat(context?.format, ext) && !isDependency(url)) return nextLoad(url, context);
109
122
  // node_modules deps are NEVER transpiled (the byte-parity boundary). This guard is
110
123
  // make-or-break now that TRANSPILE_EXTS includes `.js`/`.mjs`/`.cjs`: without it,
111
124
  // the compat tier would route every dependency `.js` through oxc. (loadTranspile's
@@ -34,14 +34,47 @@ const { join, dirname, extname: pathExtname } = getBuiltin("node:path");
34
34
  // 16 + Turbopack build died on `--js-defer-import-eval`. V8 parses these flags at
35
35
  // startup, so dropping them here keeps the feature ON while restoring the execArgv a
36
36
  // plain-Node user would have seen. Only flags NUB injected are removed; a user's own
37
- // `v8Flags` stay visible, because those are the user's choice to reason about.
37
+ // `v8Flags` stay visible, because those are the user's choice to reason about. (The
38
+ // set is empty today — `--js-defer-import-eval` moved to a runtime flip, see
39
+ // transform-core `noteRuntimeV8FlagSource` — but the hygiene stays for any future
40
+ // argv-only row.)
41
+ // The flags have to be hidden on two boundaries, and no single channel spans both.
42
+ //
43
+ // The ENV VAR crosses a PROCESS boundary: the Rust spawn layer sets it on a Node it
44
+ // starts. Deleting it after use is what stops a descendant from hiding a flag its own
45
+ // user passed, so that hygiene stays.
46
+ //
47
+ // WORKER ENVIRONMENT DATA crosses a THREAD boundary, which the env var cannot. Node
48
+ // starts a worker from the process's REAL exec argv — flags and all — whatever the main
49
+ // thread filtered, so the worker has to filter again, and this preload runs there to do
50
+ // it. Three measured properties make this the right channel and an env copy the wrong
51
+ // one (verified on 18.19 and 26.7): it survives `new Worker(…, { env: {} })`, which
52
+ // REPLACES the environment outright and would otherwise strand that worker with the flags
53
+ // visible; it is transitive to nested workers; and it does NOT cross a process boundary,
54
+ // so a thread of this process is separated from a descendant structurally rather than by
55
+ // guesswork.
56
+ const ARGV_ONLY_FLAGS_KEY = "nub.argv-only-flags";
57
+ // A compiled artifact whose sealed graph cannot reach Worker or worker_threads has
58
+ // no second thread, so nothing can have written this channel and nobody can read
59
+ // what we publish to it — only the env var below carries flags into such a process.
60
+ // Asking anyway loads the builtin, and its subgraph is eight internal modules on
61
+ // every run. This module's EVALUATION is part of the compile preamble's static
62
+ // import graph, so no call-gating reaches that cost; the same signal and the same
63
+ // deliberately over-detecting build-time scan gate the preamble's own Worker branch.
64
+ // The record is published by the bootstrap's `--require`, ahead of any ESM here,
65
+ // and is absent outside a compiled artifact — so this reads false there and the
66
+ // load stays eager, which is the only behaviour an ordinary run ever had.
67
+ const workerless = compileBootstrap?.needsWorker === false;
38
68
  try {
39
- const injectedArgvFlags = process.env.__NUB_ARGV_ONLY_FLAGS;
69
+ const workerThreads = workerless ? null : getBuiltin("node:worker_threads");
70
+ const fromEnv = process.env.__NUB_ARGV_ONLY_FLAGS;
71
+ const injectedArgvFlags = fromEnv || workerThreads?.getEnvironmentData(ARGV_ONLY_FLAGS_KEY);
40
72
  if (injectedArgvFlags) {
41
- // Delete rather than propagate: a descendant that nub augments gets its own
42
- // signal, and one that nub does not never had the flags on argv anyway.
43
- delete process.env.__NUB_ARGV_ONLY_FLAGS;
44
- const injected = new Set(injectedArgvFlags.split(" ").filter(Boolean));
73
+ if (fromEnv) {
74
+ delete process.env.__NUB_ARGV_ONLY_FLAGS;
75
+ workerThreads?.setEnvironmentData(ARGV_ONLY_FLAGS_KEY, fromEnv);
76
+ }
77
+ const injected = new Set(String(injectedArgvFlags).split(" ").filter(Boolean));
45
78
  if (Array.isArray(process.execArgv)) {
46
79
  process.execArgv = process.execArgv.filter((arg) => !injected.has(arg));
47
80
  }
@@ -64,7 +97,15 @@ const VERSION_ENV = "__NUB_VERSION";
64
97
  // feature-matrix `import-text` bands cover every release that KNOWS the flag: on a
65
98
  // version nub steps aside on but does not inject for, the import falls through to
66
99
  // Node's default loader and dies with ERR_UNKNOWN_FILE_EXTENSION (#688).
67
- const NATIVE_IMPORT_TEXT = process.allowedNodeEnvironmentFlags.has("--experimental-import-text");
100
+ // Read LAZILY. The first touch of `process.allowedNodeEnvironmentFlags` materialises
101
+ // Node's entire accepted-flag set, and as a top-level const that cost 0.355 ms of
102
+ // this module's evaluation (child CPU, 200 runs, against a 0.000 ms control) on
103
+ // every nub process and inside every compiled artifact — where this module is a
104
+ // static import of the preamble, so nothing call-gated can reach it. Both readers
105
+ // sit inside the load hook's `type: "text"` arm, which most programs never take.
106
+ let __nativeImportText;
107
+ const nativeImportText = () =>
108
+ (__nativeImportText ??= process.allowedNodeEnvironmentFlags.has("--experimental-import-text"));
68
109
 
69
110
  // ── data: URL unknown-format fidelity helpers ───────────────────────
70
111
  // Mirror Node's internal/modules/esm/get_format.js so nub's sync registerHooks load
@@ -190,6 +231,10 @@ function installWatchReporting(core) {
190
231
  // `type` (transpile — there is no user hook to do it, and Node's strip-only mode
191
232
  // can't handle enums/namespaces). See makeHooks().load.
192
233
  let __userHooksRegistered = false;
234
+ // Narrower: a user registration that brought a `load` hook, so it can transform
235
+ // what nub hands back. A resolve-only user hook that labels a `.ts` file with a
236
+ // bare `commonjs`/`module` still relies on nub as the transformer.
237
+ let __userLoadHookRegistered = false;
193
238
  function installUserHookDetector() {
194
239
  if (typeof module_.registerHooks !== "function") return;
195
240
  const orig = module_.registerHooks;
@@ -197,7 +242,10 @@ function installUserHookDetector() {
197
242
  let seen = 0;
198
243
  const wrapped = function (...args) {
199
244
  // Call #1 is nub's own preload registration; #2+ are user hooks.
200
- if (seen >= 1) __userHooksRegistered = true;
245
+ if (seen >= 1) {
246
+ __userHooksRegistered = true;
247
+ if (args[0] && typeof args[0].load === "function") __userLoadHookRegistered = true;
248
+ }
201
249
  seen += 1;
202
250
  return orig.apply(this, args);
203
251
  };
@@ -252,6 +300,7 @@ function cliAsyncLoaderPresent() {
252
300
  if (
253
301
  a === "--loader" || a.startsWith("--loader=") ||
254
302
  a === "--experimental-loader" || a.startsWith("--experimental-loader=") ||
303
+ a === "--experimental_loader" || a.startsWith("--experimental_loader=") ||
255
304
  a === "--import" || a.startsWith("--import=")
256
305
  ) { present = true; break; }
257
306
  }
@@ -275,9 +324,10 @@ function cliAsyncLoaderPresent() {
275
324
  // against a user async loader, and Node rejected the `commonjs-sync`+null-source pair
276
325
  // (#669). That helper also excludes nub's OWN preload chainer, which rides NODE_OPTIONS
277
326
  // as `--import`; a raw scan here would read it as a user loader and silently decline
278
- // the relabel for every chained project.
279
- function userAsyncLoaderActive() {
280
- return __userAsyncLoaderRegistered || foreignAsyncLoaderFlagPresent();
327
+ // the relabel for every chained project. The standalone loader supplies its
328
+ // value-aware flag scan instead, excluding its own --import entrypoint too.
329
+ function userAsyncLoaderActive(foreignLoaderFlagPresent) {
330
+ return __userAsyncLoaderRegistered || foreignLoaderFlagPresent;
281
331
  }
282
332
 
283
333
  // The Node band where the async `module.register` loader's `resolveSync`/`loadSync`
@@ -328,7 +378,7 @@ function computeForeignAsyncLoaderFlagPresent() {
328
378
  if (cliAsyncLoaderPresent()) return true; // execArgv channel
329
379
  const opts = process.env.NODE_OPTIONS;
330
380
  if (typeof opts !== "string" || opts === "") return false;
331
- const re = /(?:^|\s)--(?:experimental-)?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
381
+ const re = /(?:^|\s)--(?:experimental[-_])?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
332
382
  for (const match of opts.matchAll(re)) {
333
383
  const value = (match[1] || "").replace(/^"|"$/g, "");
334
384
  if (!NUB_CHAIN_MARKER.test(value)) return true;
@@ -593,7 +643,7 @@ function restoreSchemeOnlyBuiltinURL(result) {
593
643
  }
594
644
  }
595
645
 
596
- function makeHooks(core, watchReporting) {
646
+ function makeHooks(core, watchReporting, foreignLoaderFlagPresent = foreignAsyncLoaderFlagPresent()) {
597
647
  installUserHookDetector();
598
648
  installUserAsyncLoaderDetector();
599
649
 
@@ -655,14 +705,22 @@ function makeHooks(core, watchReporting) {
655
705
  const source = readFileSync(path);
656
706
  const pkgType = core.getPackageType(dirname(path));
657
707
  const format = core.moduleFormatFor(ext, pkgType, path, source.toString("utf8"));
658
- if (format === "commonjs") return { format: "commonjs", source: null, shortCircuit: true };
659
- return { format, source, shortCircuit: true };
708
+ if (format === "commonjs") return { format: "commonjs", source: null, responseURL: url, shortCircuit: true };
709
+ return { format, source, responseURL: url, shortCircuit: true };
660
710
  } catch {
661
711
  return null;
662
712
  }
663
713
  }
664
714
 
715
+ // Every load result passes through the runtime-V8-flag scan BEFORE Node compiles
716
+ // it, whichever branch of loadInner produced it — see transform-core
717
+ // `noteRuntimeV8FlagSource`. A no-op (one null check) unless the spawn layer armed
718
+ // a flag for this Node.
665
719
  function load(url, context, nextLoad) {
720
+ return core.noteRuntimeV8FlagSource(loadInner(url, context, nextLoad));
721
+ }
722
+
723
+ function loadInner(url, context, nextLoad) {
666
724
  const ext = core.extname(url);
667
725
 
668
726
  // Watch mode: surface this file's nearest config files (tsconfig.json,
@@ -679,7 +737,7 @@ function makeHooks(core, watchReporting) {
679
737
 
680
738
  // Import Text (attribute-keyed): honor `with { type: "text" }` on ANY extension,
681
739
  // ahead of extension dispatch so `import s from "./c.yaml" with {type:"text"}`
682
- // returns raw text, not parsed YAML. Where Node knows the flag (NATIVE_IMPORT_TEXT
740
+ // returns raw text, not parsed YAML. Where Node knows the flag (`nativeImportText()`
683
741
  // — 24.19+ on the 24.x line, 26.5+ on 26.x) step aside and let Node's own
684
742
  // textStrategy own it — nub injects --experimental-import-text there, so the
685
743
  // additive "would plain Node + the flag do the same?" test holds and users get
@@ -700,8 +758,8 @@ function makeHooks(core, watchReporting) {
700
758
  // the unknown-data-URL-format trap below instead of Node's own text answer.
701
759
  // A non-`file:` URL on the polyfill tier falls through to `nextLoad` with
702
760
  // every other unclaimed URL.
703
- if (context?.importAttributes?.type === "text" && (NATIVE_IMPORT_TEXT || core.isFileUrl(url))) {
704
- return NATIVE_IMPORT_TEXT ? nextLoad(url, context) : core.loadTextImport(url);
761
+ if (context?.importAttributes?.type === "text" && (nativeImportText() || core.isFileUrl(url))) {
762
+ return nativeImportText() ? nextLoad(url, context) : core.loadTextImport(url);
705
763
  }
706
764
 
707
765
  // A USER resolve hook (a ts-node/tsx-style transpiler registered AFTER nub's
@@ -722,7 +780,22 @@ function makeHooks(core, watchReporting) {
722
780
  // user's outer hook, which does the real ESM->CJS conversion, matching Node.
723
781
  // Native 'module-typescript'/'commonjs-typescript' formats still fall through to
724
782
  // nub's transpile below, so normal augmentation is unchanged.
725
- if (__userHooksRegistered && context && context.format === "typescript") {
783
+ //
784
+ // tsx 4.2x writes the bare `commonjs`/`module` form instead of 'typescript'
785
+ // (`outerHookOwnsFormat`), and the hook that wrote it must also be able to
786
+ // transform what it gets back: a user registration WITH a load hook
787
+ // (`__userLoadHookRegistered`), or a loader registered through
788
+ // `module.register` (`userAsyncLoaderActive`), which the registerHooks counter
789
+ // cannot see and which nub has no way to inspect. A resolve-only user hook
790
+ // that labels a `.ts` file keeps nub as its transformer. The 'typescript'
791
+ // branch keeps its own narrower gate: a non-transpiling async loader (a
792
+ // telemetry `--import`) must not turn a bare 'typescript' from Node's own
793
+ // CJS loader into a step-aside.
794
+ if (context && (
795
+ (__userHooksRegistered && context.format === "typescript") ||
796
+ ((__userLoadHookRegistered || userAsyncLoaderActive(foreignLoaderFlagPresent)) &&
797
+ core.outerHookOwnsFormat(context.format, ext) && !core.isDependency(url))
798
+ )) {
726
799
  return nextLoad(url, context);
727
800
  }
728
801
 
@@ -845,7 +918,7 @@ function makeHooks(core, watchReporting) {
845
918
  typeof url === "string" && url.startsWith("file:") &&
846
919
  Array.isArray(context && context.conditions) &&
847
920
  context.conditions.includes("import") &&
848
- !__userHooksRegistered && !userAsyncLoaderActive()
921
+ !__userHooksRegistered && !userAsyncLoaderActive(foreignLoaderFlagPresent)
849
922
  ) {
850
923
  return { ...r, format: "commonjs-sync" };
851
924
  }
@@ -976,10 +1049,36 @@ function installCjsRequireHooks(core, withClassicTranspile) {
976
1049
  // missing. An explicit `require("dep/x/sub.cjs")` is unaffected either way — an
977
1050
  // exact path is found by stat, without consulting the extension list at all.
978
1051
  const NUB_ADDED_EXTS = [".ts", ".cts", ".mts", ".tsx", ".jsx", ".cjs"];
1052
+ // A `require.extensions` handler that is ALREADY registered for one of these
1053
+ // when this runs belongs to a transpiler the user chose, and it stays in charge
1054
+ // of that extension: nub's classic shim neither replaces it nor pre-judges its
1055
+ // files as ES modules at resolve time. Every `--require` runs before any `--import`, so on
1056
+ // the compat tier — where nub's own preload is an `--import` — tsx's
1057
+ // `--require`d preflight installs its `.ts` handler FIRST; nub then overwrote
1058
+ // it, and a file tsx would have compiled to CJS (mixed `import` + `require`,
1059
+ // the shape its ESM hook hands to the CJS loader) died in nub's handler as
1060
+ // "Cannot require() this file — it is an ES module". Node itself registers only
1061
+ // `.js`/`.json`/`.node`, so nothing but a user transpiler holds one of these
1062
+ // keys here.
1063
+ const foreignExts = new Set(
1064
+ [...core.TRANSPILE_EXTS, ...core.allDataExts(), ...NUB_ADDED_EXTS].filter((ext) =>
1065
+ ext !== ".js" && ext !== ".json" && ext !== ".node" && Object.hasOwn(module_._extensions, ext)),
1066
+ );
1067
+ // Ownership of a key is decided per lookup, never frozen at start-up: user code
1068
+ // may install or replace a handler after nub's preload (`--import tsx` runs
1069
+ // after every `--require`), and from then on that key is the user's own
1070
+ // widening of LOAD_AS_FILE, exactly as under plain Node plus their handler —
1071
+ // `require("dep/sub")` finding a dependency's `sub.ts` through it is the answer
1072
+ // to preserve. A key is nub's only while BOTH hold: nub introduced it (it was
1073
+ // not in `foreignExts` — a user's `.cjs` handler that nub merely wraps still
1074
+ // counts as theirs) and it still holds a function nub registered below
1075
+ // (`nubHandlers`). Anything else is not nub's to strip, nor to pre-judge.
1076
+ const nubHandlers = new Set();
1077
+ const nubOwnsExt = (ext) => !foreignExts.has(ext) && nubHandlers.has(module_._extensions[ext]);
979
1078
  const withoutNubAddedExtensions = (fn) => {
980
1079
  const saved = [];
981
1080
  for (const ext of NUB_ADDED_EXTS) {
982
- if (Object.hasOwn(module_._extensions, ext)) {
1081
+ if (nubOwnsExt(ext)) {
983
1082
  saved.push([ext, module_._extensions[ext]]);
984
1083
  delete module_._extensions[ext];
985
1084
  }
@@ -992,7 +1091,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
992
1091
  };
993
1092
  const isDepAddedExtHit = (filename) =>
994
1093
  typeof filename === "string" &&
995
- NUB_ADDED_EXTS.includes(pathExtname(filename)) &&
1094
+ nubOwnsExt(pathExtname(filename)) &&
996
1095
  core.isDependency(pathToFileURL(filename).href);
997
1096
 
998
1097
  module_._resolveFilename = function (request, parent, isMain, options) {
@@ -1015,7 +1114,8 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1015
1114
  // and 4 from `require.resolve` up to 22.14, but 22.15 and 22.16 pass 4 for both
1016
1115
  // while still lacking native TS — and the translator crash is present on
1017
1116
  // exactly those versions. A clean error beats an opaque crash, so this stays.
1018
- if (withClassicTranspile && core.requireTargetIsEsm(resolved, pathExtname(resolved))) {
1117
+ if (withClassicTranspile && nubOwnsExt(pathExtname(resolved)) &&
1118
+ core.requireTargetIsEsm(resolved, pathExtname(resolved))) {
1019
1119
  throw requireEsmError(resolved);
1020
1120
  }
1021
1121
  return resolved;
@@ -1144,6 +1244,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1144
1244
  if (core.TRANSPILE_EXTS.has(ext)) return transpileExtension(mod, filename);
1145
1245
  return nativeJs.call(module_._extensions, mod, filename);
1146
1246
  };
1247
+ nubHandlers.add(nubExtension);
1147
1248
 
1148
1249
  // Registered for every extension either path may claim, so the dispatcher is
1149
1250
  // reached at all; `nubExtension` then decides. Node's own `.js`/`.json`/`.node`
@@ -1164,7 +1265,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1164
1265
  // resolution identical across tiers.
1165
1266
  const CODE_EXTS = new Set([".ts", ".cts", ".mts", ".tsx", ".jsx"]);
1166
1267
  for (const ext of new Set([...core.TRANSPILE_EXTS, ...core.allDataExts()])) {
1167
- if (ext === ".js" || ext === ".json" || ext === ".node") continue;
1268
+ if (ext === ".js" || ext === ".json" || ext === ".node" || foreignExts.has(ext)) continue;
1168
1269
  Object.defineProperty(module_._extensions, ext, {
1169
1270
  value: nubExtension,
1170
1271
  enumerable: CODE_EXTS.has(ext),
@@ -1192,7 +1293,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1192
1293
  // (`nativeJs` is captured above, before the TS handlers are registered.)
1193
1294
  for (const ext of [".js", ".cjs"]) {
1194
1295
  const origExtension = module_._extensions[ext] || nativeJs;
1195
- module_._extensions[ext] = (mod, filename) => {
1296
+ const plainJsExtension = (mod, filename) => {
1196
1297
  // (0) The project pointed this extension at a data loader (`{".js":"text"}`),
1197
1298
  // which the ESM path honors. These two extensions are skipped by the
1198
1299
  // registration loop above because THIS wrapper owns them and runs after it,
@@ -1215,6 +1316,8 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1215
1316
  }
1216
1317
  return origExtension.call(module_._extensions, mod, filename); // (3)
1217
1318
  };
1319
+ module_._extensions[ext] = plainJsExtension;
1320
+ nubHandlers.add(plainJsExtension);
1218
1321
  }
1219
1322
  }
1220
1323
 
@@ -1717,6 +1820,9 @@ module.exports = {
1717
1820
  preloadPolyfillPackages,
1718
1821
  installTemporalGlobal,
1719
1822
  installTemporalLazyGlobal,
1823
+ // The compiled preamble's lazy Temporal getter (compile-lazy-temporal.cjs)
1824
+ // installs the value on first access without reading the global back.
1825
+ installTemporalValue,
1720
1826
  restoreCompileCacheEnv,
1721
1827
  installCompiledChildProcess,
1722
1828
  reenableUserCompileCache,
@@ -207,6 +207,20 @@ export const TRANSPILE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".jsx"]);
207
207
  // `maybeTranspilePlainJs` gate); a no-op plain-JS file falls through to Node's
208
208
  // native loader untouched, byte-identical. node_modules is excluded at the gate.
209
209
  export const PLAIN_JS_EXTS = new Set([".js", ".mjs", ".cjs"]);
210
+ // A bare `commonjs`/`module` format on a file nub would transpile can only have
211
+ // been assigned by a hook layered ABOVE nub's. Node's resolver labels a `.ts` file
212
+ // `commonjs-typescript`/`module-typescript` (or `typescript` when the package has
213
+ // no `type`) and leaves `.jsx` unlabelled, and nub's own resolve returns no format;
214
+ // the bare form is what tsx's resolve hook writes (getFormatFromFileUrl), and its
215
+ // load hook then expects the RAW source back from `nextLoad` so it can run its own
216
+ // module-format transform — a mixed `import` + `require` file becomes CJS there,
217
+ // where nub's syntax detection would make it ESM and `require` undefined. Both
218
+ // tiers step aside on this signal, the fast tier only once the user registration
219
+ // is known to carry a load hook (preload-common.cjs). Plain JS is excluded: Node
220
+ // assigns those the bare form itself.
221
+ export function outerHookOwnsFormat(format, ext) {
222
+ return (format === "commonjs" || format === "module") && TRANSPILE_EXTS.has(ext) && !PLAIN_JS_EXTS.has(ext);
223
+ }
210
224
  // The data loaders nub SHIPS — a runtime feature, not a project setting, so they stay
211
225
  // in force inside node_modules too (see dataExtsFor).
212
226
  const BUILTIN_DATA_EXTS = { ".jsonc": "jsonc", ".json5": "json5", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", ".txt": "txt" };
@@ -785,16 +799,145 @@ export function maybeSweepCache() {
785
799
  .catch(() => {});
786
800
  }
787
801
 
802
+ // ── Runtime V8 flags (`Mitigation::RuntimeV8Flag`) ──────────────────
803
+ // A V8 syntax flag nub does NOT put on argv. The Rust spawn layer names it in
804
+ // `__NUB_RUNTIME_V8_FLAGS` (`<node-version> <flag>…`, flags.rs RUNTIME_V8_FLAGS_ENV)
805
+ // and both load hooks route every result through `noteRuntimeV8FlagSource`, which
806
+ // turns the flag on with `v8.setFlagsFromString` the first time a source uses the
807
+ // syntax. V8 consults such a flag per parse (`v8_flags.js_defer_import_eval` is read
808
+ // in the parser alone, with an empty bootstrapper hook) and Node runs V8 with
809
+ // `--no-freeze-flags-after-init`, so a flip made before the hook returns is in force
810
+ // when Node compiles that module — verified on 26.4.0 and 26.7.0, identical deferral
811
+ // to the argv flag.
812
+ //
813
+ // What it buys: a V8 flag that is non-default at STARTUP enters V8's flag hash, and
814
+ // the code cache Node embeds for its own internals is keyed on that hash, so every
815
+ // builtin compiled after startup (`node:http`, `crypto`, `zlib`, …) is rejected and
816
+ // rebuilt from source — ~20 ms for a program loading those, measured on 26.7.0. The
817
+ // flip charges that only to a program that actually uses the syntax, and only for
818
+ // the internals loaded after it. The flag also never appears in `process.execArgv`,
819
+ // where forwarding it into a Worker once killed a Next.js 16 + Turbopack build.
820
+ //
821
+ // Every Nub launch sets or removes the var, so a child that re-enters Nub carries its
822
+ // own decision; it is NOT deleted here, so a process that makes no such decision — a
823
+ // Worker, the `module.register` loader worker, a child spawned by absolute path —
824
+ // starts from a copy of this env and gets the feature too. Two guards close the gaps
825
+ // inheritance leaves: a polarity already on this process's own `process.execArgv`
826
+ // wins, either sign (V8 has the flag, or the user negated it, and the parent's signal
827
+ // must not override that); and the version stamp makes a descendant on a different
828
+ // Node (an inherited-NODE_OPTIONS grandchild) ignore a set computed for another
829
+ // binary, because a flag V8 does not know is an "Error: unrecognized flag" on stderr.
830
+ // `v8_flags` is process-global, so one flip from any thread serves every isolate.
831
+ // Node documents a post-init flag change as unsupported; for a flag the parser reads
832
+ // as one bool at the `import` token, the exposure is a benign race with a worker
833
+ // parsing concurrently, which sees the old value for that one parse.
834
+ //
835
+ // Detection is textual. The static form is always `import defer * as` — V8 allows
836
+ // `defer` with a namespace import only — so the pattern requires the `*`, which
837
+ // keeps prose that merely names the two words (a comment, a docs string) from
838
+ // arming the flag. Whitespace or comments between the tokens still match. A false
839
+ // positive that survives (the three tokens inside a string) only recreates the
840
+ // state every program had when the flag rode argv. The dynamic form
841
+ // `import.defer()` is NOT matched, on purpose: it aborts the process on every 26.x
842
+ // measured (a V8 fatal in Node's phase wiring), so a program that uses only that
843
+ // form keeps bare Node's catchable SyntaxError.
844
+ const TOKEN_GAP = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*\n)`;
845
+ const IMPORT_DEFER_RE = new RegExp(String.raw`\bimport${TOKEN_GAP}+defer${TOKEN_GAP}*\*`);
846
+ const RUNTIME_V8_FLAG_DETECTORS = {
847
+ "--js-defer-import-eval": sourceUsesImportDefer,
848
+ };
849
+ // Formats whose source Node compiles as an ES module. `import defer` is module-only
850
+ // syntax, so a CommonJS, JSON or wasm result can never need the flip; a null format
851
+ // is still undecided and is scanned.
852
+ const ESM_FORMATS = new Set(["module", "module-typescript", "typescript"]);
853
+
854
+ // The source as a string, or null when a byte-level scan for `needle` misses — so a
855
+ // file that cannot match is never decoded.
856
+ function sourceText(source, needle) {
857
+ if (typeof source === "string") return source.includes(needle) ? source : null;
858
+ let buf = null;
859
+ if (ArrayBuffer.isView(source)) {
860
+ buf = Buffer.from(source.buffer, source.byteOffset, source.byteLength);
861
+ } else if (source instanceof ArrayBuffer) {
862
+ buf = Buffer.from(source);
863
+ }
864
+ return buf !== null && buf.includes(needle) ? buf.toString("utf8") : null;
865
+ }
866
+
867
+ // Whether `source` (string, Buffer, TypedArray or ArrayBuffer) carries a static
868
+ // `import defer` declaration.
869
+ export function sourceUsesImportDefer(source) {
870
+ const text = sourceText(source, "defer");
871
+ return text !== null && IMPORT_DEFER_RE.test(text);
872
+ }
873
+
874
+ // `-e`/`-p` code never passes through a load hook, so it is scanned once at
875
+ // arm time: the preload runs before Node compiles the eval string.
876
+ function evalSourceFromExecArgv(execArgv) {
877
+ if (!Array.isArray(execArgv)) return null;
878
+ for (let i = 0; i < execArgv.length; i++) {
879
+ const arg = execArgv[i];
880
+ if (typeof arg !== "string") continue;
881
+ if (arg === "-e" || arg === "--eval" || arg === "-p" || arg === "--print" || arg === "-pe" || arg === "-ep") {
882
+ return i + 1 < execArgv.length && typeof execArgv[i + 1] === "string" ? execArgv[i + 1] : null;
883
+ }
884
+ if (arg.startsWith("--eval=") || arg.startsWith("--print=")) return arg.slice(arg.indexOf("=") + 1);
885
+ }
886
+ return null;
887
+ }
888
+
889
+ // Flags still to turn on, or null once nothing is armed — the hot path is one null
890
+ // check per load.
891
+ let pendingRuntimeV8Flags = null;
892
+
893
+ function turnOnRuntimeV8Flag(flag) {
894
+ pendingRuntimeV8Flags.delete(flag);
895
+ if (pendingRuntimeV8Flags.size === 0) pendingRuntimeV8Flags = null;
896
+ try {
897
+ __getBuiltin("node:v8").setFlagsFromString(flag);
898
+ } catch {
899
+ // The module then fails exactly as it would on bare Node.
900
+ }
901
+ }
902
+
903
+ // Route a load result through the runtime-flag scan and hand it back unchanged.
904
+ export function noteRuntimeV8FlagSource(result) {
905
+ if (pendingRuntimeV8Flags === null || result == null || result.source == null) return result;
906
+ if (result.format != null && !ESM_FORMATS.has(result.format)) return result;
907
+ for (const flag of [...pendingRuntimeV8Flags]) {
908
+ if (RUNTIME_V8_FLAG_DETECTORS[flag](result.source)) turnOnRuntimeV8Flag(flag);
909
+ }
910
+ return result;
911
+ }
912
+
913
+ {
914
+ const raw = process.env.__NUB_RUNTIME_V8_FLAGS;
915
+ if (raw) {
916
+ const [stampedVersion, ...flags] = raw.split(" ").filter(Boolean);
917
+ const execArgv = Array.isArray(process.execArgv) ? process.execArgv : [];
918
+ const armed = stampedVersion === process.versions.node
919
+ ? flags.filter((flag) =>
920
+ Object.hasOwn(RUNTIME_V8_FLAG_DETECTORS, flag) &&
921
+ !execArgv.includes(flag) && !execArgv.includes(`--no-${flag.slice(2)}`))
922
+ : [];
923
+ if (armed.length > 0) {
924
+ pendingRuntimeV8Flags = new Set(armed);
925
+ const evalSource = evalSourceFromExecArgv(execArgv);
926
+ if (evalSource !== null) noteRuntimeV8FlagSource({ format: "module", source: evalSource });
927
+ }
928
+ }
929
+ }
930
+
788
931
  // ── Transpile ───────────────────────────────────────────────────────
789
932
  // Transpile a TS/JSX file to JS, returning `{ format, source, shortCircuit }` in
790
933
  // the shape both hook tiers hand back to Node. Format is detected (not derived
791
934
  // from extension alone), so a CommonJS-syntax `.ts` is reported `commonjs` — the
792
935
  // fix that makes `require()` of a TS file work on the compat tier, where Node's
793
936
  // CJS translator loads it via this hook and keys on the returned format.
794
- export function loadTranspile(url, ext) {
937
+ export function loadTranspile(url, ext, source) {
795
938
  __ensureBuiltins();
796
939
  const filePath = fileURLToPath(url);
797
- const source = readFileSync(filePath, "utf8");
940
+ source ??= readFileSync(filePath, "utf8");
798
941
  const dir = dirname(filePath);
799
942
  // The transform-relevant compilerOptions slice + the byte-for-byte cache-key
800
943
  // component (`tsconfigHash`) both come from the native tsconfig reader.
@@ -878,7 +1021,15 @@ export function loadTranspile(url, ext) {
878
1021
  const details = result.errors.map((e) => e.codeframe || e.message).join("\n\n");
879
1022
  throw new Error(`Transpile error in ${filePath}:\n${details}`);
880
1023
  }
881
- return { format: result.format, source: result.code, shortCircuit: true };
1024
+ // `responseURL` is what an OUTER user hook keys on. Node's default load sets it
1025
+ // to the file URL, and tsx's load hook takes its CommonJS branch only when the
1026
+ // result `nextLoad` hands back carries a `file:` responseURL — without it, tsx
1027
+ // re-transformed a `.ts` file nub had already emitted as CJS in its ESM branch,
1028
+ // and `require` was undefined at run time (`tsx script.ts` under `nub run`).
1029
+ // Node itself defaults a missing responseURL to the URL, so only a hook layered
1030
+ // above nub's can observe the difference; every file-URL result nub
1031
+ // short-circuits carries it for that reason.
1032
+ return { format: result.format, source: result.code, responseURL: url, shortCircuit: true };
882
1033
  }
883
1034
 
884
1035
  // Project-source plain JS (`.js`/`.mjs`/`.cjs`) gate. Returns a transpiled load
@@ -895,7 +1046,7 @@ export function loadTranspile(url, ext) {
895
1046
  // sites (the byte-parity boundary). JSX-in-`.js` is out of scope for the syntax
896
1047
  // gate (lang is "ts", which does not parse JSX); use `.jsx`, or say so explicitly
897
1048
  // with a `loader` entry, which takes the unconditional path below instead.
898
- export function maybeTranspilePlainJs(url, ext) {
1049
+ export function maybeTranspilePlainJs(url, ext, source) {
899
1050
  __ensureBuiltins();
900
1051
  // An explicit `loader` entry pointing this extension at a code dialect moved it
901
1052
  // into TRANSPILE_EXTS, which for every other member means "always compile". Only
@@ -906,11 +1057,10 @@ export function maybeTranspilePlainJs(url, ext) {
906
1057
  // while the ESM path transpiles the same file on both tiers. The registration
907
1058
  // loop deliberately skips `.js`/`.cjs` because this wrapper owns them, so there
908
1059
  // is nothing else downstream to catch it.
909
- if (TRANSPILE_EXTS.has(ext)) return loadTranspile(url, ext);
1060
+ if (TRANSPILE_EXTS.has(ext)) return loadTranspile(url, ext, source);
910
1061
  const filePath = fileURLToPath(url);
911
- let source;
912
1062
  try {
913
- source = readFileSync(filePath, "utf8");
1063
+ source ??= readFileSync(filePath, "utf8");
914
1064
  } catch {
915
1065
  // Unreadable here → let Node's loader surface its own error.
916
1066
  return null;
@@ -921,10 +1071,10 @@ export function maybeTranspilePlainJs(url, ext) {
921
1071
  return null; // no-op: Node's native loader handles it, byte-identical.
922
1072
  }
923
1073
  // Transformable: run the SAME pipeline as TS/JSX (target es2022 lowering, tsconfig,
924
- // source maps, the Stage-3 decorator guard, format detection, cache). loadTranspile
925
- // re-reads + re-parses, but only for the rare file that actually needs lowering.
1074
+ // source maps, the Stage-3 decorator guard, format detection, cache), reusing
1075
+ // the bytes already inspected by the gate.
926
1076
  try {
927
- return loadTranspile(url, ext);
1077
+ return loadTranspile(url, ext, source);
928
1078
  } catch (err) {
929
1079
  // #225: a plain-JS file the transformable verdict flagged (a `using` decl or
930
1080
  // `v`-flag RegExp somewhere) but whose transform oxc then REJECTS — V8 tolerates
@@ -998,7 +1148,7 @@ export function loadData(url, ext) {
998
1148
  const parsed = dataValue(url, ext);
999
1149
 
1000
1150
  if (parsed == null) {
1001
- return { format: "module", source: "export default undefined;\n", shortCircuit: true };
1151
+ return { format: "module", source: "export default undefined;\n", responseURL: url, shortCircuit: true };
1002
1152
  }
1003
1153
 
1004
1154
  // Default export only. Data modules deliberately do NOT emit per-key named
@@ -1008,7 +1158,7 @@ export function loadData(url, ext) {
1008
1158
  // default — `import cfg from "./c.yaml"; const { host } = cfg;` — which the
1009
1159
  // `@nubjs/types` `Record<string, unknown>` default type makes sound.
1010
1160
  const code = `export default ${JSON.stringify(parsed)};\n`;
1011
- return { format: "module", source: code, shortCircuit: true };
1161
+ return { format: "module", source: code, responseURL: url, shortCircuit: true };
1012
1162
  }
1013
1163
 
1014
1164
  // Import Text: `import s from "./any.file" with { type: "text" }` → the raw file
@@ -1024,5 +1174,5 @@ export function loadData(url, ext) {
1024
1174
  const __textDecoder = new TextDecoder();
1025
1175
  export function loadTextImport(url) {
1026
1176
  const text = __textDecoder.decode(readFileSync(fileURLToPath(url)));
1027
- return { format: "module", source: `export default ${JSON.stringify(text)};\n`, shortCircuit: true };
1177
+ return { format: "module", source: `export default ${JSON.stringify(text)};\n`, responseURL: url, shortCircuit: true };
1028
1178
  }