@nubjs/nub-win32-x64 0.0.15 → 0.0.16

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/bin/nub.exe CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubjs/nub-win32-x64",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "Nub binary for win32-x64",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/nub-js/nub",
Binary file
@@ -20,25 +20,30 @@
20
20
  // no top-level hook registration here — importing this module never augments the
21
21
  // realm; the tier files do that.
22
22
 
23
- // EVERY dependency of this module is pulled in via CJS `require()` (below), NOT
24
- // via static ESM `import`. This is load-bearing for loader compatibility (R11):
25
- // nub loads transform-core through `require(esm)`, and Node's `require(esm)`
26
- // instantiates the module by walking its STATIC IMPORT graph through whatever ESM
27
- // loader hooks are registered — including the USER's `--loader`/`register()`
28
- // chain. Static `import get-tsconfig`/`./version.mjs`/`node:*` here therefore
29
- // leaked nub's entire internal graph (transform-core, version.mjs, get-tsconfig,
30
- // their transitive node_modules deps, and the node: builtins) THROUGH the user's
31
- // resolve/load hooks, which observed and corrupted it (a user load hook returning
32
- // `source: 1` for version.mjs, a strict loader throwing on a bare specifier — see
33
- // test-esm-loader-chaining, -example-loader, -preserve-symlinks-not-found,
34
- // test-shadow-realm-custom-loaders). Verified: a CJS `require()` of a
35
- // package/builtin does NOT route through the ESM loader chain, so loading the graph
36
- // this way bypasses the user chain entirely. (The transpiler and TS/JSX detection
37
- // no longer go through any npm package at all: they are native calls into nub's own
38
- // N-API addon, so the worst historical leak pulling oxc-transform's ESM entry
39
- // graph through the user chain is gone by construction.) `process
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
40
44
  // .getBuiltinModule` fetches node: builtins synchronously off the loader chain;
41
- // `createRequire(import.meta.url)` resolves the bare deps from nub's distribution.
45
+ // `createRequire(import.meta.url)` resolves the (now CommonJS-only) vendored
46
+ // polyfills + the `@oxc-project/runtime` helpers from nub's distribution.
42
47
  // This file keeps its `export`s (it stays an ES module), but has ZERO static
43
48
  // imports, so `require(esm)` finds no dependency graph to route through the user.
44
49
  // `process.getBuiltinModule` (Node 22.3 / backported to 20.16 / 18.20.4) fetches a
@@ -59,51 +64,31 @@ const { createRequire } = __getBuiltin("node:module");
59
64
  const __require = createRequire(import.meta.url);
60
65
 
61
66
  const module = __getBuiltin("node:module");
62
- const { readFileSync, writeFileSync, mkdirSync, statSync, renameSync, unlinkSync, readdirSync } = __getBuiltin("node:fs");
67
+ const { readFileSync, writeFileSync, mkdirSync, statSync } = __getBuiltin("node:fs");
63
68
  const { fileURLToPath, pathToFileURL } = __getBuiltin("node:url");
64
- const { join, dirname, resolve: pathResolve, extname: pathExtname } = __getBuiltin("node:path");
69
+ const { join, dirname } = __getBuiltin("node:path");
65
70
  // Nub's N-API addon — the in-process TS/JSX transpiler (`transform`,
66
- // `detectModuleInfo`) AND the data-format parsers (`parseYaml`/`parseToml`/
67
- // `parseJson5`/`parseJsonc`), all native. Loaded once per module instance (= once
68
- // per thread: the main thread and the loader worker each import this module
69
- // separately). It is a `.node` binary resolved by absolute path off this file's
70
- // dir, so it never touches the ESM loader chain the historical
71
- // require(esm)-of-an-ESM-npm-package leak (oxc-transform) is gone: transpilation
72
- // is a synchronous native call, no JS package, no static-import graph to route.
71
+ // `transformCached`, `detectModuleInfo`), the tsconfig reader + additive
72
+ // TS-resolver (`loadTsconfig`, `resolveTs`), AND the data-format parsers
73
+ // (`parseYaml`/`parseToml`/`parseJson5`/`parseJsonc`), all native. Loaded once
74
+ // per module instance (= once per thread: the main thread and the loader worker
75
+ // each import this module separately). It is a `.node` binary resolved by absolute
76
+ // path off this file's dir, so it never touches the ESM loader chain the
77
+ // historical require(esm)-of-an-ESM-npm-package leak (oxc-transform, and before
78
+ // this migration get-tsconfig) is gone: transpilation, tsconfig discovery, the
79
+ // additive resolution, and the transpile cache are synchronous native calls, no JS
80
+ // package, no static-import graph to route. nub now loads ZERO npm packages
81
+ // internally, so the user ESM loader chain can never observe a nub dependency.
73
82
  let nubNative = null;
74
83
  for (const rel of ["./addons/nub-native.node", "../runtime/addons/nub-native.node"]) {
75
84
  try { nubNative = __require(fileURLToPath(new URL(rel, import.meta.url))); break; } catch {}
76
85
  }
77
- // get-tsconfig is `type: module` but ships a CJS `require` export
78
- // (./dist/index.cjs), so `require()` of it loads the CommonJS build no
79
- // require(esm), no ESM-loader-chain routing.
80
- const { getTsconfig, createPathsMatcher } = __require("get-tsconfig");
81
-
82
- // NUB_VERSION is the single source of truth in runtime/version.mjs. We must NOT
83
- // `import` it (that would route version.mjs through the user loader chain — see
84
- // above; a user load hook returning bogus source corrupts it), and we cannot
85
- // `require()` it either (it is an ES module, so `require()` uses require(esm),
86
- // which re-routes version.mjs's own load through the chain). Instead read its
87
- // text directly and extract the literal — `make version` keeps the assignment on
88
- // one line (`export const NUB_VERSION = "x.y.z";`), so a tight regex is stable.
89
- const NUB_VERSION = (() => {
90
- try {
91
- const text = readFileSync(fileURLToPath(new URL("./version.mjs", import.meta.url)), "utf8");
92
- const m = text.match(/NUB_VERSION\s*=\s*["']([^"']+)["']/);
93
- if (m) return m[1];
94
- } catch {}
95
- return "0.0.0";
96
- })();
97
-
98
- // `node:crypto` is used ONLY to hash the transpile-cache key, so it loads lazily
99
- // on first transpile rather than at module top level. Importing it eagerly pulls
100
- // in the crypto/tls native tree (~dozens of builtins) on EVERY startup — including
101
- // a plain-JS run that never transpiles anything (R7). The first `.ts` transpile
102
- // pays the one-time require; a no-TS run never touches it. Memoized.
103
- let _createHash = null;
104
- function getCreateHash() {
105
- return (_createHash ??= __require("node:crypto").createHash);
106
- }
86
+
87
+ // NOTE: the transpile-cache version component is no longer read here. nub's
88
+ // version is baked into the native addon at compile time (`env!("CARGO_PKG_VERSION")`
89
+ // in nub-native's cache.rs), which `make version` keeps in lockstep with
90
+ // runtime/version.mjs and Cargo.toml — so the cache key's version component lives
91
+ // natively now, and this file no longer needs to read version.mjs.
107
92
 
108
93
  // ── Constants ───────────────────────────────────────────────────────
109
94
  export const TRANSPILE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".jsx"]);
@@ -159,15 +144,24 @@ export function setWatchHooks({ reportDep, reportEnvDir } = {}) {
159
144
  }
160
145
 
161
146
  // ── tsconfig + package-type caches ──────────────────────────────────
147
+ // tsconfig discovery / parse / `extends` resolution + the `paths` matcher all
148
+ // happen natively (nub-native `loadTsconfig`, the get-tsconfig@4.14.0 port). This
149
+ // JS wrapper exists only to (a) memoize per importer-dir — native ALSO memoizes,
150
+ // but a JS-side Map skips the napi boundary on a hit and lets watch-mode report
151
+ // the dep exactly once per dir — and (b) surface the resolved tsconfig path to the
152
+ // watch FilesWatcher. The returned shape exposes the transform-relevant
153
+ // `compilerOptions` slice and the `tsconfigHash` cache-key component; the `paths`
154
+ // matcher lives entirely in native (`resolveTs` runs it), so there is no JS matcher.
162
155
  const tsconfigCache = new Map();
163
156
  export function getTsconfigForDir(dir) {
164
157
  if (tsconfigCache.has(dir)) return tsconfigCache.get(dir);
165
- const result = getTsconfig(dir);
166
- const matcher = result ? createPathsMatcher(result) : null;
167
- const entry = { tsconfig: result, matcher };
168
- tsconfigCache.set(dir, entry);
169
- if (result?.path) _reportDep?.(result.path);
170
- return entry;
158
+ // { path: string|null, compilerOptions: object|null, tsconfigHash: string }
159
+ const result = nubNative
160
+ ? nubNative.loadTsconfig(dir)
161
+ : { path: null, compilerOptions: null, tsconfigHash: "" };
162
+ tsconfigCache.set(dir, result);
163
+ if (result.path) _reportDep?.(result.path);
164
+ return result;
171
165
  }
172
166
 
173
167
  // The NEAREST package.json's `type` decides the format of ambiguous extensions
@@ -214,11 +208,6 @@ export function fileExists(filePath) {
214
208
  return s !== undefined && s.isFile();
215
209
  }
216
210
 
217
- export function dirExists(filePath) {
218
- const s = statSync(filePath, { throwIfNoEntry: false });
219
- return s !== undefined && s.isDirectory();
220
- }
221
-
222
211
  function safeRequireResolve(specifier) {
223
212
  try { return __require.resolve(specifier); } catch { return null; }
224
213
  }
@@ -230,104 +219,26 @@ export function barePkg(specifier) {
230
219
  }
231
220
 
232
221
  // ── Resolution ──────────────────────────────────────────────────────
233
- // Read a directory's package.json `main` (its legacy CJS entry point), or null.
234
- // `exports` is deliberately NOT consulted: Node honors `exports` only for
235
- // package-name/self-reference resolution, never for a relative/absolute import
236
- // of a directory path (verified against Node 24 — a relative dir import with
237
- // `exports` but no `main` falls through to index, not the export). So matching
238
- // Node here means `main` only.
239
- function readPackageMain(dir) {
240
- const pkgPath = join(dir, "package.json");
241
- if (!fileExists(pkgPath)) return null;
222
+ // The ADDITIVE TS resolution — tsconfig `paths` aliases, `.ts/.tsx/.mts/.cts/.jsx`
223
+ // extension probing, the `.js`→`.ts` (and `.jsx→.tsx`, `.mjs→.mts`, `.cjs→.cts`)
224
+ // emit-convention swap, directory-index probing, and reading a directory's
225
+ // `package.json#main` all happens natively now (nub-native `resolveTs`). It
226
+ // returns an absolute path for the additive cases nub owns, or `null` for
227
+ // EVERYTHING Node owns (node_modules, `exports`/`imports`, conditions, scoped/bare
228
+ // specifiers), which the resolve hooks below turn into a fall-through to Node. That
229
+ // `null` is the byte-for-byte compat boundary; reimplementing Node's resolution in
230
+ // nub is forbidden. The `node:`/`data:`/builtin guards, the nub-internal-graph
231
+ // bypass, vendored packages, and the clobber map all stay in JS and run BEFORE the
232
+ // native resolver (see resolveSpec / resolveCjsPath).
233
+ function resolveTs(specifier, parentPath) {
234
+ if (!nubNative) return null;
242
235
  try {
243
- const main = JSON.parse(readFileSync(pkgPath, "utf8")).main;
244
- return typeof main === "string" && main.trim() ? main : null;
236
+ return nubNative.resolveTs(specifier, parentPath || "");
245
237
  } catch {
246
238
  return null;
247
239
  }
248
240
  }
249
241
 
250
- // Try to resolve a file path with extensionless probing + .js→.ts swap.
251
- // `allowDirMain` honors a resolved directory's package.json `main` before its
252
- // index; it is cleared on the recursive main-target probe because Node's
253
- // LOAD_AS_DIRECTORY resolves `main` with file+index probing only and does not
254
- // recurse into the target's own nested `main` (verified against Node 24).
255
- export function tryResolveFile(target, parentExt, allowDirMain = true) {
256
- // If the target already has an extension and exists, use it.
257
- const existingExt = pathExtname(target);
258
- if (existingExt && fileExists(target)) return target;
259
-
260
- // .js → .ts swap (tsc emit convention reversal).
261
- if (existingExt === ".js") {
262
- const tsSwap = target.slice(0, -3) + ".ts";
263
- if (fileExists(tsSwap)) return tsSwap;
264
- const tsxSwap = target.slice(0, -3) + ".tsx";
265
- if (fileExists(tsxSwap)) return tsxSwap;
266
- }
267
- if (existingExt === ".jsx") {
268
- const tsxSwap = target.slice(0, -4) + ".tsx";
269
- if (fileExists(tsxSwap)) return tsxSwap;
270
- }
271
- // .mjs → .mts swap (Bun does this).
272
- if (existingExt === ".mjs") {
273
- const mtsSwap = target.slice(0, -4) + ".mts";
274
- if (fileExists(mtsSwap)) return mtsSwap;
275
- }
276
- // .cjs → .cts swap — the CommonJS analog of .mjs→.mts. tsc resolves
277
- // `import "./foo.cjs"` to foo.cts (it strips the .cjs and finds the .cts
278
- // source — verified via --traceResolution), so a TS file using the emitted
279
- // extension to reference a .cts source must resolve at runtime. (Bun omits
280
- // this swap even though it does .mjs→.mts; we match tsc, not that gap.)
281
- if (existingExt === ".cjs") {
282
- const ctsSwap = target.slice(0, -4) + ".cts";
283
- if (fileExists(ctsSwap)) return ctsSwap;
284
- }
285
-
286
- // Extensionless: probe in parent-ext-aware order.
287
- if (!existingExt) {
288
- const probeOrder = getProbeOrder(parentExt);
289
- for (const ext of probeOrder) {
290
- if (fileExists(target + ext)) return target + ext;
291
- }
292
- // Directory: honor package.json `main` (Node's legacy LOAD_AS_DIRECTORY)
293
- // before falling back to index probing. The main target is resolved with
294
- // the same extensionless/TS-swap probing (so a TS package can point `main`
295
- // at a `.ts`, or `.js`→`.ts` swaps apply), but without re-reading a nested
296
- // `main` — matching Node. If `main` is absent or unresolvable, index wins
297
- // (Node falls back to index too, with a DEP0128 warning we needn't emit).
298
- if (dirExists(target)) {
299
- if (allowDirMain) {
300
- const main = readPackageMain(target);
301
- if (main) {
302
- const resolved = tryResolveFile(pathResolve(target, main), parentExt, false);
303
- if (resolved) return resolved;
304
- }
305
- }
306
- for (const ext of probeOrder) {
307
- const idx = join(target, "index" + ext);
308
- if (fileExists(idx)) return idx;
309
- }
310
- }
311
- }
312
-
313
- return null;
314
- }
315
-
316
- export function getProbeOrder(parentExt) {
317
- switch (parentExt) {
318
- case ".tsx": return [".tsx", ".ts", ".jsx", ".js", ".json"];
319
- // .mts/.cts prefer their own module system first, but STILL fall through to
320
- // the general TS (`.ts`) and JS extensions: tsc and Node resolve an
321
- // extensionless `./foo` from a .mts/.cts parent to foo.ts / foo.js too, not
322
- // only foo.mts / foo.cts. Omitting `.ts` here is what made `require('./config')`
323
- // — and a tsconfig-paths alias — from a .cts (or .mts) parent miss a `.ts`
324
- // target (works from .js/.cjs, which use the default order below).
325
- case ".mts": return [".mts", ".ts", ".mjs", ".js", ".json"];
326
- case ".cts": return [".cts", ".ts", ".cjs", ".js", ".json"];
327
- default: return [".ts", ".tsx", ".js", ".jsx", ".json"];
328
- }
329
- }
330
-
331
242
  // nub's own runtime directory (this file's dir, as a file: URL prefix). Any
332
243
  // resolution whose IMPORTER lives here is one of nub's internal requires — the
333
244
  // preload loading transform-core, the Temporal lazy getter resolving
@@ -340,57 +251,20 @@ export function getProbeOrder(parentExt) {
340
251
  // resolution for these.
341
252
  const RUNTIME_DIR_URL = new URL(".", import.meta.url).href;
342
253
 
343
- // nub's internal-graph package roots the file: URL prefixes of the npm packages
344
- // nub itself loads (get-tsconfig) and their transitive deps. Any resolution whose
345
- // IMPORTER lives under one of these is part of nub's internal graph, NOT user code,
346
- // and must FULLY short-circuit (resolve natively, return shortCircuit:true) — never
347
- // delegate to nextResolve for EVERY specifier, including node: builtins and the
348
- // package's own relative imports, so the user ESM loader chain never observes nub's
349
- // internals (R11). get-tsconfig is loaded as CJS (its `require` export resolves to
350
- // ./dist/index.cjs), so its graph already bypasses the chain; this short-circuit is
351
- // the belt-and-suspenders for any ESM hop into a nub-internal package. The biggest
352
- // historical leak oxc-transform's `type: module` entry pulled through require(esm)
353
- // and walked through the user loader — is gone: the transpiler is now a native
354
- // addon call, no npm package. Computed lazily (and pinned even on resolve failure)
355
- // so a missing dep can't wedge startup.
356
- let _nubGraphRoots = null;
357
- function nubGraphRoots() {
358
- if (_nubGraphRoots) return _nubGraphRoots;
359
- const roots = [];
360
- for (const pkg of ["get-tsconfig"]) {
361
- try {
362
- const entry = __require.resolve(pkg);
363
- // Package root = the directory two levels up does not work generically;
364
- // instead key on the package-name segment: everything under
365
- // `.../node_modules/<pkg>/` is that package. Use the entry's dir-with-pkg.
366
- const idx = entry.lastIndexOf(`${sep()}node_modules${sep()}`);
367
- if (idx !== -1) {
368
- // Keep through the package-name segment (handles scoped names too).
369
- const afterNM = entry.slice(idx + (`${sep()}node_modules${sep()}`).length);
370
- const firstSeg = afterNM.startsWith("@")
371
- ? afterNM.split(sep()).slice(0, 2).join(sep())
372
- : afterNM.split(sep())[0];
373
- const pkgRoot = entry.slice(0, idx) + `${sep()}node_modules${sep()}` + firstSeg + sep();
374
- roots.push(pathToFileURL(pkgRoot).href);
375
- }
376
- } catch {}
377
- }
378
- return (_nubGraphRoots = roots);
379
- }
380
- function sep() {
381
- return process.platform === "win32" ? "\\" : "/";
382
- }
383
-
384
- // Is this importer part of nub's own internal module graph (runtime dir or a nub
385
- // dependency package)? Such imports must bypass the user ESM loader chain entirely.
254
+ // Is this importer part of nub's own internal module graph? Such imports must
255
+ // bypass the user ESM loader chain entirely (R11). nub now loads ZERO npm packages
256
+ // internally tsconfig, the additive resolver, the transpile cache, the
257
+ // transpiler, and module detection are ALL native nub-native calls, and the only
258
+ // remaining JS deps (@oxc-project/runtime helpers, the polyfills) are CommonJS,
259
+ // whose `require()` graph already bypasses the ESM loader chain by construction. So
260
+ // the only nub-internal ESM importer left is nub's own runtime directory (this
261
+ // file, the preload tiers, the Temporal lazy getter resolving @js-temporal/
262
+ // polyfill). The historical "nub-dependency package roots" walk which existed
263
+ // solely to catch an ESM hop into get-tsconfig (and before that oxc-transform) is
264
+ // gone with those packages.
386
265
  function isNubInternalParent(parentURL) {
387
266
  if (!parentURL) return false;
388
- const p = String(parentURL);
389
- if (p.startsWith(RUNTIME_DIR_URL)) return true;
390
- for (const root of nubGraphRoots()) {
391
- if (p.startsWith(root)) return true;
392
- }
393
- return false;
267
+ return String(parentURL).startsWith(RUNTIME_DIR_URL);
394
268
  }
395
269
 
396
270
  // Resolve a specifier the way both hook tiers do. Returns `{ url, shortCircuit }`
@@ -403,7 +277,7 @@ export function resolveSpec(specifier, parentURL) {
403
277
  // node:/data:/builtin early-returns below, because those `return null` =
404
278
  // DELEGATE to the user loader — and a nub-internal `import "node:module"` (e.g.
405
279
  // from a nub-dependency ESM entry) delegated to a strict user loader is exactly
406
- // the R11 leak. See isNubInternalParent / nubGraphRoots.
280
+ // the R11 leak. See isNubInternalParent.
407
281
  if (isNubInternalParent(parentURL)) {
408
282
  if (specifier.startsWith("node:") || module.builtinModules.includes(specifier)) {
409
283
  const url = specifier.startsWith("node:") ? specifier : `node:${specifier}`;
@@ -446,30 +320,16 @@ export function resolveSpec(specifier, parentURL) {
446
320
  }
447
321
 
448
322
  const parent = String(parentURL || "");
449
- const parentExt = extname(parent);
450
-
451
- // 4. tsconfig-paths (only for bare/aliased specifiers, not relative).
452
- if (!specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("file:") && !isNodeModules(parent)) {
453
- const parentDir = parent.startsWith("file:") ? dirname(fileURLToPath(parent)) : process.cwd();
454
- const { matcher } = getTsconfigForDir(parentDir);
455
- if (matcher) {
456
- const mapped = matcher(specifier);
457
- if (mapped && mapped.length > 0) {
458
- for (const candidate of mapped) {
459
- const resolved = tryResolveFile(candidate, parentExt);
460
- if (resolved) return { url: pathToFileURL(resolved).href, shortCircuit: true };
461
- }
462
- }
463
- }
464
- }
465
323
 
466
- // 5. Extensionless probing (only when parent is a TS file).
467
- if (TS_PARENT_EXTS.has(parentExt) && (specifier.startsWith("./") || specifier.startsWith("../"))) {
468
- const parentDir = dirname(fileURLToPath(parent));
469
- const target = pathResolve(parentDir, specifier);
470
- const resolved = tryResolveFile(target, parentExt);
471
- if (resolved) return { url: pathToFileURL(resolved).href, shortCircuit: true };
472
- }
324
+ // 4. The ADDITIVE TS resolution (tsconfig `paths`, extension probing, `.js`→`.ts`
325
+ // swap, directory index/`main`) native. `resolveTs` is handed the parent's
326
+ // absolute FS path (or "" for a non-file: parent / the entry, where it falls back
327
+ // to cwd, matching the old `process.cwd()` parentDir). A non-null result is an
328
+ // additive hit nub owns; null falls through to Node's resolver (the compat
329
+ // boundary node_modules, `exports`, bare/scoped specifiers stay Node's).
330
+ const parentPath = parent.startsWith("file:") ? fileURLToPath(parent) : "";
331
+ const resolved = resolveTs(specifier, parentPath);
332
+ if (resolved) return { url: pathToFileURL(resolved).href, shortCircuit: true };
473
333
 
474
334
  return null;
475
335
  }
@@ -486,36 +346,11 @@ export function resolveCjsPath(request, parentPath) {
486
346
  module.builtinModules.includes(request)) {
487
347
  return null;
488
348
  }
489
- const parentExt = parentPath ? pathExtname(parentPath) : "";
490
-
491
- // tsconfig `paths` bare/aliased specifiers from a file outside node_modules
492
- // (not gated on a TS parent: a plain .js with a paths alias resolves too).
493
- if (!request.startsWith(".") && !request.startsWith("/") && !request.startsWith("file:") &&
494
- !isNodeModules(parentPath || "")) {
495
- const parentDir = parentPath ? dirname(parentPath) : process.cwd();
496
- const { matcher } = getTsconfigForDir(parentDir);
497
- if (matcher) {
498
- const mapped = matcher(request);
499
- if (mapped && mapped.length > 0) {
500
- for (const candidate of mapped) {
501
- const resolved = tryResolveFile(candidate, parentExt);
502
- if (resolved) return resolved;
503
- }
504
- }
505
- }
506
- return null; // a plain bare package → let Node resolve it from node_modules
507
- }
508
-
509
- // Extensionless probing + .js→.ts swap for a relative specifier — only when the
510
- // requiring file is itself TS (same TS_PARENT_EXTS gate as resolveSpec step 5).
511
- if (parentPath && TS_PARENT_EXTS.has(parentExt) &&
512
- (request.startsWith("./") || request.startsWith("../"))) {
513
- const target = pathResolve(dirname(parentPath), request);
514
- const resolved = tryResolveFile(target, parentExt);
515
- if (resolved) return resolved;
516
- }
517
-
518
- return null;
349
+ // The SAME native additive resolver as resolveSpec, returning an absolute path
350
+ // (not a URL). Vendored/clobber/builtin are import-only and never reach here. A
351
+ // null result (node_modules / `exports` / a plain bare package) falls through to
352
+ // Node's CJS resolver the compat boundary.
353
+ return resolveTs(request, parentPath || "");
519
354
  }
520
355
 
521
356
  // Would `require()`-ing this resolved TS file need Node's require(esm)? An
@@ -602,23 +437,17 @@ function hasDecoratorSyntax(filePath, source, lang) {
602
437
  return detectModuleInfo(filePath, source, lang).hasDecorators;
603
438
  }
604
439
 
605
- // Drop a trailing bare `export {};` — oxc injects it to preserve module-ness
606
- // after stripping a file's only module syntax (e.g. a lone `import type`).
607
- const EMPTY_EXPORT_MARKER = /(?:^|\n)[ \t]*export[ \t]*\{[ \t]*\}[ \t]*;?\s*$/;
608
- function stripEmptyExportMarker(code) {
609
- return code.replace(EMPTY_EXPORT_MARKER, "");
610
- }
611
-
612
440
  // ── Transpile cache ─────────────────────────────────────────────────
613
- // NUB_VERSION (from version.mjs) is the SOLE version component of the cache key:
614
- // the transpiler is nub's own native addon, compiled per release against a pinned
615
- // oxc, so any emit change ships only in a new nub version, which `make version`
616
- // bumps (and which rebuilds the addon). CACHE_SCHEMA busts the
617
- // cache when the on-disk ENTRY FORMAT changes (v3 = integrity prefix + leading
618
- // format byte). The fast and compat tiers share this cache: post-extraction they
619
- // emit byte-identical output for the same (source, ext, tsconfig, pkgType), so a
620
- // single cache under one key is correct and maximizes hits.
621
- const CACHE_SCHEMA = "3";
441
+ // The transpile cache `cacheGet` + transform-on-miss + post-processing
442
+ // (CJS empty-export strip, inline sourceMap, `//# sourceURL=`) + `cacheSet` is
443
+ // ONE native call now (nub-native `transformCached`): the cache key (NUB_VERSION
444
+ // is the sole version component a new release ships any emit change + a rebuilt
445
+ // addon), the 16-hex integrity prefix, the `c`/`m` format byte, and the atomic
446
+ // `*.tmp`-then-rename write all live in Rust, byte-identical to the old JS cache so
447
+ // warm caches survive. This JS file keeps only (a) the cache enable/disable signal
448
+ // and (b) the cache directory it passes IN, so the policy stays in JS and native
449
+ // just does the I/O against the dir nub hands it.
450
+ //
622
451
  // Disable the transpile cache when (a) the permission model is active (writing a
623
452
  // cache file may not be granted), or (b) the user set `NODE_COMPILE_CACHE=0` —
624
453
  // Node's compile-cache disable signal, which nub honors as "no caching in this
@@ -635,50 +464,6 @@ if (!CACHE_DISABLED) {
635
464
  }
636
465
  }
637
466
 
638
- function cacheKey(source) {
639
- return getCreateHash()("sha256")
640
- .update(NUB_VERSION).update("\0")
641
- .update(CACHE_SCHEMA).update("\0")
642
- .update(source)
643
- .digest("hex");
644
- }
645
- // Each entry is stored as `<16-hex integrity prefix><body>`, where the prefix is
646
- // the first 8 bytes of sha256(body). cacheGet re-checks it and treats ANY
647
- // mismatch — truncation, on-disk corruption, bit-rot, external edits — as a miss,
648
- // so the entry is re-transpiled and overwritten (self-heals) instead of feeding
649
- // garbage to V8.
650
- const CACHE_INTEGRITY_LEN = 16;
651
- function cacheIntegrity(body) {
652
- return getCreateHash()("sha256").update(body).digest("hex").slice(0, CACHE_INTEGRITY_LEN);
653
- }
654
- function cacheGet(key) {
655
- if (!cacheDir) return null;
656
- let raw;
657
- try {
658
- raw = readFileSync(join(cacheDir, key), "utf8");
659
- } catch {
660
- return null;
661
- }
662
- if (raw.length < CACHE_INTEGRITY_LEN) return null;
663
- const body = raw.slice(CACHE_INTEGRITY_LEN);
664
- if (raw.slice(0, CACHE_INTEGRITY_LEN) !== cacheIntegrity(body)) return null;
665
- return body;
666
- }
667
- let cacheTmpCounter = 0;
668
- function cacheSet(key, value) {
669
- if (!cacheDir) return;
670
- const finalPath = join(cacheDir, key);
671
- // Atomic write: temp file in the same dir, then rename (atomic on POSIX +
672
- // Windows same-volume), so a concurrent reader sees old-or-complete, never torn.
673
- const tmpPath = `${finalPath}.${process.pid}.${cacheTmpCounter++}.tmp`;
674
- try {
675
- writeFileSync(tmpPath, cacheIntegrity(value) + value);
676
- renameSync(tmpPath, finalPath);
677
- } catch {
678
- try { unlinkSync(tmpPath); } catch {}
679
- }
680
- }
681
-
682
467
  // ── Bounded-cache maintenance ───────────────────────────────────────
683
468
  const CACHE_MAX_BYTES = 512 * 1024 * 1024; // 512 MiB — bounds runaway growth, not normal use
684
469
  const SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000; // ≤ one sweep per day
@@ -713,25 +498,14 @@ export function loadTranspile(url, ext) {
713
498
  const filePath = fileURLToPath(url);
714
499
  const source = readFileSync(filePath, "utf8");
715
500
  const dir = dirname(filePath);
716
- const { tsconfig } = getTsconfigForDir(dir);
717
- const co = tsconfig?.config?.compilerOptions;
501
+ // The transform-relevant compilerOptions slice + the byte-for-byte cache-key
502
+ // component (`tsconfigHash`) both come from the native tsconfig reader.
503
+ const { compilerOptions: co, tsconfigHash } = getTsconfigForDir(dir);
718
504
 
719
- // Cache key folds in ext, the resolved tsconfig, and the nearest package.json
720
- // type the same source can transpile to a different format under a different
721
- // type. The cached entry's leading byte ('c'/'m') records the chosen format,
722
- // so a hit needs no re-detection.
505
+ // The nearest package.json `type` decides the format of an ambiguous extension
506
+ // (.ts/.tsx/.jsx); .mts/.cts are explicit so its lookup is skipped. The chosen
507
+ // format is folded into the cache key (and the entry's leading byte) by native.
723
508
  const pkgType = ext === ".mts" || ext === ".cts" ? undefined : getPackageType(dir);
724
- const tsconfigHash = co ? JSON.stringify(co) : "";
725
- const key = cacheKey(source + "\0" + ext + "\0" + tsconfigHash + "\0" + (pkgType || ""));
726
- const cached = cacheGet(key);
727
- if (cached) {
728
- return {
729
- format: cached[0] === "c" ? "commonjs" : "module",
730
- source: cached.slice(1),
731
- shortCircuit: true,
732
- };
733
- }
734
-
735
509
  const format = moduleFormatFor(ext, pkgType, filePath, source);
736
510
 
737
511
  const lang = ext === ".tsx" ? "tsx" : ext === ".jsx" ? "jsx" : "ts";
@@ -766,39 +540,28 @@ export function loadTranspile(url, ext) {
766
540
  // Stage-3 decorators: oxc returns errors:[] and emits the `@decorator` syntax
767
541
  // verbatim, so the result-error check below never fires and V8 throws a bare
768
542
  // SyntaxError. When legacy mode is off and decorator syntax is present, reject
769
- // with the documented Option-A diagnostic instead.
543
+ // with the documented Option-A diagnostic instead. (Cheap `source.includes("@")`
544
+ // pre-filter keeps decorator-free files off the native parser; runs BEFORE the
545
+ // cache so the diagnostic surfaces even on what would be a warm hit.)
770
546
  if (co?.experimentalDecorators !== true && source.includes("@") &&
771
547
  hasDecoratorSyntax(filePath, source, lang)) {
772
548
  throw stage3DecoratorError(filePath);
773
549
  }
774
550
 
775
- const result = nubNative.transform(filePath, source, opts);
551
+ // cacheGet + transform-on-miss + post-process (CJS empty-export strip, inline
552
+ // sourceMap, sourceURL append) + cacheSet — ALL native, byte-identical on-disk.
553
+ // The cache key folds in ext + tsconfigHash + pkgType (same source, different
554
+ // type → different format → distinct entry). `cacheDir: null/undefined` is the
555
+ // JS enable/disable signal: native then skips all cache I/O and just transforms.
556
+ const formatByte = format === "commonjs" ? "c" : "m";
557
+ const result = nubNative.transformCached(
558
+ filePath, source, opts, ext, tsconfigHash || "", pkgType || "", formatByte, cacheDir ?? undefined,
559
+ );
776
560
  if (result.errors.length > 0) {
777
561
  const details = result.errors.map((e) => e.codeframe || e.message).join("\n\n");
778
562
  throw new Error(`Transpile error in ${filePath}:\n${details}`);
779
563
  }
780
-
781
- let code = result.code;
782
- // A CommonJS file must not carry oxc's injected ESM `export {};` marker (CJS
783
- // body + ESM marker won't run). Node's strip-types emits no such marker.
784
- if (format === "commonjs") code = stripEmptyExportMarker(code);
785
- if (result.map) {
786
- const map = typeof result.map === "string" ? JSON.parse(result.map) : result.map;
787
- map.sourcesContent = [source];
788
- code += `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString("base64")}\n`;
789
- }
790
- // Append a `//# sourceURL=` magic comment, matching Node's native strip-types
791
- // (lib/internal/modules/typescript.js: `return ${code}\n\n//# sourceURL=${filename}`).
792
- // This is the marker V8/the inspector reads to set `scriptParsed.hasSourceURL =
793
- // true` — the signal that a script is generated/transpiled rather than read
794
- // verbatim from disk (test-inspector-strip-types asserts it). It coexists with
795
- // the inline sourceMappingURL above (maps still drive stack frames); sourceURL
796
- // only names the origin. Use the absolute file path, exactly as Node does.
797
- code += `\n//# sourceURL=${filePath}\n`;
798
-
799
- // Store the chosen format as a leading byte so cache hits skip re-detection.
800
- cacheSet(key, (format === "commonjs" ? "c" : "m") + code);
801
- return { format, source: code, shortCircuit: true };
564
+ return { format: result.format, source: result.code, shortCircuit: true };
802
565
  }
803
566
 
804
567
  // ── Data-format imports ─────────────────────────────────────────────