@nubjs/loader 0.9.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubjs/loader",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Standalone TypeScript loader for Node.js from the Nub project — TypeScript, JSX, tsconfig paths, and data-format imports through a native transform, registered the way tsx and ts-node are",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/nubjs/nub",
@@ -49,13 +49,13 @@
49
49
  "@oxc-project/runtime": "0.140.0"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nubjs/loader-darwin-arm64": "0.9.0",
53
- "@nubjs/loader-darwin-x64": "0.9.0",
54
- "@nubjs/loader-linux-x64": "0.9.0",
55
- "@nubjs/loader-linux-x64-musl": "0.9.0",
56
- "@nubjs/loader-linux-arm64": "0.9.0",
57
- "@nubjs/loader-linux-arm64-musl": "0.9.0",
58
- "@nubjs/loader-win32-x64": "0.9.0",
59
- "@nubjs/loader-win32-arm64": "0.9.0"
52
+ "@nubjs/loader-darwin-arm64": "0.9.1",
53
+ "@nubjs/loader-darwin-x64": "0.9.1",
54
+ "@nubjs/loader-linux-x64": "0.9.1",
55
+ "@nubjs/loader-linux-x64-musl": "0.9.1",
56
+ "@nubjs/loader-linux-arm64": "0.9.1",
57
+ "@nubjs/loader-linux-arm64-musl": "0.9.1",
58
+ "@nubjs/loader-win32-x64": "0.9.1",
59
+ "@nubjs/loader-win32-arm64": "0.9.1"
60
60
  }
61
61
  }
@@ -30,7 +30,7 @@ import "./floor-builtin.mjs";
30
30
  import {
31
31
  TRANSPILE_EXTS, PLAIN_JS_EXTS, CLOBBER_MAP, dataExtsFor,
32
32
  extname, isFileUrl, resolveSpec, loadTranspile, maybeTranspilePlainJs, loadData, loadTextImport, isDependency,
33
- noteRuntimeV8FlagSource,
33
+ noteRuntimeV8FlagSource, outerHookOwnsFormat,
34
34
  } from "./transform-core.mjs";
35
35
  import { createRequire, isBuiltin } from "node:module";
36
36
  import { existsSync } from "node:fs";
@@ -114,6 +114,11 @@ async function loadInner(url, context, nextLoad) {
114
114
  // fast-tier hook.
115
115
  if (context?.importAttributes?.type === "text" && isFileUrl(url)) return loadTextImport(url);
116
116
  const ext = extname(url);
117
+ // A user loader above nub's assigned this TypeScript file a bare module format
118
+ // and will transform it itself from the raw source (tsx's pattern; see
119
+ // `outerHookOwnsFormat`). This tier has no registration counter like the fast
120
+ // tier's and needs none: nothing but such a hook produces the bare form here.
121
+ if (outerHookOwnsFormat(context?.format, ext) && !isDependency(url)) return nextLoad(url, context);
117
122
  // node_modules deps are NEVER transpiled (the byte-parity boundary). This guard is
118
123
  // make-or-break now that TRANSPILE_EXTS includes `.js`/`.mjs`/`.cjs`: without it,
119
124
  // the compat tier would route every dependency `.js` through oxc. (loadTranspile's
@@ -231,6 +231,10 @@ function installWatchReporting(core) {
231
231
  // `type` (transpile — there is no user hook to do it, and Node's strip-only mode
232
232
  // can't handle enums/namespaces). See makeHooks().load.
233
233
  let __userHooksRegistered = false;
234
+ // Narrower: a user registration that brought a `load` hook, so it can transform
235
+ // what nub hands back. A resolve-only user hook that labels a `.ts` file with a
236
+ // bare `commonjs`/`module` still relies on nub as the transformer.
237
+ let __userLoadHookRegistered = false;
234
238
  function installUserHookDetector() {
235
239
  if (typeof module_.registerHooks !== "function") return;
236
240
  const orig = module_.registerHooks;
@@ -238,7 +242,10 @@ function installUserHookDetector() {
238
242
  let seen = 0;
239
243
  const wrapped = function (...args) {
240
244
  // Call #1 is nub's own preload registration; #2+ are user hooks.
241
- if (seen >= 1) __userHooksRegistered = true;
245
+ if (seen >= 1) {
246
+ __userHooksRegistered = true;
247
+ if (args[0] && typeof args[0].load === "function") __userLoadHookRegistered = true;
248
+ }
242
249
  seen += 1;
243
250
  return orig.apply(this, args);
244
251
  };
@@ -698,8 +705,8 @@ function makeHooks(core, watchReporting, foreignLoaderFlagPresent = foreignAsync
698
705
  const source = readFileSync(path);
699
706
  const pkgType = core.getPackageType(dirname(path));
700
707
  const format = core.moduleFormatFor(ext, pkgType, path, source.toString("utf8"));
701
- if (format === "commonjs") return { format: "commonjs", source: null, shortCircuit: true };
702
- return { format, source, shortCircuit: true };
708
+ if (format === "commonjs") return { format: "commonjs", source: null, responseURL: url, shortCircuit: true };
709
+ return { format, source, responseURL: url, shortCircuit: true };
703
710
  } catch {
704
711
  return null;
705
712
  }
@@ -773,7 +780,22 @@ function makeHooks(core, watchReporting, foreignLoaderFlagPresent = foreignAsync
773
780
  // user's outer hook, which does the real ESM->CJS conversion, matching Node.
774
781
  // Native 'module-typescript'/'commonjs-typescript' formats still fall through to
775
782
  // nub's transpile below, so normal augmentation is unchanged.
776
- if (__userHooksRegistered && context && context.format === "typescript") {
783
+ //
784
+ // tsx 4.2x writes the bare `commonjs`/`module` form instead of 'typescript'
785
+ // (`outerHookOwnsFormat`), and the hook that wrote it must also be able to
786
+ // transform what it gets back: a user registration WITH a load hook
787
+ // (`__userLoadHookRegistered`), or a loader registered through
788
+ // `module.register` (`userAsyncLoaderActive`), which the registerHooks counter
789
+ // cannot see and which nub has no way to inspect. A resolve-only user hook
790
+ // that labels a `.ts` file keeps nub as its transformer. The 'typescript'
791
+ // branch keeps its own narrower gate: a non-transpiling async loader (a
792
+ // telemetry `--import`) must not turn a bare 'typescript' from Node's own
793
+ // CJS loader into a step-aside.
794
+ if (context && (
795
+ (__userHooksRegistered && context.format === "typescript") ||
796
+ ((__userLoadHookRegistered || userAsyncLoaderActive(foreignLoaderFlagPresent)) &&
797
+ core.outerHookOwnsFormat(context.format, ext) && !core.isDependency(url))
798
+ )) {
777
799
  return nextLoad(url, context);
778
800
  }
779
801
 
@@ -1027,10 +1049,36 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1027
1049
  // missing. An explicit `require("dep/x/sub.cjs")` is unaffected either way — an
1028
1050
  // exact path is found by stat, without consulting the extension list at all.
1029
1051
  const NUB_ADDED_EXTS = [".ts", ".cts", ".mts", ".tsx", ".jsx", ".cjs"];
1052
+ // A `require.extensions` handler that is ALREADY registered for one of these
1053
+ // when this runs belongs to a transpiler the user chose, and it stays in charge
1054
+ // of that extension: nub's classic shim neither replaces it nor pre-judges its
1055
+ // files as ES modules at resolve time. Every `--require` runs before any `--import`, so on
1056
+ // the compat tier — where nub's own preload is an `--import` — tsx's
1057
+ // `--require`d preflight installs its `.ts` handler FIRST; nub then overwrote
1058
+ // it, and a file tsx would have compiled to CJS (mixed `import` + `require`,
1059
+ // the shape its ESM hook hands to the CJS loader) died in nub's handler as
1060
+ // "Cannot require() this file — it is an ES module". Node itself registers only
1061
+ // `.js`/`.json`/`.node`, so nothing but a user transpiler holds one of these
1062
+ // keys here.
1063
+ const foreignExts = new Set(
1064
+ [...core.TRANSPILE_EXTS, ...core.allDataExts(), ...NUB_ADDED_EXTS].filter((ext) =>
1065
+ ext !== ".js" && ext !== ".json" && ext !== ".node" && Object.hasOwn(module_._extensions, ext)),
1066
+ );
1067
+ // Ownership of a key is decided per lookup, never frozen at start-up: user code
1068
+ // may install or replace a handler after nub's preload (`--import tsx` runs
1069
+ // after every `--require`), and from then on that key is the user's own
1070
+ // widening of LOAD_AS_FILE, exactly as under plain Node plus their handler —
1071
+ // `require("dep/sub")` finding a dependency's `sub.ts` through it is the answer
1072
+ // to preserve. A key is nub's only while BOTH hold: nub introduced it (it was
1073
+ // not in `foreignExts` — a user's `.cjs` handler that nub merely wraps still
1074
+ // counts as theirs) and it still holds a function nub registered below
1075
+ // (`nubHandlers`). Anything else is not nub's to strip, nor to pre-judge.
1076
+ const nubHandlers = new Set();
1077
+ const nubOwnsExt = (ext) => !foreignExts.has(ext) && nubHandlers.has(module_._extensions[ext]);
1030
1078
  const withoutNubAddedExtensions = (fn) => {
1031
1079
  const saved = [];
1032
1080
  for (const ext of NUB_ADDED_EXTS) {
1033
- if (Object.hasOwn(module_._extensions, ext)) {
1081
+ if (nubOwnsExt(ext)) {
1034
1082
  saved.push([ext, module_._extensions[ext]]);
1035
1083
  delete module_._extensions[ext];
1036
1084
  }
@@ -1043,7 +1091,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1043
1091
  };
1044
1092
  const isDepAddedExtHit = (filename) =>
1045
1093
  typeof filename === "string" &&
1046
- NUB_ADDED_EXTS.includes(pathExtname(filename)) &&
1094
+ nubOwnsExt(pathExtname(filename)) &&
1047
1095
  core.isDependency(pathToFileURL(filename).href);
1048
1096
 
1049
1097
  module_._resolveFilename = function (request, parent, isMain, options) {
@@ -1066,7 +1114,8 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1066
1114
  // and 4 from `require.resolve` up to 22.14, but 22.15 and 22.16 pass 4 for both
1067
1115
  // while still lacking native TS — and the translator crash is present on
1068
1116
  // exactly those versions. A clean error beats an opaque crash, so this stays.
1069
- if (withClassicTranspile && core.requireTargetIsEsm(resolved, pathExtname(resolved))) {
1117
+ if (withClassicTranspile && nubOwnsExt(pathExtname(resolved)) &&
1118
+ core.requireTargetIsEsm(resolved, pathExtname(resolved))) {
1070
1119
  throw requireEsmError(resolved);
1071
1120
  }
1072
1121
  return resolved;
@@ -1195,6 +1244,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1195
1244
  if (core.TRANSPILE_EXTS.has(ext)) return transpileExtension(mod, filename);
1196
1245
  return nativeJs.call(module_._extensions, mod, filename);
1197
1246
  };
1247
+ nubHandlers.add(nubExtension);
1198
1248
 
1199
1249
  // Registered for every extension either path may claim, so the dispatcher is
1200
1250
  // reached at all; `nubExtension` then decides. Node's own `.js`/`.json`/`.node`
@@ -1215,7 +1265,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1215
1265
  // resolution identical across tiers.
1216
1266
  const CODE_EXTS = new Set([".ts", ".cts", ".mts", ".tsx", ".jsx"]);
1217
1267
  for (const ext of new Set([...core.TRANSPILE_EXTS, ...core.allDataExts()])) {
1218
- if (ext === ".js" || ext === ".json" || ext === ".node") continue;
1268
+ if (ext === ".js" || ext === ".json" || ext === ".node" || foreignExts.has(ext)) continue;
1219
1269
  Object.defineProperty(module_._extensions, ext, {
1220
1270
  value: nubExtension,
1221
1271
  enumerable: CODE_EXTS.has(ext),
@@ -1243,7 +1293,7 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1243
1293
  // (`nativeJs` is captured above, before the TS handlers are registered.)
1244
1294
  for (const ext of [".js", ".cjs"]) {
1245
1295
  const origExtension = module_._extensions[ext] || nativeJs;
1246
- module_._extensions[ext] = (mod, filename) => {
1296
+ const plainJsExtension = (mod, filename) => {
1247
1297
  // (0) The project pointed this extension at a data loader (`{".js":"text"}`),
1248
1298
  // which the ESM path honors. These two extensions are skipped by the
1249
1299
  // registration loop above because THIS wrapper owns them and runs after it,
@@ -1266,6 +1316,8 @@ function installCjsRequireHooks(core, withClassicTranspile) {
1266
1316
  }
1267
1317
  return origExtension.call(module_._extensions, mod, filename); // (3)
1268
1318
  };
1319
+ module_._extensions[ext] = plainJsExtension;
1320
+ nubHandlers.add(plainJsExtension);
1269
1321
  }
1270
1322
  }
1271
1323
 
@@ -1705,6 +1757,90 @@ function installVersionMarker() {
1705
1757
  } catch {}
1706
1758
  }
1707
1759
 
1760
+ // ── libuv threadpool policy ──────────────────────────────────────────
1761
+ // The launcher sized the pool (`UV_THREADPOOL_SIZE = max(4, cores)`, spawn.rs) and
1762
+ // Node read it at startup, so the variable has done its work for THIS process.
1763
+ // Two things remain, both about the cores the extra threads would take:
1764
+ //
1765
+ // 1. Children keep Node's default. A `cluster` or PM2 fork, or any `child_process`
1766
+ // spawn, inherits `process.env`; N workers each carrying a cores-sized pool is
1767
+ // the oversubscription Node's own maintainers closed nodejs/node#61533 over.
1768
+ // So nub's OWN value is deleted from `process.env`. Ownership is two markers
1769
+ // the launcher stamps (spawn.rs `RestorableVar`): the value equals the
1770
+ // `__NUB_AUGMENTED_*` record of what the launcher handed Node, AND the compat
1771
+ // presence mask says the variable was ABSENT before the outermost nub ran (bit
1772
+ // 1 << 5 is this variable's slot). A shell value, an env-file value, or a value
1773
+ // the launcher merely passed through fails one of the two and inherits as it
1774
+ // would under plain Node. The markers themselves stay, which is how a nested
1775
+ // `nub` knows to size its child again. libuv reads the variable LAZILY, at the
1776
+ // first pool use, and a `process.env` delete reaches the C environment, so the
1777
+ // pool is created (one `fs.access`) before the variable goes; otherwise libuv
1778
+ // would find nothing and build Node's four. A pool of four is Node's default,
1779
+ // so the variable goes at once and nothing is demoted.
1780
+ // 2. The threads beyond Node's four run at a lower priority on Linux, so they only
1781
+ // take cycles nothing else on the box wants (Chromium's best-effort tier, nice
1782
+ // 10). Measured on 16 vCPU beside twelve busy processes: the neighbours keep
1783
+ // 98.6% of their CPU instead of 92.5%, the server still gains 20% over four
1784
+ // threads, and an idle box loses nothing. libuv creates every worker
1785
+ // synchronously inside the first pool submit, so one `fs.access` call makes
1786
+ // them all exist (`access` never takes the io_uring path that lets stat, read
1787
+ // and open skip the pool); the new thread ids (or the `libuv-worker` name) name
1788
+ // them, and `os.setPriority(tid)` targets one thread on Linux. The thread ids
1789
+ // are exact only across the call that builds the pool: the fast tier's
1790
+ // `--require` preload runs before any pool use and builds it here, but the
1791
+ // compat tier's `--import` preload is itself read through the pool, so the
1792
+ // launcher `--require`s threadpool-snapshot.cjs ahead of it to build the pool
1793
+ // and record its threads there.
1794
+ const THREADPOOL_ENV = "UV_THREADPOOL_SIZE";
1795
+ const THREADPOOL_MARK_ENV = "__NUB_AUGMENTED_UV_THREADPOOL_SIZE";
1796
+ const COMPAT_PRESENT_ENV = "__NUB_COMPAT_PRESENT";
1797
+ const THREADPOOL_PRESENT_BIT = 1 << 5;
1798
+ const THREADPOOL_NODE_DEFAULT = 4;
1799
+ const THREADPOOL_EXTRA_NICE = 10;
1800
+ const THREADPOOL_WORKERS = Symbol.for("nub.threadpool.workers");
1801
+
1802
+ function installThreadpoolPolicy() {
1803
+ const size = process.env[THREADPOOL_ENV];
1804
+ if (size === undefined || size !== process.env[THREADPOOL_MARK_ENV]) return;
1805
+ if ((Number(process.env[COMPAT_PRESENT_ENV]) || 0) & THREADPOOL_PRESENT_BIT) return;
1806
+ if (!(Number(size) > THREADPOOL_NODE_DEFAULT)) {
1807
+ // Node's own size: nothing to demote, and libuv builds the same four whether
1808
+ // it still finds the variable or not, so it goes at once.
1809
+ delete process.env[THREADPOOL_ENV];
1810
+ return;
1811
+ }
1812
+ try {
1813
+ // The `--require` preload re-runs inside every loader worker; the pool is
1814
+ // process-wide, so only the main thread touches it.
1815
+ if (!require("node:worker_threads").isMainThread) return;
1816
+ const fs = require("node:fs");
1817
+ const linux = process.platform === "linux";
1818
+ const tids = () => fs.readdirSync("/proc/self/task").map(Number).filter(Boolean);
1819
+ let workers = linux ? process[THREADPOOL_WORKERS] : undefined;
1820
+ if (workers === undefined) {
1821
+ const before = linux ? new Set(tids()) : null;
1822
+ fs.access("/", () => {});
1823
+ const isWorker = (t) => {
1824
+ if (!before.has(t)) return true;
1825
+ try {
1826
+ return fs.readFileSync(`/proc/self/task/${t}/comm`, "latin1").trim() === "libuv-worker";
1827
+ } catch {
1828
+ return false;
1829
+ }
1830
+ };
1831
+ if (linux) workers = tids().filter(isWorker);
1832
+ }
1833
+ delete process.env[THREADPOOL_ENV];
1834
+ if (!linux) return;
1835
+ const os = require("node:os");
1836
+ for (const t of workers.sort((a, b) => a - b).slice(THREADPOOL_NODE_DEFAULT)) {
1837
+ try {
1838
+ os.setPriority(t, THREADPOOL_EXTRA_NICE);
1839
+ } catch {}
1840
+ }
1841
+ } catch {}
1842
+ }
1843
+
1708
1844
  // ── User preloads (`nub.jsonc` `preload`) ───────────────────────────
1709
1845
  // nub loads the user's preload entries HERE rather than emitting one NODE_OPTIONS
1710
1846
  // token per entry. Two reasons, and the first is a correctness bug in the wild:
@@ -1756,6 +1892,7 @@ async function importUserPreloadChain() {
1756
1892
 
1757
1893
  module.exports = {
1758
1894
  installVersionMarker,
1895
+ installThreadpoolPolicy,
1759
1896
  installWatchReporting,
1760
1897
  registerLoaderWorker,
1761
1898
  makeHooks,
@@ -207,6 +207,20 @@ export const TRANSPILE_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".jsx"]);
207
207
  // `maybeTranspilePlainJs` gate); a no-op plain-JS file falls through to Node's
208
208
  // native loader untouched, byte-identical. node_modules is excluded at the gate.
209
209
  export const PLAIN_JS_EXTS = new Set([".js", ".mjs", ".cjs"]);
210
+ // A bare `commonjs`/`module` format on a file nub would transpile can only have
211
+ // been assigned by a hook layered ABOVE nub's. Node's resolver labels a `.ts` file
212
+ // `commonjs-typescript`/`module-typescript` (or `typescript` when the package has
213
+ // no `type`) and leaves `.jsx` unlabelled, and nub's own resolve returns no format;
214
+ // the bare form is what tsx's resolve hook writes (getFormatFromFileUrl), and its
215
+ // load hook then expects the RAW source back from `nextLoad` so it can run its own
216
+ // module-format transform — a mixed `import` + `require` file becomes CJS there,
217
+ // where nub's syntax detection would make it ESM and `require` undefined. Both
218
+ // tiers step aside on this signal, the fast tier only once the user registration
219
+ // is known to carry a load hook (preload-common.cjs). Plain JS is excluded: Node
220
+ // assigns those the bare form itself.
221
+ export function outerHookOwnsFormat(format, ext) {
222
+ return (format === "commonjs" || format === "module") && TRANSPILE_EXTS.has(ext) && !PLAIN_JS_EXTS.has(ext);
223
+ }
210
224
  // The data loaders nub SHIPS — a runtime feature, not a project setting, so they stay
211
225
  // in force inside node_modules too (see dataExtsFor).
212
226
  const BUILTIN_DATA_EXTS = { ".jsonc": "jsonc", ".json5": "json5", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", ".txt": "txt" };
@@ -1007,7 +1021,15 @@ export function loadTranspile(url, ext, source) {
1007
1021
  const details = result.errors.map((e) => e.codeframe || e.message).join("\n\n");
1008
1022
  throw new Error(`Transpile error in ${filePath}:\n${details}`);
1009
1023
  }
1010
- return { format: result.format, source: result.code, shortCircuit: true };
1024
+ // `responseURL` is what an OUTER user hook keys on. Node's default load sets it
1025
+ // to the file URL, and tsx's load hook takes its CommonJS branch only when the
1026
+ // result `nextLoad` hands back carries a `file:` responseURL — without it, tsx
1027
+ // re-transformed a `.ts` file nub had already emitted as CJS in its ESM branch,
1028
+ // and `require` was undefined at run time (`tsx script.ts` under `nub run`).
1029
+ // Node itself defaults a missing responseURL to the URL, so only a hook layered
1030
+ // above nub's can observe the difference; every file-URL result nub
1031
+ // short-circuits carries it for that reason.
1032
+ return { format: result.format, source: result.code, responseURL: url, shortCircuit: true };
1011
1033
  }
1012
1034
 
1013
1035
  // Project-source plain JS (`.js`/`.mjs`/`.cjs`) gate. Returns a transpiled load
@@ -1126,7 +1148,7 @@ export function loadData(url, ext) {
1126
1148
  const parsed = dataValue(url, ext);
1127
1149
 
1128
1150
  if (parsed == null) {
1129
- return { format: "module", source: "export default undefined;\n", shortCircuit: true };
1151
+ return { format: "module", source: "export default undefined;\n", responseURL: url, shortCircuit: true };
1130
1152
  }
1131
1153
 
1132
1154
  // Default export only. Data modules deliberately do NOT emit per-key named
@@ -1136,7 +1158,7 @@ export function loadData(url, ext) {
1136
1158
  // default — `import cfg from "./c.yaml"; const { host } = cfg;` — which the
1137
1159
  // `@nubjs/types` `Record<string, unknown>` default type makes sound.
1138
1160
  const code = `export default ${JSON.stringify(parsed)};\n`;
1139
- return { format: "module", source: code, shortCircuit: true };
1161
+ return { format: "module", source: code, responseURL: url, shortCircuit: true };
1140
1162
  }
1141
1163
 
1142
1164
  // Import Text: `import s from "./any.file" with { type: "text" }` → the raw file
@@ -1152,5 +1174,5 @@ export function loadData(url, ext) {
1152
1174
  const __textDecoder = new TextDecoder();
1153
1175
  export function loadTextImport(url) {
1154
1176
  const text = __textDecoder.decode(readFileSync(fileURLToPath(url)));
1155
- return { format: "module", source: `export default ${JSON.stringify(text)};\n`, shortCircuit: true };
1177
+ return { format: "module", source: `export default ${JSON.stringify(text)};\n`, responseURL: url, shortCircuit: true };
1156
1178
  }