@nubjs/loader 0.8.3 → 0.9.0

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",
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",
53
+ "@nubjs/loader-darwin-x64": "0.9.0",
54
+ "@nubjs/loader-linux-x64": "0.9.0",
55
+ "@nubjs/loader-linux-x64-musl": "0.9.0",
56
+ "@nubjs/loader-linux-arm64": "0.9.0",
57
+ "@nubjs/loader-linux-arm64-musl": "0.9.0",
58
+ "@nubjs/loader-win32-x64": "0.9.0",
59
+ "@nubjs/loader-win32-arm64": "0.9.0"
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,
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
@@ -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
@@ -252,6 +293,7 @@ function cliAsyncLoaderPresent() {
252
293
  if (
253
294
  a === "--loader" || a.startsWith("--loader=") ||
254
295
  a === "--experimental-loader" || a.startsWith("--experimental-loader=") ||
296
+ a === "--experimental_loader" || a.startsWith("--experimental_loader=") ||
255
297
  a === "--import" || a.startsWith("--import=")
256
298
  ) { present = true; break; }
257
299
  }
@@ -275,9 +317,10 @@ function cliAsyncLoaderPresent() {
275
317
  // against a user async loader, and Node rejected the `commonjs-sync`+null-source pair
276
318
  // (#669). That helper also excludes nub's OWN preload chainer, which rides NODE_OPTIONS
277
319
  // 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();
320
+ // the relabel for every chained project. The standalone loader supplies its
321
+ // value-aware flag scan instead, excluding its own --import entrypoint too.
322
+ function userAsyncLoaderActive(foreignLoaderFlagPresent) {
323
+ return __userAsyncLoaderRegistered || foreignLoaderFlagPresent;
281
324
  }
282
325
 
283
326
  // The Node band where the async `module.register` loader's `resolveSync`/`loadSync`
@@ -328,7 +371,7 @@ function computeForeignAsyncLoaderFlagPresent() {
328
371
  if (cliAsyncLoaderPresent()) return true; // execArgv channel
329
372
  const opts = process.env.NODE_OPTIONS;
330
373
  if (typeof opts !== "string" || opts === "") return false;
331
- const re = /(?:^|\s)--(?:experimental-)?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
374
+ const re = /(?:^|\s)--(?:experimental[-_])?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
332
375
  for (const match of opts.matchAll(re)) {
333
376
  const value = (match[1] || "").replace(/^"|"$/g, "");
334
377
  if (!NUB_CHAIN_MARKER.test(value)) return true;
@@ -593,7 +636,7 @@ function restoreSchemeOnlyBuiltinURL(result) {
593
636
  }
594
637
  }
595
638
 
596
- function makeHooks(core, watchReporting) {
639
+ function makeHooks(core, watchReporting, foreignLoaderFlagPresent = foreignAsyncLoaderFlagPresent()) {
597
640
  installUserHookDetector();
598
641
  installUserAsyncLoaderDetector();
599
642
 
@@ -662,7 +705,15 @@ function makeHooks(core, watchReporting) {
662
705
  }
663
706
  }
664
707
 
708
+ // Every load result passes through the runtime-V8-flag scan BEFORE Node compiles
709
+ // it, whichever branch of loadInner produced it — see transform-core
710
+ // `noteRuntimeV8FlagSource`. A no-op (one null check) unless the spawn layer armed
711
+ // a flag for this Node.
665
712
  function load(url, context, nextLoad) {
713
+ return core.noteRuntimeV8FlagSource(loadInner(url, context, nextLoad));
714
+ }
715
+
716
+ function loadInner(url, context, nextLoad) {
666
717
  const ext = core.extname(url);
667
718
 
668
719
  // Watch mode: surface this file's nearest config files (tsconfig.json,
@@ -679,7 +730,7 @@ function makeHooks(core, watchReporting) {
679
730
 
680
731
  // Import Text (attribute-keyed): honor `with { type: "text" }` on ANY extension,
681
732
  // 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
733
+ // returns raw text, not parsed YAML. Where Node knows the flag (`nativeImportText()`
683
734
  // — 24.19+ on the 24.x line, 26.5+ on 26.x) step aside and let Node's own
684
735
  // textStrategy own it — nub injects --experimental-import-text there, so the
685
736
  // additive "would plain Node + the flag do the same?" test holds and users get
@@ -700,8 +751,8 @@ function makeHooks(core, watchReporting) {
700
751
  // the unknown-data-URL-format trap below instead of Node's own text answer.
701
752
  // A non-`file:` URL on the polyfill tier falls through to `nextLoad` with
702
753
  // 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);
754
+ if (context?.importAttributes?.type === "text" && (nativeImportText() || core.isFileUrl(url))) {
755
+ return nativeImportText() ? nextLoad(url, context) : core.loadTextImport(url);
705
756
  }
706
757
 
707
758
  // A USER resolve hook (a ts-node/tsx-style transpiler registered AFTER nub's
@@ -845,7 +896,7 @@ function makeHooks(core, watchReporting) {
845
896
  typeof url === "string" && url.startsWith("file:") &&
846
897
  Array.isArray(context && context.conditions) &&
847
898
  context.conditions.includes("import") &&
848
- !__userHooksRegistered && !userAsyncLoaderActive()
899
+ !__userHooksRegistered && !userAsyncLoaderActive(foreignLoaderFlagPresent)
849
900
  ) {
850
901
  return { ...r, format: "commonjs-sync" };
851
902
  }
@@ -1717,6 +1768,9 @@ module.exports = {
1717
1768
  preloadPolyfillPackages,
1718
1769
  installTemporalGlobal,
1719
1770
  installTemporalLazyGlobal,
1771
+ // The compiled preamble's lazy Temporal getter (compile-lazy-temporal.cjs)
1772
+ // installs the value on first access without reading the global back.
1773
+ installTemporalValue,
1720
1774
  restoreCompileCacheEnv,
1721
1775
  installCompiledChildProcess,
1722
1776
  reenableUserCompileCache,
@@ -785,16 +785,145 @@ export function maybeSweepCache() {
785
785
  .catch(() => {});
786
786
  }
787
787
 
788
+ // ── Runtime V8 flags (`Mitigation::RuntimeV8Flag`) ──────────────────
789
+ // A V8 syntax flag nub does NOT put on argv. The Rust spawn layer names it in
790
+ // `__NUB_RUNTIME_V8_FLAGS` (`<node-version> <flag>…`, flags.rs RUNTIME_V8_FLAGS_ENV)
791
+ // and both load hooks route every result through `noteRuntimeV8FlagSource`, which
792
+ // turns the flag on with `v8.setFlagsFromString` the first time a source uses the
793
+ // syntax. V8 consults such a flag per parse (`v8_flags.js_defer_import_eval` is read
794
+ // in the parser alone, with an empty bootstrapper hook) and Node runs V8 with
795
+ // `--no-freeze-flags-after-init`, so a flip made before the hook returns is in force
796
+ // when Node compiles that module — verified on 26.4.0 and 26.7.0, identical deferral
797
+ // to the argv flag.
798
+ //
799
+ // What it buys: a V8 flag that is non-default at STARTUP enters V8's flag hash, and
800
+ // the code cache Node embeds for its own internals is keyed on that hash, so every
801
+ // builtin compiled after startup (`node:http`, `crypto`, `zlib`, …) is rejected and
802
+ // rebuilt from source — ~20 ms for a program loading those, measured on 26.7.0. The
803
+ // flip charges that only to a program that actually uses the syntax, and only for
804
+ // the internals loaded after it. The flag also never appears in `process.execArgv`,
805
+ // where forwarding it into a Worker once killed a Next.js 16 + Turbopack build.
806
+ //
807
+ // Every Nub launch sets or removes the var, so a child that re-enters Nub carries its
808
+ // own decision; it is NOT deleted here, so a process that makes no such decision — a
809
+ // Worker, the `module.register` loader worker, a child spawned by absolute path —
810
+ // starts from a copy of this env and gets the feature too. Two guards close the gaps
811
+ // inheritance leaves: a polarity already on this process's own `process.execArgv`
812
+ // wins, either sign (V8 has the flag, or the user negated it, and the parent's signal
813
+ // must not override that); and the version stamp makes a descendant on a different
814
+ // Node (an inherited-NODE_OPTIONS grandchild) ignore a set computed for another
815
+ // binary, because a flag V8 does not know is an "Error: unrecognized flag" on stderr.
816
+ // `v8_flags` is process-global, so one flip from any thread serves every isolate.
817
+ // Node documents a post-init flag change as unsupported; for a flag the parser reads
818
+ // as one bool at the `import` token, the exposure is a benign race with a worker
819
+ // parsing concurrently, which sees the old value for that one parse.
820
+ //
821
+ // Detection is textual. The static form is always `import defer * as` — V8 allows
822
+ // `defer` with a namespace import only — so the pattern requires the `*`, which
823
+ // keeps prose that merely names the two words (a comment, a docs string) from
824
+ // arming the flag. Whitespace or comments between the tokens still match. A false
825
+ // positive that survives (the three tokens inside a string) only recreates the
826
+ // state every program had when the flag rode argv. The dynamic form
827
+ // `import.defer()` is NOT matched, on purpose: it aborts the process on every 26.x
828
+ // measured (a V8 fatal in Node's phase wiring), so a program that uses only that
829
+ // form keeps bare Node's catchable SyntaxError.
830
+ const TOKEN_GAP = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*\n)`;
831
+ const IMPORT_DEFER_RE = new RegExp(String.raw`\bimport${TOKEN_GAP}+defer${TOKEN_GAP}*\*`);
832
+ const RUNTIME_V8_FLAG_DETECTORS = {
833
+ "--js-defer-import-eval": sourceUsesImportDefer,
834
+ };
835
+ // Formats whose source Node compiles as an ES module. `import defer` is module-only
836
+ // syntax, so a CommonJS, JSON or wasm result can never need the flip; a null format
837
+ // is still undecided and is scanned.
838
+ const ESM_FORMATS = new Set(["module", "module-typescript", "typescript"]);
839
+
840
+ // The source as a string, or null when a byte-level scan for `needle` misses — so a
841
+ // file that cannot match is never decoded.
842
+ function sourceText(source, needle) {
843
+ if (typeof source === "string") return source.includes(needle) ? source : null;
844
+ let buf = null;
845
+ if (ArrayBuffer.isView(source)) {
846
+ buf = Buffer.from(source.buffer, source.byteOffset, source.byteLength);
847
+ } else if (source instanceof ArrayBuffer) {
848
+ buf = Buffer.from(source);
849
+ }
850
+ return buf !== null && buf.includes(needle) ? buf.toString("utf8") : null;
851
+ }
852
+
853
+ // Whether `source` (string, Buffer, TypedArray or ArrayBuffer) carries a static
854
+ // `import defer` declaration.
855
+ export function sourceUsesImportDefer(source) {
856
+ const text = sourceText(source, "defer");
857
+ return text !== null && IMPORT_DEFER_RE.test(text);
858
+ }
859
+
860
+ // `-e`/`-p` code never passes through a load hook, so it is scanned once at
861
+ // arm time: the preload runs before Node compiles the eval string.
862
+ function evalSourceFromExecArgv(execArgv) {
863
+ if (!Array.isArray(execArgv)) return null;
864
+ for (let i = 0; i < execArgv.length; i++) {
865
+ const arg = execArgv[i];
866
+ if (typeof arg !== "string") continue;
867
+ if (arg === "-e" || arg === "--eval" || arg === "-p" || arg === "--print" || arg === "-pe" || arg === "-ep") {
868
+ return i + 1 < execArgv.length && typeof execArgv[i + 1] === "string" ? execArgv[i + 1] : null;
869
+ }
870
+ if (arg.startsWith("--eval=") || arg.startsWith("--print=")) return arg.slice(arg.indexOf("=") + 1);
871
+ }
872
+ return null;
873
+ }
874
+
875
+ // Flags still to turn on, or null once nothing is armed — the hot path is one null
876
+ // check per load.
877
+ let pendingRuntimeV8Flags = null;
878
+
879
+ function turnOnRuntimeV8Flag(flag) {
880
+ pendingRuntimeV8Flags.delete(flag);
881
+ if (pendingRuntimeV8Flags.size === 0) pendingRuntimeV8Flags = null;
882
+ try {
883
+ __getBuiltin("node:v8").setFlagsFromString(flag);
884
+ } catch {
885
+ // The module then fails exactly as it would on bare Node.
886
+ }
887
+ }
888
+
889
+ // Route a load result through the runtime-flag scan and hand it back unchanged.
890
+ export function noteRuntimeV8FlagSource(result) {
891
+ if (pendingRuntimeV8Flags === null || result == null || result.source == null) return result;
892
+ if (result.format != null && !ESM_FORMATS.has(result.format)) return result;
893
+ for (const flag of [...pendingRuntimeV8Flags]) {
894
+ if (RUNTIME_V8_FLAG_DETECTORS[flag](result.source)) turnOnRuntimeV8Flag(flag);
895
+ }
896
+ return result;
897
+ }
898
+
899
+ {
900
+ const raw = process.env.__NUB_RUNTIME_V8_FLAGS;
901
+ if (raw) {
902
+ const [stampedVersion, ...flags] = raw.split(" ").filter(Boolean);
903
+ const execArgv = Array.isArray(process.execArgv) ? process.execArgv : [];
904
+ const armed = stampedVersion === process.versions.node
905
+ ? flags.filter((flag) =>
906
+ Object.hasOwn(RUNTIME_V8_FLAG_DETECTORS, flag) &&
907
+ !execArgv.includes(flag) && !execArgv.includes(`--no-${flag.slice(2)}`))
908
+ : [];
909
+ if (armed.length > 0) {
910
+ pendingRuntimeV8Flags = new Set(armed);
911
+ const evalSource = evalSourceFromExecArgv(execArgv);
912
+ if (evalSource !== null) noteRuntimeV8FlagSource({ format: "module", source: evalSource });
913
+ }
914
+ }
915
+ }
916
+
788
917
  // ── Transpile ───────────────────────────────────────────────────────
789
918
  // Transpile a TS/JSX file to JS, returning `{ format, source, shortCircuit }` in
790
919
  // the shape both hook tiers hand back to Node. Format is detected (not derived
791
920
  // from extension alone), so a CommonJS-syntax `.ts` is reported `commonjs` — the
792
921
  // fix that makes `require()` of a TS file work on the compat tier, where Node's
793
922
  // CJS translator loads it via this hook and keys on the returned format.
794
- export function loadTranspile(url, ext) {
923
+ export function loadTranspile(url, ext, source) {
795
924
  __ensureBuiltins();
796
925
  const filePath = fileURLToPath(url);
797
- const source = readFileSync(filePath, "utf8");
926
+ source ??= readFileSync(filePath, "utf8");
798
927
  const dir = dirname(filePath);
799
928
  // The transform-relevant compilerOptions slice + the byte-for-byte cache-key
800
929
  // component (`tsconfigHash`) both come from the native tsconfig reader.
@@ -895,7 +1024,7 @@ export function loadTranspile(url, ext) {
895
1024
  // sites (the byte-parity boundary). JSX-in-`.js` is out of scope for the syntax
896
1025
  // gate (lang is "ts", which does not parse JSX); use `.jsx`, or say so explicitly
897
1026
  // with a `loader` entry, which takes the unconditional path below instead.
898
- export function maybeTranspilePlainJs(url, ext) {
1027
+ export function maybeTranspilePlainJs(url, ext, source) {
899
1028
  __ensureBuiltins();
900
1029
  // An explicit `loader` entry pointing this extension at a code dialect moved it
901
1030
  // into TRANSPILE_EXTS, which for every other member means "always compile". Only
@@ -906,11 +1035,10 @@ export function maybeTranspilePlainJs(url, ext) {
906
1035
  // while the ESM path transpiles the same file on both tiers. The registration
907
1036
  // loop deliberately skips `.js`/`.cjs` because this wrapper owns them, so there
908
1037
  // is nothing else downstream to catch it.
909
- if (TRANSPILE_EXTS.has(ext)) return loadTranspile(url, ext);
1038
+ if (TRANSPILE_EXTS.has(ext)) return loadTranspile(url, ext, source);
910
1039
  const filePath = fileURLToPath(url);
911
- let source;
912
1040
  try {
913
- source = readFileSync(filePath, "utf8");
1041
+ source ??= readFileSync(filePath, "utf8");
914
1042
  } catch {
915
1043
  // Unreadable here → let Node's loader surface its own error.
916
1044
  return null;
@@ -921,10 +1049,10 @@ export function maybeTranspilePlainJs(url, ext) {
921
1049
  return null; // no-op: Node's native loader handles it, byte-identical.
922
1050
  }
923
1051
  // 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.
1052
+ // source maps, the Stage-3 decorator guard, format detection, cache), reusing
1053
+ // the bytes already inspected by the gate.
926
1054
  try {
927
- return loadTranspile(url, ext);
1055
+ return loadTranspile(url, ext, source);
928
1056
  } catch (err) {
929
1057
  // #225: a plain-JS file the transformable verdict flagged (a `using` decl or
930
1058
  // `v`-flag RegExp somewhere) but whose transform oxc then REJECTS — V8 tolerates