@nubjs/loader 0.0.0 → 0.8.2

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.
@@ -0,0 +1,1028 @@
1
+ // Nub transform core — the single source of truth shared by both hook tiers.
2
+ //
3
+ // runtime/preload.mjs (fast path, Node 22.15+, sync `module.registerHooks`) and
4
+ // the compat-tier loader worker (Node 18.19–22.14, async `module.register` →
5
+ // runtime/preload-async-hooks.mjs) both import every resolution + transpile
6
+ // primitive from here. The tier files own only the parts that genuinely differ:
7
+ // hook registration (sync vs async signatures), polyfill preloading, the
8
+ // Temporal lazy global, watch-mode IPC, and the compat-tier CJS `require()`
9
+ // shim. EVERYTHING about how a file is resolved and transpiled — extension
10
+ // probing, the `.js`→`.ts` swap, tsconfig `paths`, module-format detection,
11
+ // transform options (including `target: 'es2022'` `using`-lowering), the
12
+ // Stage-3 decorator guard, the on-disk cache, data-format imports, package
13
+ // clobbering — lives here, so the two tiers can never drift. (They used to:
14
+ // separate copies diverged on probe order, `target` lowering, the decorator
15
+ // guard, module-format detection, the Temporal clobber's named exports, and the
16
+ // reserved-export filter — every one a real compat bug. This module is the fix.)
17
+ //
18
+ // Side effects are confined to: loading the N-API addon (data parsers + the
19
+ // in-process TS/JSX transpiler), and reading/writing the transpile cache. There is
20
+ // no top-level hook registration here — importing this module never augments the
21
+ // realm; the tier files do that.
22
+
23
+ // EVERY node: builtin this module needs is pulled in via CJS `require()` / `process
24
+ // .getBuiltinModule` (below), NOT via static ESM `import`. This is load-bearing for
25
+ // loader compatibility (R11): nub loads transform-core through `require(esm)`, and
26
+ // Node's `require(esm)` instantiates the module by walking its STATIC IMPORT graph
27
+ // through whatever ESM loader hooks are registered — including the USER's
28
+ // `--loader`/`register()` chain. Static `import get-tsconfig`/`./version.mjs`/`node:*`
29
+ // here therefore once leaked nub's entire internal graph (transform-core,
30
+ // version.mjs, get-tsconfig, their transitive node_modules deps, and the node:
31
+ // builtins) THROUGH the user's resolve/load hooks, which observed and corrupted it
32
+ // (a user load hook returning `source: 1` for version.mjs, a strict loader throwing
33
+ // on a bare specifier — see test-esm-loader-chaining, -example-loader,
34
+ // -preserve-symlinks-not-found, test-shadow-realm-custom-loaders). Verified: a CJS
35
+ // `require()` of a builtin does NOT route through the ESM loader chain, so loading
36
+ // off it bypasses the user chain entirely. As of this migration the point is
37
+ // stronger: transform-core `require()`s ZERO npm packages — the transpiler, TS/JSX
38
+ // detection, tsconfig discovery/parse, the additive TS-resolver, AND the transpile
39
+ // cache are ALL native calls into nub's own N-API addon (loaded by absolute `.node`
40
+ // path, off the loader chain), and the version.mjs text read is gone (the cache
41
+ // version is baked into the addon). So the worst historical leaks — oxc-transform's
42
+ // and then get-tsconfig's graphs pulled through the user chain — are gone by
43
+ // construction; only node: builtins remain, fetched off the chain. `process
44
+ // .getBuiltinModule` fetches node: builtins synchronously off the loader chain;
45
+ // `createRequire(import.meta.url)` resolves the (now CommonJS-only) vendored
46
+ // polyfills + the `@oxc-project/runtime` helpers from nub's distribution.
47
+ // This file keeps its `export`s (it stays an ES module) but has ZERO static
48
+ // imports — INCLUDING zero static `import` of any `node:` builtin — so `require(esm)`
49
+ // of transform-core finds no dependency graph to route through the user loader.
50
+ // This is load-bearing, not cosmetic: transform-core previously carried a static
51
+ // `import { createRequire } from "node:module"`. That import sat in transform-core's
52
+ // static graph, so when nub's fast-tier preload.cjs does `require("./transform-core
53
+ // .mjs")` (a `require(esm)`), Node instantiated transform-core by walking its static
54
+ // import graph THROUGH the user's pre-registered `--experimental-loader` /
55
+ // `module.register` chain — and a user resolve hook that rejects or rewrites
56
+ // `node:module` (e.g. the example-loader that throws on any non-`./`/`../`/URL
57
+ // specifier) then exploded nub's own load, while resolve-count loaders saw a phantom
58
+ // `node:module` hit. (Observed against es-module/test-esm-example-loader,
59
+ // -loader-chaining, -initialization, -preserve-symlinks-not-found, and
60
+ // parallel/test-shadow-realm-custom-loaders.) The earlier comment here claimed the
61
+ // `node:module` import was "never routed through a user loader hook" — that was
62
+ // FALSE for the fast-tier `require(esm)` path, and is the bug this rewrite fixes.
63
+ //
64
+ // `process.getBuiltinModule` (Node 22.3 / backported to 20.16 / 18.20.4) fetches a
65
+ // node: builtin synchronously OFF the loader chain, with no static import — so on
66
+ // the fast tier (22.15+, the only tier that loads transform-core via `require(esm)`,
67
+ // and where getBuiltinModule ALWAYS exists) there is nothing in the graph for a user
68
+ // loader to observe. On the narrow FLOOR below 22.3/20.16/18.20.4 (18.19.x,
69
+ // 20.11–20.15, 22.0–22.2) it's `undefined`; there, transform-core is loaded ONLY via
70
+ // static ESM `import` from the compat-tier entries (preload.mjs main thread /
71
+ // preload-async-hooks.mjs loader worker), both OFF any user loader chain — so the
72
+ // floor's `node:module` access cannot leak.
73
+ //
74
+ // BRAND BOUNDARY — the floor's `createRequire` is THREADED IN THROUGH MODULE SCOPE,
75
+ // not parked on `globalThis`. floor-builtin.mjs holds the lone static `import {
76
+ // createRequire } from "node:module"` and pushes the value in here via the
77
+ // `setBootstrapCreateRequire` setter below; nothing is ever written to the user's
78
+ // global object (a `globalThis.__nub*` sentinel is the same brand leak as a NUB_*
79
+ // env var — enumerable in user code AND worker realms — so it is forbidden). The
80
+ // floor's `node:module` import lives only in floor-builtin.mjs, which the fast tier
81
+ // never loads, so it never enters the fast-tier `require(esm)` graph.
82
+ //
83
+ // On the floor the threaded value isn't available at this module's top-level eval:
84
+ // the compat entry imports floor-builtin AHEAD of transform-core, but ES modules
85
+ // evaluate the importEE before the importer's body, so floor-builtin's setter call
86
+ // (made during ITS evaluation) lands AFTER transform-core's body has run. So on the
87
+ // floor every builtin is acquired LAZILY, on first hook use — by which point the
88
+ // setter has run. On the fast tier getBuiltinModule is present, so the builtins are
89
+ // acquired eagerly here at module eval (no setter, no floor path involved).
90
+ let _bootstrapCreateRequire = null;
91
+ // Called by floor-builtin.mjs (imported first by the compat entries) to hand in the
92
+ // floor's `createRequire` without any globalThis surface. NEVER called on the fast
93
+ // tier (getBuiltinModule covers it). The compat entries import floor-builtin ahead of
94
+ // transform-core, so by the time this fires transform-core's body has already
95
+ // evaluated (importEE before importer) — which is exactly why this setter also runs
96
+ // __ensureBuiltins() right now: it lands DURING floor-builtin's evaluation, before the
97
+ // entry's body and long before any hook fires, so every builtin binding is ready
98
+ // without ever consulting globalThis.
99
+ export function setBootstrapCreateRequire(fn) {
100
+ _bootstrapCreateRequire = fn;
101
+ __ensureBuiltins();
102
+ }
103
+
104
+ // node: builtins (lazy on the floor, eager on the fast tier — see above). On the
105
+ // floor `_bootstrapCreateRequire` is read at FETCH time (inside the thunk), never at
106
+ // definition time, so floor-builtin's setter has run before the first fetch fires.
107
+ function __getBuiltin(id) {
108
+ if (typeof process.getBuiltinModule === "function") return process.getBuiltinModule(id);
109
+ return _bootstrapCreateRequire(import.meta.url)(id);
110
+ }
111
+
112
+ // Builtin bindings + the native addon, populated by __ensureBuiltins(). They stay
113
+ // `let` (not `const`) because on the floor they are filled on first hook use rather
114
+ // than at module eval — see the lazy-vs-eager note above.
115
+ let createRequire, __require, module, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync;
116
+ let fileURLToPath, pathToFileURL, join, dirname;
117
+ // Nub's N-API addon — the in-process TS/JSX transpiler (`transform`,
118
+ // `transformCached`, `detectModuleInfo`), the tsconfig reader + additive
119
+ // TS-resolver (`loadTsconfig`, `resolveTs`), AND the data-format parsers
120
+ // (`parseYaml`/`parseToml`/`parseJson5`/`parseJsonc`), all native. Loaded once
121
+ // per module instance (= once per thread: the main thread and the loader worker
122
+ // each import this module separately). It is a `.node` binary resolved by absolute
123
+ // path off this file's dir, so it never touches the ESM loader chain — the
124
+ // historical require(esm)-of-an-ESM-npm-package leak (oxc-transform, and before
125
+ // this migration get-tsconfig) is gone: transpilation, tsconfig discovery, the
126
+ // additive resolution, and the transpile cache are synchronous native calls, no JS
127
+ // package, no static-import graph to route. nub now loads ZERO npm packages
128
+ // internally, so the user ESM loader chain can never observe a nub dependency.
129
+ let nubNative = null;
130
+
131
+ // Idempotent. Acquires the node: builtins + the native addon. Runs eagerly at module
132
+ // eval on the fast tier (getBuiltinModule present); on the floor it is invoked at the
133
+ // top of every exported entry point, where the threaded createRequire is ready.
134
+ let __builtinsReady = false;
135
+ function __ensureBuiltins() {
136
+ if (__builtinsReady) return;
137
+ __builtinsReady = true;
138
+ ({ createRequire } = __getBuiltin("node:module"));
139
+ __require = createRequire(import.meta.url);
140
+ module = __getBuiltin("node:module");
141
+ ({ readFileSync, writeFileSync, mkdirSync, statSync, realpathSync } = __getBuiltin("node:fs"));
142
+ ({ fileURLToPath, pathToFileURL } = __getBuiltin("node:url"));
143
+ ({ join, dirname } = __getBuiltin("node:path"));
144
+ for (const rel of ["./addons/nub-native.node", "../runtime/addons/nub-native.node"]) {
145
+ try { nubNative = __require(fileURLToPath(new URL(rel, import.meta.url))); break; } catch {}
146
+ }
147
+ // Standalone-loader distribution (`node --import <pkg>`): the addon rides a
148
+ // per-platform npm package rather than a sibling addons/ dir; the loader entry
149
+ // resolves it and hands the absolute path over via internal env plumbing
150
+ // (loader-platform.cjs ensureAddonEnv). LAST in probe order, deliberately: a
151
+ // nub-CLI process nested under the standalone loader inherits the env var, and
152
+ // probing it first would load the outer loader's (possibly differently-
153
+ // versioned) addon over the CLI's own bundled one.
154
+ if (!nubNative && process.env.__NUB_ADDON_PATH) {
155
+ try { nubNative = __require(process.env.__NUB_ADDON_PATH); } catch {}
156
+ }
157
+ }
158
+ // Fast tier: getBuiltinModule is present, so acquire everything now (preserves the
159
+ // original eager-at-eval behavior). The floor defers to first-use — see above.
160
+ if (typeof process.getBuiltinModule === "function") __ensureBuiltins();
161
+
162
+ // The resolved `nub.jsonc` snapshot. The Rust frontend resolves it once, after
163
+ // the final cwd is known, and transports it unchanged through nested shim
164
+ // launches, so every process in a run transpiles against the same config. This
165
+ // is internal process plumbing, not a user-facing environment knob.
166
+ let runtimeConfig = {};
167
+ try { runtimeConfig = JSON.parse(process.env.__NUB_RUNTIME_CONFIG || "{}"); } catch {}
168
+ const RUNTIME_LOADER = runtimeConfig.loader || {};
169
+ const RUNTIME_TSCONFIG = runtimeConfig.tsconfig || undefined;
170
+ // Transform-only TypeScript options may live directly in `nub.jsonc`. They
171
+ // override the selected/nearest tsconfig because the project runtime config is
172
+ // the more specific source for what Nub executes; `baseUrl`/`paths` stay in the
173
+ // tsconfig reader, where editors and the resolver share them.
174
+ const RUNTIME_COMPILER_OPTIONS = {};
175
+ for (const key of [
176
+ "jsx",
177
+ "jsxFactory",
178
+ "jsxFragmentFactory",
179
+ "jsxImportSource",
180
+ "experimentalDecorators",
181
+ "emitDecoratorMetadata",
182
+ ]) {
183
+ if (runtimeConfig[key] !== null && runtimeConfig[key] !== undefined) {
184
+ RUNTIME_COMPILER_OPTIONS[key] = runtimeConfig[key];
185
+ }
186
+ }
187
+
188
+ // NOTE: the transpile-cache version component is no longer read here. nub's
189
+ // version is baked into the native addon at compile time (`env!("CARGO_PKG_VERSION")`
190
+ // in nub-native's cache.rs), which `make version` keeps in lockstep with
191
+ // runtime/version.mjs and Cargo.toml — so the cache key's version component lives
192
+ // natively now, and this file no longer needs to read version.mjs.
193
+
194
+ // ── Constants ───────────────────────────────────────────────────────
195
+ // TS/JSX exts ALWAYS transform (type-stripping is required), so they live in
196
+ // TRANSPILE_EXTS — the set every dispatch site checks to route a file to
197
+ // loadTranspile. Plain JS (.js/.mjs/.cjs) is DELIBERATELY NOT here: a plain-JS file
198
+ // is transpiled ONLY when it carries transformable syntax (`using`/`await using`,
199
+ // `v`-flag RegExp, decorators), and a no-op plain-JS file must take Node's OWN load
200
+ // path BYTE-FOR-BYTE — putting it in TRANSPILE_EXTS would route every `.js`/`.cjs`
201
+ // through nub's hook and change native CJS/ESM behavior (the `commonjs-sync` relabel,
202
+ // require.cache, the require-of-ESM-syntax-.cjs error). So plain JS is handled by a
203
+ // SEPARATE narrow path (`maybeTranspilePlainJs`) that fires only for transformable
204
+ // files and is a no-op (returns null) otherwise — see PLAIN_JS_EXTS below.
205
+ export const TRANSPILE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".jsx"]);
206
+ // Project-source plain JS. Routed to the transpiler ONLY when transformable (the
207
+ // `maybeTranspilePlainJs` gate); a no-op plain-JS file falls through to Node's
208
+ // native loader untouched, byte-identical. node_modules is excluded at the gate.
209
+ export const PLAIN_JS_EXTS = new Set([".js", ".mjs", ".cjs"]);
210
+ // The data loaders nub SHIPS — a runtime feature, not a project setting, so they stay
211
+ // in force inside node_modules too (see dataExtsFor).
212
+ const BUILTIN_DATA_EXTS = { ".jsonc": "jsonc", ".json5": "json5", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", ".txt": "txt" };
213
+ // The built-ins with this project's `loader` config layered on top: an extension
214
+ // pointed at a TS/JSX dialect moves to TRANSPILE_EXTS, anything else becomes (or
215
+ // overrides) a data loader.
216
+ const PROJECT_DATA_EXTS = { ...BUILTIN_DATA_EXTS };
217
+ for (const [ext, loader] of Object.entries(RUNTIME_LOADER)) {
218
+ if (loader === "ts" || loader === "tsx" || loader === "jsx") {
219
+ delete PROJECT_DATA_EXTS[ext];
220
+ TRANSPILE_EXTS.add(ext);
221
+ } else {
222
+ TRANSPILE_EXTS.delete(ext);
223
+ PROJECT_DATA_EXTS[ext] = loader === "text" ? "txt" : loader;
224
+ }
225
+ }
226
+ export const TS_PARENT_EXTS = new Set([".ts", ".tsx", ".mts", ".cts"]);
227
+
228
+ // Which data-loader map governs `url`. A project's `loader` config must not reach code
229
+ // the project didn't write — `{".json": "text"}` would otherwise turn every dependency's
230
+ // JSON import into a string, and pointing a built-in extension at a transpile loader
231
+ // would DELETE a loader a dependency relies on. Same project/dependency boundary the
232
+ // TRANSPILE_EXTS and PLAIN_JS_EXTS dispatches draw with `!isNodeModules`, but as a map
233
+ // SWAP rather than a bail: the built-in half must keep serving deps. Both the dispatch
234
+ // sites and loadData() route through here, so "does this load?" and "as what?" can
235
+ // never disagree.
236
+ export function dataExtsFor(url) {
237
+ return isNodeModules(url) ? BUILTIN_DATA_EXTS : PROJECT_DATA_EXTS;
238
+ }
239
+
240
+ // Packages resolved from Nub's distribution, not the user's.
241
+ export const VENDORED_PACKAGES = new Set(["@oxc-project/runtime"]);
242
+
243
+ // Built-in modules provided by Nub (resolved to files in this distribution).
244
+ // connect() sockets deferred per design decision — "sockets" specifier not clobbered.
245
+ export const BUILTIN_MODULES = new Map();
246
+
247
+ // Package clobbering: specifiers that resolve to a synthetic module re-exporting
248
+ // the native global instead of the userland package.
249
+ export const CLOBBER_MAP = new Map([
250
+ // Reading globalThis.Temporal triggers the lazy getter the tier file installs,
251
+ // which loads the polyfill by resolved path — that load is what installs
252
+ // Date.prototype.toTemporalInstant, so Temporal MUST be read first.
253
+ // @js-temporal/polyfill exports { Temporal, Intl, toTemporalInstant }; mirror
254
+ // all three so `import { Temporal, Intl, toTemporalInstant } from ...` binds.
255
+ ["@js-temporal/polyfill", () => `const T = globalThis.Temporal; export default T; export const Temporal = T; export const Intl = globalThis.Intl; export const toTemporalInstant = Date.prototype.toTemporalInstant;`],
256
+ ["urlpattern-polyfill", () => `export const URLPattern = globalThis.URLPattern;`],
257
+ ["abort-controller", () => `export const AbortController = globalThis.AbortController; export const AbortSignal = globalThis.AbortSignal; export default globalThis.AbortController;`],
258
+ ]);
259
+
260
+ // ── Watch-mode hooks (injected by the main-thread tier) ─────────────
261
+ // `nub watch` needs config files (tsconfig.json, package.json) and `.env*` —
262
+ // which are not in any import graph — surfaced to Node's FilesWatcher. The main
263
+ // thread (preload.mjs) injects reporters; the loader worker injects nothing
264
+ // (watch IPC is main-thread only), so these default to no-ops.
265
+ let _reportDep = null;
266
+ let _reportEnvDir = null;
267
+ export function setWatchHooks({ reportDep, reportEnvDir } = {}) {
268
+ if (reportDep) _reportDep = reportDep;
269
+ if (reportEnvDir) _reportEnvDir = reportEnvDir;
270
+ }
271
+
272
+ // ── tsconfig + package-type caches ──────────────────────────────────
273
+ // tsconfig discovery / parse / `extends` resolution + the `paths` matcher all
274
+ // happen natively (nub-native `loadTsconfig`, the get-tsconfig@4.14.0 port). This
275
+ // JS wrapper exists only to (a) memoize per importer-dir — native ALSO memoizes,
276
+ // but a JS-side Map skips the napi boundary on a hit and lets watch-mode report
277
+ // the dep exactly once per dir — and (b) surface the resolved tsconfig path to the
278
+ // watch FilesWatcher. The returned shape exposes the transform-relevant
279
+ // `compilerOptions` slice and the `tsconfigHash` cache-key component; the `paths`
280
+ // matcher lives entirely in native (`resolveTs` runs it), so there is no JS matcher.
281
+ const tsconfigCache = new Map();
282
+ export function getTsconfigForDir(dir) {
283
+ if (tsconfigCache.has(dir)) return tsconfigCache.get(dir);
284
+ // { path: string|null, compilerOptions: object|null, tsconfigHash: string }
285
+ const result = nubNative
286
+ ? nubNative.loadTsconfig(dir, RUNTIME_TSCONFIG)
287
+ : { path: null, compilerOptions: null, tsconfigHash: "" };
288
+ const compilerOptions = Object.keys(RUNTIME_COMPILER_OPTIONS).length > 0
289
+ ? { ...(result.compilerOptions || {}), ...RUNTIME_COMPILER_OPTIONS }
290
+ : result.compilerOptions;
291
+ const resolved = { ...result, compilerOptions };
292
+ tsconfigCache.set(dir, resolved);
293
+ if (resolved.path) _reportDep?.(resolved.path);
294
+ return resolved;
295
+ }
296
+
297
+ // The NEAREST package.json's `type` decides the format of ambiguous extensions
298
+ // (.ts/.tsx/.jsx, like Node's .js). The nearest one wins even when its `type`
299
+ // is absent — Node does not skip a typeless package.json to find a typed
300
+ // ancestor — so we stop at the first package.json found. Returns "module",
301
+ // "commonjs", or undefined.
302
+ const packageTypeCache = new Map();
303
+ export function getPackageType(dir) {
304
+ if (packageTypeCache.has(dir)) return packageTypeCache.get(dir);
305
+ let type;
306
+ let current = dir;
307
+ for (;;) {
308
+ const pkgPath = join(current, "package.json");
309
+ if (fileExists(pkgPath)) {
310
+ // Keep the runtime package-type read aligned with the Rust tsconfig reader:
311
+ // Windows editors and PowerShell may prefix valid JSON with one UTF-8 BOM.
312
+ try { type = JSON.parse(readFileSync(pkgPath, "utf8").replace(/^\uFEFF/, "")).type; } catch {}
313
+ // Watch this package.json (a `type`/script edit should restart) and the
314
+ // `.env*` files alongside it (the package root is where they live).
315
+ _reportDep?.(pkgPath);
316
+ _reportEnvDir?.(current);
317
+ break;
318
+ }
319
+ const parent = dirname(current);
320
+ if (parent === current) break;
321
+ current = parent;
322
+ }
323
+ packageTypeCache.set(dir, type);
324
+ return type;
325
+ }
326
+
327
+ // ── Filesystem helpers ──────────────────────────────────────────────
328
+ // Is this a URL whose bytes nub may read off disk? Every branch of either load
329
+ // hook that claims a module ends in `fileURLToPath` + `readFileSync`, so `file:`
330
+ // is the whole answer. A load hook sees whatever scheme resolution produced:
331
+ // `node:`, `data:`, and — because a user `module.register` loader may serve any
332
+ // protocol it likes — `custom://x.js`, `byop://1/index.mjs`, an http-loader's
333
+ // `https://…/x.js`. None of those are nub's to claim.
334
+ export function isFileUrl(url) {
335
+ return typeof url === "string" && url.startsWith("file:");
336
+ }
337
+
338
+ export function extname(url) {
339
+ // Report an extension ONLY for a `file:` URL: the extension is what dispatches
340
+ // both load hooks into their transpile/data branches, and a non-`file:` URL that
341
+ // merely ENDS in something extension-shaped used to enter them anyway, where the
342
+ // unguarded `fileURLToPath` threw ERR_INVALID_URL_SCHEME — masking Node's own
343
+ // ERR_UNSUPPORTED_ESM_URL_SCHEME and killing every custom-protocol ESM loader
344
+ // that plain Node runs fine. `data:` was the first face of this (its payload is
345
+ // INLINE, so a trailing `//x.ts` comment or a sourceMappingURL reads as an
346
+ // extension); testing for `file:` positively covers it and every other scheme at
347
+ // once, and — unlike a "does this look like a scheme" test — cannot mistake a
348
+ // Windows drive letter for one.
349
+ if (!isFileUrl(url)) return "";
350
+ const path = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
351
+ const dot = path.lastIndexOf(".");
352
+ return dot === -1 ? "" : path.slice(dot);
353
+ }
354
+
355
+ export function isNodeModules(url) {
356
+ return url.includes("/node_modules/") || url.includes("\\node_modules\\");
357
+ }
358
+
359
+ // "Is this really a dependency?" — the ONE definition every gate that decides how a
360
+ // file LOADS uses, so the resolve step and each load step cannot disagree about the
361
+ // same file. (Watch-mode config reporting still uses the plain check: it asks which
362
+ // files to watch, not how they load.)
363
+ //
364
+ // A `/node_modules/` segment normally settles it: Node realpaths a resolved module,
365
+ // so the path we are handed IS the real one. Under `--preserve-symlinks` it does not
366
+ // — a workspace package symlinked into node_modules keeps that segment while its
367
+ // files genuinely live in the project, and calling it a dependency would refuse the
368
+ // TypeScript that is its build output. The stat therefore happens only under the
369
+ // flag, and only for paths that already look like a dependency; the default path
370
+ // stays the pure substring test it always was.
371
+ export function isDependency(url) {
372
+ if (!isNodeModules(url)) return false;
373
+ if (!PRESERVE_SYMLINKS) return true;
374
+ try {
375
+ return isNodeModules(pathToFileURL(realpathSync(fileURLToPath(url))).href);
376
+ } catch {
377
+ return true; // unreadable: trust the literal path
378
+ }
379
+ }
380
+
381
+ export function fileExists(filePath) {
382
+ const s = statSync(filePath, { throwIfNoEntry: false });
383
+ return s !== undefined && s.isFile();
384
+ }
385
+
386
+ function safeRequireResolve(specifier) {
387
+ try { return __require.resolve(specifier); } catch { return null; }
388
+ }
389
+
390
+ export function barePkg(specifier) {
391
+ return specifier.startsWith("@")
392
+ ? specifier.split("/").slice(0, 2).join("/")
393
+ : specifier.split("/")[0];
394
+ }
395
+
396
+ // ── Resolution ──────────────────────────────────────────────────────
397
+ // The ADDITIVE TS resolution — tsconfig `paths` aliases, `.ts/.tsx/.mts/.cts/.jsx`
398
+ // extension probing, the `.js`→`.ts` (and `.jsx→.tsx`, `.mjs→.mts`, `.cjs→.cts`)
399
+ // emit-convention swap, directory-index probing, and reading a directory's
400
+ // `package.json#main` — all happens natively now (nub-native `resolveTs`). It
401
+ // returns an absolute path for the additive cases nub owns, or `null` for
402
+ // EVERYTHING Node owns (node_modules, `exports`/`imports`, conditions, scoped/bare
403
+ // specifiers), which the resolve hooks below turn into a fall-through to Node. That
404
+ // `null` is the byte-for-byte compat boundary; reimplementing Node's resolution in
405
+ // nub is forbidden. The `node:`/`data:`/builtin guards, the nub-internal-graph
406
+ // bypass, vendored packages, and the clobber map all stay in JS and run BEFORE the
407
+ // native resolver (see resolveSpec / resolveCjsPath).
408
+ // Node's `--preserve-symlinks` decides whether a resolved module is keyed by its
409
+ // real path or the path it was reached through, and the native resolver has to hand
410
+ // back whichever one Node itself would — otherwise a symlinked workspace package
411
+ // reached two ways instantiates twice. The flag arrives by argv OR `NODE_OPTIONS`,
412
+ // and only one of those shows up in `execArgv`, so both are checked. The word
413
+ // boundary matters: `--preserve-symlinks-main` is a DIFFERENT flag that governs only
414
+ // the entry point.
415
+ const PRESERVE_SYMLINKS =
416
+ process.execArgv.includes("--preserve-symlinks") ||
417
+ /(^|\s)--preserve-symlinks(\s|$)/.test(process.env.NODE_OPTIONS || "");
418
+
419
+ function resolveTs(specifier, parentPath) {
420
+ if (!nubNative) return null;
421
+ try {
422
+ return nubNative.resolveTs(specifier, parentPath || "", RUNTIME_TSCONFIG, PRESERVE_SYMLINKS);
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ // nub's own runtime directory (this file's dir, as a file: URL prefix). Any
429
+ // resolution whose IMPORTER lives here is one of nub's internal requires — the
430
+ // preload loading transform-core, the Temporal lazy getter resolving
431
+ // @js-temporal/polyfill — and must NEVER be routed through nub's own
432
+ // clobber/vendored/tsconfig logic: those are user-code conveniences, and applying
433
+ // them to nub's internals both breaks them (e.g. the Temporal clobber re-exports
434
+ // globalThis.Temporal, which IS the getter → a require of the polyfill from the
435
+ // getter would recurse into the clobber) and amplifies the user loader chain by
436
+ // re-walking nub's internal graph through user hooks (R11). Short-circuit to native
437
+ // resolution for these.
438
+ const RUNTIME_DIR_URL = new URL(".", import.meta.url).href;
439
+
440
+ // Is this importer part of nub's own internal module graph? Such imports must
441
+ // bypass the user ESM loader chain entirely (R11). nub now loads ZERO npm packages
442
+ // internally — tsconfig, the additive resolver, the transpile cache, the
443
+ // transpiler, and module detection are ALL native nub-native calls, and the only
444
+ // remaining JS deps (@oxc-project/runtime helpers, the polyfills) are CommonJS,
445
+ // whose `require()` graph already bypasses the ESM loader chain by construction. So
446
+ // the only nub-internal ESM importer left is nub's own runtime directory (this
447
+ // file, the preload tiers, the Temporal lazy getter resolving @js-temporal/
448
+ // polyfill). The historical "nub-dependency package roots" walk — which existed
449
+ // solely to catch an ESM hop into get-tsconfig (and before that oxc-transform) — is
450
+ // gone with those packages.
451
+ function isNubInternalParent(parentURL) {
452
+ if (!parentURL) return false;
453
+ return String(parentURL).startsWith(RUNTIME_DIR_URL);
454
+ }
455
+
456
+ // Set while resolveSpec is inside its own `require.resolve` for a nub-internal
457
+ // importer; see the re-entrancy guard there. Module-scoped, so each realm (the
458
+ // main thread, each worker, the async tier's loader worker) carries its own — and
459
+ // resolve hooks are synchronous, so a single flag cannot interleave.
460
+ let resolvingInternal = false;
461
+
462
+ // Resolve a specifier the way both hook tiers do. Returns `{ url, shortCircuit }`
463
+ // to short-circuit Node's resolver, or `null` to fall through to `nextResolve`.
464
+ // `parentURL` is the importer (a file: URL string), or "" for the entry.
465
+ export function resolveSpec(specifier, parentURL) {
466
+ // nub's own internal graph (importer inside nub's runtime dir OR a nub
467
+ // dependency package): resolve natively and SHORT-CIRCUIT so nextResolve (the
468
+ // user's loader chain) never observes nub's internals. This MUST run before the
469
+ // node:/data:/builtin early-returns below, because those `return null` =
470
+ // DELEGATE to the user loader — and a nub-internal `import "node:module"` (e.g.
471
+ // from a nub-dependency ESM entry) delegated to a strict user loader is exactly
472
+ // the R11 leak. See isNubInternalParent.
473
+ if (isNubInternalParent(parentURL)) {
474
+ if (specifier.startsWith("node:") || module.isBuiltin(specifier)) {
475
+ const url = specifier.startsWith("node:") ? specifier : `node:${specifier}`;
476
+ return { url, shortCircuit: true };
477
+ }
478
+ if (specifier.startsWith("data:")) return { url: specifier, shortCircuit: true };
479
+ // Re-entrancy guard. The `require.resolve` below runs through Node's CJS
480
+ // resolver, which invokes the registered resolve hook — i.e. back into this
481
+ // function with the same specifier and parent. Unguarded, the two call each
482
+ // other until V8 exhausts the stack and the RangeError lands in the catch: every
483
+ // nub-internal relative require cost ~849 nested hook invocations (measured on
484
+ // the fast-tier preload's own `./navigator-shim.mjs` / `./worker-blob-url.cjs`),
485
+ // ~50 CPU-ms per process, and produced the right answer only by accident of that
486
+ // unwind. Delegating the RE-ENTRANT call is not a behavior change: the outer
487
+ // frame still short-circuits the user chain, and Node's default resolver is what
488
+ // `require.resolve` was going to consult anyway.
489
+ if (resolvingInternal) return null;
490
+ // A relative/bare import from inside nub's graph: resolve it natively from the
491
+ // parent's own require() resolver (NOT nub's tsconfig/clobber/probe logic) and
492
+ // short-circuit. Bare specifiers resolve from the parent package's location.
493
+ resolvingInternal = true;
494
+ try {
495
+ const parentReq = createRequire(parentURL);
496
+ const resolved = parentReq.resolve(specifier);
497
+ return { url: pathToFileURL(resolved).href, shortCircuit: true };
498
+ } catch {
499
+ // Couldn't resolve from the parent (e.g. a non-file: parent): still short-
500
+ // circuit by handing the specifier back as-is, so the user chain is bypassed.
501
+ return null;
502
+ } finally {
503
+ resolvingInternal = false;
504
+ }
505
+ }
506
+
507
+ // node: and data: protocols, and bare Node built-ins, are never ours.
508
+ if (specifier.startsWith("node:") || specifier.startsWith("data:")) return null;
509
+ if (module.isBuiltin(specifier)) return null;
510
+
511
+ // 1. Built-in modules provided by Nub.
512
+ if (BUILTIN_MODULES.has(specifier)) {
513
+ return { url: BUILTIN_MODULES.get(specifier), shortCircuit: true };
514
+ }
515
+
516
+ // 2. Vendored packages (e.g. @oxc-project/runtime).
517
+ const bare = barePkg(specifier);
518
+ if (VENDORED_PACKAGES.has(bare)) {
519
+ const resolved = safeRequireResolve(specifier);
520
+ if (resolved) return { url: pathToFileURL(resolved).href, shortCircuit: true };
521
+ }
522
+
523
+ // 3. Package clobbering.
524
+ if (CLOBBER_MAP.has(bare) && !isDependency(parentURL || "")) {
525
+ return { url: `data:text/javascript,${encodeURIComponent(CLOBBER_MAP.get(bare)())}`, shortCircuit: true };
526
+ }
527
+
528
+ const parent = String(parentURL || "");
529
+
530
+ // 4. The ADDITIVE TS resolution (tsconfig `paths`, extension probing, `.js`→`.ts`
531
+ // swap, directory index/`main`) — native. `resolveTs` is handed the parent's
532
+ // absolute FS path (or "" for a non-file: parent / the entry, where it falls back
533
+ // to cwd, matching the old `process.cwd()` parentDir). A non-null result is an
534
+ // additive hit nub owns; null falls through to Node's resolver (the compat
535
+ // boundary — node_modules, `exports`, bare/scoped specifiers stay Node's).
536
+ const parentPath = parent.startsWith("file:") ? fileURLToPath(parent) : "";
537
+ const resolved = resolveTs(specifier, parentPath);
538
+ if (resolved) return { url: pathToFileURL(resolved).href, shortCircuit: true };
539
+
540
+ return null;
541
+ }
542
+
543
+ // CommonJS `require()` resolution for the compat-tier Module._resolveFilename
544
+ // patch. Returns an absolute file path for a require specifier nub should
545
+ // redirect (tsconfig `paths`, extensionless `.ts`, `.js`→`.ts` swap), or null to
546
+ // defer to Node's resolver. Mirrors resolveSpec steps 4–5 but returns a path (not
547
+ // a URL) and never handles clobber/vendored/builtin — those are import-only, and
548
+ // a clobber's data: URL can't be a require target. `parentPath` is the requiring
549
+ // file's absolute path (from the CJS parent Module), or null for the entry.
550
+ export function resolveCjsPath(request, parentPath) {
551
+ if (request.startsWith("node:") || request.startsWith("data:") ||
552
+ module.isBuiltin(request)) {
553
+ return null;
554
+ }
555
+ // The SAME native additive resolver as resolveSpec, returning an absolute path
556
+ // (not a URL). Vendored/clobber/builtin are import-only and never reach here. A
557
+ // null result (node_modules / `exports` / a plain bare package) falls through to
558
+ // Node's CJS resolver — the compat boundary.
559
+ return resolveTs(request, parentPath || "");
560
+ }
561
+
562
+ // Would `require()`-ing this resolved TS file need Node's require(esm)? An
563
+ // ESM-syntax `.ts`/`.mts` (or a `.ts` in a `type: module` package) transpiles to
564
+ // ESM, which `require()` can only load via require(esm). On the compat tier that
565
+ // path is the loader-worker's CJS translator, which on Node below the #60380 fix
566
+ // crashes cryptically (`cjsCache.get(job.url)` is undefined) instead of erroring.
567
+ // The compat CJS shim calls this so it can surface a clean ERR_REQUIRE_ESM
568
+ // instead. (`.cts` is always CommonJS → false; non-transpiled extensions → false.)
569
+ export function requireTargetIsEsm(filePath, ext) {
570
+ if (ext === ".cts") return false;
571
+ if (ext === ".mts") return true;
572
+ if (!TRANSPILE_EXTS.has(ext)) return false;
573
+ let source;
574
+ try { source = readFileSync(filePath, "utf8"); } catch { return false; }
575
+ const pkgType = getPackageType(dirname(filePath));
576
+ return moduleFormatFor(ext, pkgType, filePath, source) === "module";
577
+ }
578
+
579
+ // ── Module-format detection ─────────────────────────────────────────
580
+ // The oxc `lang` for a transpiled extension: the project's `loader` config wins
581
+ // where it turns JSX on (`tsx`/`jsx`), otherwise the extension decides. Shared by
582
+ // the format probe and the transform itself so the parse that DECIDES the format
583
+ // and the parse that PRODUCES the output can never disagree about what a file is.
584
+ // The one pairing this would silently discard — `ts` on `.tsx`/`.jsx`, which
585
+ // still parses as JSX — is refused by the config parser (`validate_loader`), so
586
+ // reaching the extension fallback here always means the config asked for nothing
587
+ // different, never that a request was dropped.
588
+ function langFor(ext) {
589
+ const configuredLoader = RUNTIME_LOADER[ext];
590
+ return configuredLoader === "tsx" || configuredLoader === "jsx"
591
+ ? configuredLoader
592
+ : ext === ".tsx" ? "tsx" : ext === ".jsx" ? "jsx" : "ts";
593
+ }
594
+
595
+ // Both signals nub needs to read off a file's syntax — the absent-`type` module
596
+ // format and the Stage-3-decorator guard — come from ONE native call into nub's
597
+ // N-API addon (`detectModuleInfo`, the oxc parser compiled in-process). There is
598
+ // no JS parser package anymore: `oxc-parser` (ESM-only, which used to need
599
+ // `require(esm)` on the fast tier and a dynamic-`import()` `ensureParser()` dance
600
+ // on the 18.19 compat tier) is gone, and with it the whole "is require(esm)
601
+ // available here?" fork. The native call is synchronous and works identically on
602
+ // every supported Node, so there is nothing to preload and no async warm-up — the
603
+ // former `ensureParser()` export is removed (its compat-tier callers just stop
604
+ // calling it). Used only for ambiguous extensions / the decorator guard; explicit
605
+ // `type` and `.mts`/`.cts` short-circuit before the parser runs.
606
+ function detectModuleInfo(filePath, source, lang) {
607
+ // Addon missing (should never happen in a real install): default to ESM for
608
+ // format (the common case) and "no decorators" for the guard — the same fallback
609
+ // the old oxc-parser-unavailable branches used.
610
+ if (!nubNative) return { hasValueEsmSyntax: true, hasDecorators: false, transformableSyntax: false };
611
+ try {
612
+ return nubNative.detectModuleInfo(filePath, source, lang);
613
+ } catch {
614
+ // Unparseable → CJS for format + no decorators (the transpile/V8 surfaces the
615
+ // real error), matching the old per-call catch blocks. `transformableSyntax:
616
+ // false` is the SAFE plain-JS default — the verbatim path hands the raw bytes
617
+ // back, so V8 surfaces the real syntax error exactly where Node would.
618
+ return { hasValueEsmSyntax: false, hasDecorators: false, transformableSyntax: false };
619
+ }
620
+ }
621
+
622
+ // Map a transpiled file's extension + nearest package.json "type" to the module
623
+ // format Node's loader should use. `.mts`/`.cts` are explicit; an explicit
624
+ // `type` is authoritative; otherwise (ambiguous) we detect from source syntax —
625
+ // full Node parity (`--experimental-detect-module`), so a CJS-syntax `.ts` with
626
+ // no `type` runs as CJS on nub exactly as on Node. See internal/runtime/module-format.md.
627
+ // `.mjs`→module / `.cjs`→commonjs are explicit (mirroring `.mts`/`.cts`), so the
628
+ // plain-JS gate gets the right format without a needless detect.
629
+ export function moduleFormatFor(ext, pkgType, filePath, source) {
630
+ return moduleFormatWithInfo(ext, pkgType, filePath, source).format;
631
+ }
632
+
633
+ // Same format decision as moduleFormatFor, but ALSO returns the `ModuleInfo`
634
+ // (`detectModuleInfo`) result when a parse was needed — `{ format, info }`, with
635
+ // `info` null on the no-parse short-circuits (`.mts`/`.mjs`/`.cts`/`.cjs`, explicit
636
+ // `type`). loadTranspile uses this so its ONE parse serves BOTH readers — the
637
+ // format decision (`hasValueEsmSyntax`) and the Stage-3 decorator guard
638
+ // (`hasDecorators`) — instead of `moduleFormatFor` + `hasDecoratorSyntax` each
639
+ // parsing the same source. On a short-circuit (`info` null) no parse happened, so
640
+ // the decorator guard runs its own single parse: still ≤1 detect per file.
641
+ function moduleFormatWithInfo(ext, pkgType, filePath, source) {
642
+ if (ext === ".mts" || ext === ".mjs") return { format: "module", info: null };
643
+ if (ext === ".cts" || ext === ".cjs") return { format: "commonjs", info: null };
644
+ if (pkgType === "module") return { format: "module", info: null };
645
+ if (pkgType === "commonjs") return { format: "commonjs", info: null };
646
+ const info = detectModuleInfo(filePath, source, langFor(ext));
647
+ return { format: info.hasValueEsmSyntax ? "module" : "commonjs", info };
648
+ }
649
+
650
+ // The Stage-3-decorator rejection diagnostic. oxc does not lower TC39 Stage 3
651
+ // decorators yet (oxc-project/oxc#9170) — it passes the `@decorator` syntax
652
+ // through verbatim with errors:[], so without this check V8 throws a bare
653
+ // `SyntaxError: Invalid or unexpected token`. See internal/runtime/stage3-decorators.md.
654
+ function stage3DecoratorError(filePath) {
655
+ return new Error(
656
+ `Nub: Stage 3 decorators are not supported by the transpiler yet.\n` +
657
+ `This is an upstream limitation in oxc (oxc-project/oxc#9170).\n` +
658
+ ` in ${filePath}\n\n` +
659
+ `Workarounds:\n` +
660
+ ` 1. Set "decorators": "legacy" in nub.jsonc, or set\n` +
661
+ ` "experimentalDecorators": true in tsconfig.json\n` +
662
+ ` (the shape NestJS / TypeORM / class-validator are written against).\n` +
663
+ ` 2. Wait for Stage 3 decorator support in oxc; tracked upstream at\n` +
664
+ ` https://github.com/oxc-project/oxc/issues/9170.\n\n` +
665
+ `See: https://www.typescriptlang.org/tsconfig/#experimentalDecorators`,
666
+ );
667
+ }
668
+
669
+ // Does the source contain TC39 decorator syntax (`@expr` on a class or class
670
+ // member)? Used ONLY when legacy decorators are off, to surface a clear
671
+ // diagnostic instead of oxc's verbatim passthrough → V8 SyntaxError. The cheap
672
+ // `source.includes("@")` pre-filter in the caller keeps decorator-free files off
673
+ // the native parser. The walk now happens in Rust (detectModuleInfo's AST visit).
674
+ function hasDecoratorSyntax(filePath, source, lang) {
675
+ return detectModuleInfo(filePath, source, lang).hasDecorators;
676
+ }
677
+
678
+ // ── Transpile cache ─────────────────────────────────────────────────
679
+ // The transpile cache — `cacheGet` + transform-on-miss + post-processing
680
+ // (CJS empty-export strip, inline sourceMap, `//# sourceURL=`) + `cacheSet` — is
681
+ // ONE native call now (nub-native `transformCached`): the cache key (NUB_VERSION
682
+ // is the sole version component — a new release ships any emit change + a rebuilt
683
+ // addon), the 16-hex integrity prefix, the `c`/`m` format byte, and the atomic
684
+ // `*.tmp`-then-rename write all live in Rust, byte-identical to the old JS cache so
685
+ // warm caches survive. This JS file keeps only (a) the cache enable/disable signal
686
+ // and (b) the cache directory it passes IN, so the policy stays in JS and native
687
+ // just does the I/O against the dir nub hands it.
688
+ //
689
+ // Disable the transpile cache when (a) the permission model is active (writing a
690
+ // cache file may not be granted), or (b) the user set `NODE_COMPILE_CACHE=0` —
691
+ // Node's compile-cache disable signal, which nub honors as "no caching in this
692
+ // pipeline" (one knob for both V8's compile cache and nub's transpile cache; no
693
+ // nub-specific env var). Per internal/runtime/transpile-cache.md (the maintainer 2026-05-18).
694
+ const CACHE_DISABLED =
695
+ process.permission?.has !== undefined || process.env.NODE_COMPILE_CACHE === "0";
696
+ // Resolved lazily (memoized) rather than at module eval, because on the floor the
697
+ // node:path builtins it needs aren't bound until __ensureBuiltins() runs on first
698
+ // hook use. `null` = disabled / no writable dir; `undefined` cacheDirResolved means
699
+ // "not yet computed".
700
+ let cacheDir = null;
701
+ let cacheDirResolved = false;
702
+ // nub's cache ROOT (`<cache>/nub`), computed WITHOUT creating anything, so the
703
+ // sweep-due probe and the compile-cache check can name a directory without a
704
+ // mkdir side effect on every startup.
705
+ function cacheRoot() {
706
+ const base = process.env.XDG_CACHE_HOME || (process.env.HOME ? join(process.env.HOME, ".cache") : null);
707
+ return base ? join(base, "nub") : null;
708
+ }
709
+ function getCacheDir() {
710
+ if (cacheDirResolved) return cacheDir;
711
+ cacheDirResolved = true;
712
+ if (CACHE_DISABLED) return cacheDir;
713
+ __ensureBuiltins();
714
+ const root = cacheRoot();
715
+ if (root) {
716
+ cacheDir = join(root, "transpile");
717
+ try { mkdirSync(cacheDir, { recursive: true }); } catch { cacheDir = null; }
718
+ }
719
+ return cacheDir;
720
+ }
721
+
722
+ // ── Bounded-cache maintenance ───────────────────────────────────────
723
+ const CACHE_MAX_BYTES = 512 * 1024 * 1024; // 512 MiB — bounds runaway growth, not normal use
724
+ const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; // ≤ one sweep per day
725
+
726
+ // Is a sweep DUE right now? Deliberately cheap and side-effect-free: one
727
+ // `statSync` against a path built without `mkdir`, loading no module the preload
728
+ // has not already loaded. The caller uses this to decide whether to schedule the
729
+ // sweep AT ALL, which is what lets the scheduled work be ref'd instead of
730
+ // unref'd — see preload.cjs for why that mattered.
731
+ //
732
+ // It deliberately does NOT test for the main thread. The tempting cheap test —
733
+ // "is worker_threads in `process.moduleLoadList`?" — is simply WRONG here:
734
+ // nub's own preload already pulls worker_threads in on the MAIN thread
735
+ // (verified), so it reports every run as a worker and nothing ever sweeps.
736
+ // `maybeSweepCache` asks `isMainThread` authoritatively, so the worst a worker
737
+ // thread costs is one statSync and a scheduled immediate that no-ops.
738
+ export function sweepDue() {
739
+ if (CACHE_DISABLED) return false;
740
+ __ensureBuiltins();
741
+ const root = cacheRoot();
742
+ if (!root) return false;
743
+ const s = statSync(join(root, "transpile", ".sweep"), { throwIfNoEntry: false });
744
+ return !s || Date.now() - s.mtimeMs >= SWEEP_INTERVAL_MS;
745
+ }
746
+
747
+ export function maybeSweepCache() {
748
+ __ensureBuiltins();
749
+ const dir = getCacheDir();
750
+ if (!dir) return;
751
+ // Workers inherit this preload (via execArgv); only the main thread sweeps.
752
+ try {
753
+ if (!__require("node:worker_threads").isMainThread) return;
754
+ } catch {
755
+ return;
756
+ }
757
+ const sentinel = join(dir, ".sweep");
758
+ const s = statSync(sentinel, { throwIfNoEntry: false });
759
+ if (s && Date.now() - s.mtimeMs < SWEEP_INTERVAL_MS) return;
760
+ try {
761
+ writeFileSync(sentinel, "");
762
+ } catch {
763
+ return;
764
+ }
765
+ // nub's OWN default V8 compile-cache dir gets the same daily treatment. The
766
+ // Rust spawn layer creates it and points NODE_COMPILE_CACHE at it for every
767
+ // augmented run (spawn.rs `default_compile_cache_dir`), it gains an entry per
768
+ // distinct module path plus a whole subdirectory per Node build, and nothing
769
+ // ever removed any of it — 6.9 GB across ~594k files after ~12 days on a
770
+ // working machine. Swept ONLY when NODE_COMPILE_CACHE is exactly nub's own
771
+ // dir: a dir the USER chose is theirs, and nub must not evict from it.
772
+ const root = cacheRoot();
773
+ const ownCompileCache = root ? join(root, "v8-compile-cache") : null;
774
+ const compileDir =
775
+ ownCompileCache && process.env.NODE_COMPILE_CACHE === ownCompileCache ? ownCompileCache : null;
776
+ import("./cache-evict.mjs")
777
+ .then((m) => {
778
+ // Below Node 22.3 the module cannot reach `process.getBuiltinModule`; hand it
779
+ // the same createRequire-backed getter this file uses. See cache-evict.mjs's
780
+ // no-static-imports note.
781
+ m.setBuiltinGetter(__getBuiltin);
782
+ m.sweepCache(dir, CACHE_MAX_BYTES);
783
+ if (compileDir) m.sweepCompileCache(compileDir, CACHE_MAX_BYTES);
784
+ })
785
+ .catch(() => {});
786
+ }
787
+
788
+ // ── Transpile ───────────────────────────────────────────────────────
789
+ // Transpile a TS/JSX file to JS, returning `{ format, source, shortCircuit }` in
790
+ // the shape both hook tiers hand back to Node. Format is detected (not derived
791
+ // from extension alone), so a CommonJS-syntax `.ts` is reported `commonjs` — the
792
+ // fix that makes `require()` of a TS file work on the compat tier, where Node's
793
+ // CJS translator loads it via this hook and keys on the returned format.
794
+ export function loadTranspile(url, ext) {
795
+ __ensureBuiltins();
796
+ const filePath = fileURLToPath(url);
797
+ const source = readFileSync(filePath, "utf8");
798
+ const dir = dirname(filePath);
799
+ // The transform-relevant compilerOptions slice + the byte-for-byte cache-key
800
+ // component (`tsconfigHash`) both come from the native tsconfig reader.
801
+ const { compilerOptions: co, tsconfigHash } = getTsconfigForDir(dir);
802
+
803
+ // The nearest package.json `type` decides the format of an ambiguous extension
804
+ // (.ts/.tsx/.jsx); .mts/.cts are explicit so its lookup is skipped. The chosen
805
+ // format is folded into the cache key (and the entry's leading byte) by native.
806
+ const pkgType = ext === ".mts" || ext === ".cts" ? undefined : getPackageType(dir);
807
+ // ONE detectModuleInfo parse for both the format decision and the decorator
808
+ // guard below: `moduleInfo` is the parsed ModuleInfo when the format needed a
809
+ // parse (ambiguous ext, no explicit `type`), else null (a no-parse short-circuit).
810
+ const { format, info: moduleInfo } = moduleFormatWithInfo(ext, pkgType, filePath, source);
811
+
812
+ const lang = langFor(ext);
813
+
814
+ const opts = {
815
+ lang,
816
+ sourceType: format === "commonjs" ? "commonjs" : "module",
817
+ sourcemap: true,
818
+ // Lower syntax newer than the 22.15 floor. Critically this downlevels
819
+ // `using`/`await using` (Explicit Resource Management) — unparseable on Node
820
+ // 22's V8 — into the vendored `@oxc-project/runtime/helpers/usingCtx` shape,
821
+ // which resolves via VENDORED_PACKAGES. Without a target, oxc leaves `using`
822
+ // verbatim and Node 22 throws a SyntaxError. es2022 is the highest target
823
+ // that still lowers `using` while leaving everything Node 22 already supports
824
+ // (top-level await, class fields, private methods) untouched.
825
+ target: "es2022",
826
+ typescript: {},
827
+ // Decorators default to OFF (Stage-3 mode), matching tsc: legacy semantics
828
+ // and metadata are opt-in via tsconfig. See internal/runtime/non-erasable-syntax.md.
829
+ decorator: co?.experimentalDecorators === true
830
+ ? { legacy: true, emitDecoratorMetadata: co?.emitDecoratorMetadata === true }
831
+ : undefined,
832
+ };
833
+ if (lang === "tsx" || lang === "jsx") {
834
+ opts.jsx = {
835
+ runtime: co?.jsx === "react" ? "classic" : "automatic",
836
+ development: co?.jsx === "react-jsxdev",
837
+ importSource: co?.jsxImportSource || "react",
838
+ };
839
+ if (co?.jsxFactory) opts.jsx.pragma = co.jsxFactory;
840
+ if (co?.jsxFragmentFactory) opts.jsx.pragmaFrag = co.jsxFragmentFactory;
841
+ }
842
+
843
+ // Stage-3 decorators: oxc returns errors:[] and emits the `@decorator` syntax
844
+ // verbatim, so the result-error check below never fires and V8 throws a bare
845
+ // SyntaxError. When legacy mode is off and decorator syntax is present, reject
846
+ // with the documented Option-A diagnostic instead. (Cheap `source.includes("@")`
847
+ // pre-filter keeps decorator-free files off the native parser; runs BEFORE the
848
+ // cache so the diagnostic surfaces even on what would be a warm hit.) Reuse the
849
+ // `hasDecorators` flag from the format parse above when it ran (`moduleInfo`
850
+ // non-null), so the ambiguous-ext + `@` path detects ONCE; on a no-parse
851
+ // short-circuit (`.mts`/`.cts`/explicit `type`) it does its own single parse.
852
+ if (co?.experimentalDecorators !== true && source.includes("@") &&
853
+ (moduleInfo ? moduleInfo.hasDecorators : hasDecoratorSyntax(filePath, source, lang))) {
854
+ throw stage3DecoratorError(filePath);
855
+ }
856
+
857
+ // cacheGet + transform-on-miss + post-process (CJS empty-export strip, inline
858
+ // sourceMap, sourceURL append) + cacheSet — ALL native, byte-identical on-disk.
859
+ // The cache key folds in ext + tsconfigHash + pkgType (same source, different
860
+ // type → different format → distinct entry). `cacheDir: null/undefined` is the
861
+ // JS enable/disable signal: native then skips all cache I/O and just transforms.
862
+ const formatByte = format === "commonjs" ? "c" : "m";
863
+ // The RAW configured loader, not `lang`: a non-TS/JSX loader (`text`, `json5`)
864
+ // changes the output without changing `lang`, so the key must see it.
865
+ const runtimeHash = JSON.stringify({
866
+ loader: RUNTIME_LOADER[ext] || null,
867
+ tsconfig: RUNTIME_TSCONFIG || null,
868
+ compilerOptions: RUNTIME_COMPILER_OPTIONS,
869
+ });
870
+ // process.version decides how the appended `//# sourceURL` is percent-encoded:
871
+ // it is spelled to match THIS host's pathToFileURL, whose escape set widened
872
+ // mid-release-line, so the same file has two valid spellings across hosts.
873
+ // Native derives the band from it and folds that band into the cache key.
874
+ const result = nubNative.transformCached(
875
+ filePath, source, opts, ext, `${tsconfigHash || ""}\0${runtimeHash}`, pkgType || "", formatByte, getCacheDir() ?? undefined, process.version,
876
+ );
877
+ if (result.errors.length > 0) {
878
+ const details = result.errors.map((e) => e.codeframe || e.message).join("\n\n");
879
+ throw new Error(`Transpile error in ${filePath}:\n${details}`);
880
+ }
881
+ return { format: result.format, source: result.code, shortCircuit: true };
882
+ }
883
+
884
+ // Project-source plain JS (`.js`/`.mjs`/`.cjs`) gate. Returns a transpiled load
885
+ // result ONLY when the file carries syntax oxc lowers at nub's es2022 target
886
+ // (`using`/`await using`, a `v`-flag RegExp, or decorators); otherwise returns
887
+ // `null`, meaning "this file needs no transform — handle it with Node's OWN loader,
888
+ // exactly as a non-listed extension." This is why `.js`/`.mjs`/`.cjs` are NOT in
889
+ // TRANSPILE_EXTS: a no-op plain-JS file must take Node's native load path
890
+ // byte-for-byte (preserving the `commonjs-sync` relabel, require.cache, the
891
+ // require-of-ESM-syntax-`.cjs` error — all of which intercepting the file would
892
+ // break), and oxc would reformat it (quotes/semicolons/whitespace + a sourcemap
893
+ // footer) if we ran it through anyway. The verdict rides ONE parse (the same one
894
+ // `detectModuleInfo` does for format detection). node_modules is gated at the call
895
+ // sites (the byte-parity boundary). JSX-in-`.js` is out of scope for the syntax
896
+ // gate (lang is "ts", which does not parse JSX); use `.jsx`, or say so explicitly
897
+ // with a `loader` entry, which takes the unconditional path below instead.
898
+ export function maybeTranspilePlainJs(url, ext) {
899
+ __ensureBuiltins();
900
+ // An explicit `loader` entry pointing this extension at a code dialect moved it
901
+ // into TRANSPILE_EXTS, which for every other member means "always compile". Only
902
+ // a plain-JS extension can reach here, so this is true ONLY when the project
903
+ // configured one, and it must not fall through to the syntax gate below: that
904
+ // gate asks "does this file NEED lowering", answers no for JSX (it detects with
905
+ // lang "ts", which cannot parse it), and hands raw JSX to Node for V8 to reject —
906
+ // while the ESM path transpiles the same file on both tiers. The registration
907
+ // loop deliberately skips `.js`/`.cjs` because this wrapper owns them, so there
908
+ // is nothing else downstream to catch it.
909
+ if (TRANSPILE_EXTS.has(ext)) return loadTranspile(url, ext);
910
+ const filePath = fileURLToPath(url);
911
+ let source;
912
+ try {
913
+ source = readFileSync(filePath, "utf8");
914
+ } catch {
915
+ // Unreadable here → let Node's loader surface its own error.
916
+ return null;
917
+ }
918
+ // lang "ts" parses all JS (a TS superset) but NOT JSX — JSX-in-.js is out of scope.
919
+ const info = detectModuleInfo(filePath, source, "ts");
920
+ if (!info.transformableSyntax && !info.hasDecorators) {
921
+ return null; // no-op: Node's native loader handles it, byte-identical.
922
+ }
923
+ // 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.
926
+ try {
927
+ return loadTranspile(url, ext);
928
+ } catch (err) {
929
+ // #225: a plain-JS file the transformable verdict flagged (a `using` decl or
930
+ // `v`-flag RegExp somewhere) but whose transform oxc then REJECTS — V8 tolerates
931
+ // constructs oxc's stricter ES grammar forbids, e.g. `set x(v = []) {}` in pnpm
932
+ // 11.x's bundled `pnpm.mjs`. Falling back to `null` hands the file to Node's
933
+ // native loader running the ORIGINAL source: a V8-tolerated file runs, and a
934
+ // GENUINELY broken one still surfaces V8's own SyntaxError at the same spot Node
935
+ // would — so no real error is masked. Plain-JS only (the `.ts`/`.tsx`/`.jsx` path
936
+ // keeps hard-erroring, since those MUST transpile to run); the cost is that
937
+ // down-leveling is forfeited for THIS one file. The Stage-3 decorator diagnostic
938
+ // is a deliberate nub error and must not be swallowed — a decorator file can't
939
+ // run on V8 raw regardless, so re-throw nub's guidance.
940
+ if (info.hasDecorators) throw err;
941
+ return null;
942
+ }
943
+ }
944
+
945
+ // ── Data-format imports ─────────────────────────────────────────────
946
+ function lazyRequire(pkg) {
947
+ try { return __require(pkg); } catch {
948
+ throw new Error(`Nub: importing this file requires the "${pkg}" package.\nInstall it: npm install ${pkg}`);
949
+ }
950
+ }
951
+
952
+ function stripJsonComments(text) {
953
+ let result = "", i = 0, inString = false, escape = false;
954
+ while (i < text.length) {
955
+ const ch = text[i];
956
+ if (escape) { result += ch; escape = false; i++; continue; }
957
+ if (inString) { if (ch === "\\") escape = true; if (ch === '"') inString = false; result += ch; i++; continue; }
958
+ if (ch === '"') { inString = true; result += ch; i++; continue; }
959
+ if (ch === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; continue; }
960
+ if (ch === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; }
961
+ result += ch; i++;
962
+ }
963
+ return result;
964
+ }
965
+
966
+ /// Every data extension either tier may serve — the built-ins plus whatever this
967
+ /// project's `loader` added. The classic `require.extensions` shim registers from
968
+ /// this so the CJS path covers exactly what the ESM path does; `dataExtsFor` still
969
+ /// decides per-URL which of them is live inside `node_modules`.
970
+ export function allDataExts() {
971
+ return new Set([...Object.keys(BUILTIN_DATA_EXTS), ...Object.keys(PROJECT_DATA_EXTS)]);
972
+ }
973
+
974
+ /// The value a data module exposes as its default export. Split out of
975
+ /// [`loadData`] so the classic `require()` handler resolves a file through the
976
+ /// SAME parser dispatch and the same `dataExtsFor` node_modules pinning — two
977
+ /// tiers cannot disagree about what a document means if only one function reads it.
978
+ export function dataValue(url, ext) {
979
+ const raw = readFileSync(fileURLToPath(url), "utf8");
980
+ const kind = dataExtsFor(url)[ext];
981
+ if (kind === "txt") return raw;
982
+
983
+ if (nubNative) {
984
+ if (kind === "yaml") return nubNative.parseYaml(raw);
985
+ if (kind === "toml") return nubNative.parseToml(raw);
986
+ if (kind === "json5") return nubNative.parseJson5(raw);
987
+ if (kind === "jsonc") return nubNative.parseJsonc(raw);
988
+ } else {
989
+ if (kind === "yaml") return lazyRequire("yaml").parse(raw);
990
+ if (kind === "toml") return lazyRequire("@iarna/toml").parse(raw);
991
+ if (kind === "json5") return lazyRequire("json5").parse(raw);
992
+ if (kind === "jsonc") return JSON.parse(stripJsonComments(raw));
993
+ }
994
+ return undefined;
995
+ }
996
+
997
+ export function loadData(url, ext) {
998
+ const parsed = dataValue(url, ext);
999
+
1000
+ if (parsed == null) {
1001
+ return { format: "module", source: "export default undefined;\n", shortCircuit: true };
1002
+ }
1003
+
1004
+ // Default export only. Data modules deliberately do NOT emit per-key named
1005
+ // exports: named imports of data are categorically un-typeable in TypeScript
1006
+ // (a `declare module "*.yaml"` wildcard has no per-key export index signature),
1007
+ // and default-only matches Node's own JSON modules. Consumers destructure the
1008
+ // default — `import cfg from "./c.yaml"; const { host } = cfg;` — which the
1009
+ // `@nubjs/types` `Record<string, unknown>` default type makes sound.
1010
+ const code = `export default ${JSON.stringify(parsed)};\n`;
1011
+ return { format: "module", source: code, shortCircuit: true };
1012
+ }
1013
+
1014
+ // Import Text: `import s from "./any.file" with { type: "text" }` → the raw file
1015
+ // contents as a default-export string, on ANY extension. This is nub's own
1016
+ // implementation of the import-attribute text feature Node standardized upstream
1017
+ // (translators.js textStrategy); it is attribute-KEYED (the load hooks call this
1018
+ // when `context.importAttributes?.type === "text"`), orthogonal to the
1019
+ // EXTENSION-keyed `.txt` data loader above. Semantics match Node's textStrategy:
1020
+ // decode via TextDecoder (UTF-8, strips a leading BOM — unlike readFileSync utf8,
1021
+ // which keeps it) and expose ONLY a `default` export (a named import errors, as on
1022
+ // Node). shortCircuit so this fully owns the module and Node's own
1023
+ // unknown-'text'-attribute validation never runs.
1024
+ const __textDecoder = new TextDecoder();
1025
+ export function loadTextImport(url) {
1026
+ const text = __textDecoder.decode(readFileSync(fileURLToPath(url)));
1027
+ return { format: "module", source: `export default ${JSON.stringify(text)};\n`, shortCircuit: true };
1028
+ }