@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nub contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1 +1,48 @@
1
- Placeholder — the Nub loader ships here with the next Nub release. See https://github.com/nubjs/nub.
1
+ # @nubjs/loader
2
+
3
+ Standalone TypeScript loader for Node.js, from the [Nub](https://nubjs.com) project. Register it the way tsx or ts-node is registered, and TypeScript works in `import`, in `require()`, and in worker threads — powered by the same native oxc-based transform the Nub CLI uses.
4
+
5
+ ```sh
6
+ npm install --save-dev @nubjs/loader
7
+ node --import @nubjs/loader app.ts
8
+ ```
9
+
10
+ Any way Node accepts a preload works:
11
+
12
+ ```sh
13
+ node --import @nubjs/loader app.ts # one run
14
+ NODE_OPTIONS="--import @nubjs/loader" vitest # tools that spawn node themselves
15
+ node --require @nubjs/loader app.ts # CommonJS delivery (see below)
16
+ ```
17
+
18
+ ## What it does
19
+
20
+ - Transpiles `.ts` / `.tsx` / `.mts` / `.cts` / `.jsx` on the fly — full TypeScript, including enums, namespaces, and legacy decorators, not just type stripping.
21
+ - Resolves TypeScript conventions: tsconfig `paths` and `baseUrl`, extensionless imports, the `.js` → `.ts` emit-convention swap, directory index files.
22
+ - Augments CommonJS `require()` with the same resolution and transpile, not only `import`.
23
+ - Loads data formats as modules: `.yaml`, `.toml`, `.json5`, `.jsonc`, `.txt`, and `with { type: "text" }` imports.
24
+ - Lowers `using` / `await using` and other syntax newer than the running Node.
25
+ - Inline source maps, on for every transpiled file.
26
+ - Applies inside worker threads automatically (Node inherits the preload).
27
+
28
+ Dependencies under `node_modules` are never transpiled, and files Node handles natively load byte-for-byte unchanged — the loader adds behavior, it does not modify Node's.
29
+
30
+ ## Entry points
31
+
32
+ ```sh
33
+ node --import @nubjs/loader app.ts # ESM hooks + CommonJS require() augmentation
34
+ node --require @nubjs/loader app.ts # same, delivered as a CommonJS preload (Node 20.19+)
35
+ node --import @nubjs/loader/esm app.ts # ESM hooks only
36
+ ```
37
+
38
+ Module formats follow Node's own rules: a `.cts` file is CommonJS and a `.mts` file is an ES module, and the loader transpiles types and syntax without converting one format into the other.
39
+
40
+ ## Node support
41
+
42
+ Node 18.19 and newer. On Node 22.15+ hooks register synchronously in-thread (`module.registerHooks`); older versions run them in Node's loader worker (`module.register`). The `--require` delivery needs `require(esm)` (Node 20.19+ / 22.12+); below that use `--import`.
43
+
44
+ ## Relationship to the Nub CLI
45
+
46
+ The [`@nubjs/nub`](https://www.npmjs.com/package/@nubjs/nub) CLI is a complete TypeScript-first toolchain — runner, package manager, Node version management — and does everything this loader does without any flags. This package is the loader alone, for cases where the `node` invocation itself is fixed: existing tooling, test runners, other CLIs that spawn `node`.
47
+
48
+ Platform binaries ship as `optionalDependencies` (`@nubjs/loader-*`) for macOS, Linux (glibc and musl), and Windows, on x64 and arm64.
@@ -0,0 +1,180 @@
1
+ // Bounded-size eviction for the transpile cache (A16). Kept in its own module
2
+ // so it loads lazily — only when a sweep is actually due (see preload.mjs's
3
+ // throttled maybeSweepCache) — and so it can be tested in isolation.
4
+ //
5
+ // LRU by mtime: when the cache exceeds `maxBytes`, delete oldest-written entries
6
+ // until at or below `lowWater`. Entries are content-addressed, so evicting one
7
+ // only costs a re-transpile on next use. mtime reflects write time, not last
8
+ // read — true read-LRU would need an mtime touch on every cache hit, defeating
9
+ // the read-only fast path; oldest-written is the right, cheap proxy for a cache
10
+ // whose entries are written once per source version.
11
+
12
+ // NO STATIC `node:` IMPORTS. This module is reached by an UNAWAITED dynamic
13
+ // `import()` from the deferred sweep, so its import graph is in flight while the
14
+ // user's entry is still loading. On Node 20.10–20.18 a PENDING builtin job in the
15
+ // ESM loadCache makes a transpiled CommonJS entry's synchronous `require()` of the
16
+ // same builtin take the ESM translator's sync path (getModuleJobSync → runSync) and
17
+ // trip `assert(this.module instanceof ModuleWrap)` — ERR_INTERNAL_ASSERTION, ~25% of
18
+ // runs (#706). Fetching the builtins synchronously never enters the ESM loader, so
19
+ // no job exists for user code to collide with.
20
+ let readdirSync, rmdirSync, statSync, unlinkSync, join;
21
+ let _getBuiltin = null;
22
+
23
+ /**
24
+ * Thread in a synchronous builtin getter. Required below Node 22.3, where
25
+ * `process.getBuiltinModule` does not exist — transform-core hands over the same
26
+ * getter it uses itself (createRequire-backed on the floor).
27
+ */
28
+ export function setBuiltinGetter(fn) {
29
+ _getBuiltin = fn;
30
+ }
31
+
32
+ function ensureBuiltins() {
33
+ if (readdirSync) return;
34
+ const get = _getBuiltin || ((id) => process.getBuiltinModule(id));
35
+ ({ readdirSync, rmdirSync, statSync, unlinkSync } = get("node:fs"));
36
+ ({ join } = get("node:path"));
37
+ }
38
+
39
+ // A valid cache entry is a 64-char lowercase-hex sha256 (see cacheKey in
40
+ // preload.mjs). Everything else — the `.sweep` sentinel, `*.tmp` in-flight
41
+ // writes from cacheSet — is skipped: never counted toward the cap, never
42
+ // evicted.
43
+ const ENTRY_RE = /^[0-9a-f]{64}$/;
44
+
45
+ // What one entry actually costs the filesystem. `stat.size` is the LOGICAL
46
+ // length, but the disk hands out whole blocks, and this cache is mostly small
47
+ // files — measured on a real 32k-entry cache: 45% of entries sat under one 4 KiB
48
+ // block, so 492.6 MB of logical bytes occupied 571.0 MB of disk, and a nominal
49
+ // 512 MiB cap silently permitted ~594 MB. `blocks` is POSIX (512-byte units);
50
+ // Windows does not report it, so fall back to the logical size there.
51
+ function diskBytes(s) {
52
+ return typeof s.blocks === "number" && s.blocks > 0 ? s.blocks * 512 : s.size;
53
+ }
54
+
55
+ /**
56
+ * Evict oldest entries from `dir` until total entry bytes ≤ `lowWater`, if they
57
+ * exceed `maxBytes`. Sizes are the entries' real on-disk footprint, so the cap
58
+ * bounds what the user's disk actually loses. Returns `{ scanned, deleted, freed }`.
59
+ * Best-effort: a stat or unlink that fails (concurrent reader/evictor, Windows
60
+ * open handle) is skipped, and the next sweep retries. Never throws.
61
+ */
62
+ export function sweepCache(dir, maxBytes, lowWater = Math.floor(maxBytes * 0.75)) {
63
+ ensureBuiltins();
64
+ let names;
65
+ try {
66
+ names = readdirSync(dir);
67
+ } catch {
68
+ return { scanned: 0, deleted: 0, freed: 0 };
69
+ }
70
+
71
+ const entries = [];
72
+ let total = 0;
73
+ for (const name of names) {
74
+ if (!ENTRY_RE.test(name)) continue;
75
+ const path = join(dir, name);
76
+ const s = statSync(path, { throwIfNoEntry: false });
77
+ if (!s || !s.isFile()) continue;
78
+ const size = diskBytes(s);
79
+ entries.push({ path, size, mtime: s.mtimeMs });
80
+ total += size;
81
+ }
82
+
83
+ if (total <= maxBytes) {
84
+ return { scanned: entries.length, deleted: 0, freed: 0 };
85
+ }
86
+
87
+ // Oldest first; delete down to the low-water mark so we don't sweep again on
88
+ // the very next write.
89
+ entries.sort((a, b) => a.mtime - b.mtime);
90
+ let deleted = 0;
91
+ let freed = 0;
92
+ for (const e of entries) {
93
+ if (total <= lowWater) break;
94
+ try {
95
+ unlinkSync(e.path);
96
+ total -= e.size;
97
+ freed += e.size;
98
+ deleted++;
99
+ } catch {
100
+ // Already removed by a concurrent sweep, or held open (Windows): skip.
101
+ // `total` is unchanged so we keep trying to reach lowWater via other
102
+ // entries; the loop is bounded by `entries`, so it still terminates.
103
+ }
104
+ }
105
+ return { scanned: entries.length, deleted, freed };
106
+ }
107
+
108
+ /**
109
+ * Evict from nub's OWN default V8 compile-cache dir. The layout there is Node's,
110
+ * not ours — `<dir>/<version>-<arch>-<hash>-<uid>/<entry>` — so this walks one
111
+ * level down, treats every regular file as an entry (V8 picks the names; there
112
+ * is no pattern of ours to match), and prunes a version directory once eviction
113
+ * has emptied it. Those version directories are the quiet half of the problem:
114
+ * one accumulates per Node build ever run under nub and nothing removed it, so a
115
+ * working machine had 96 of them totalling 6.9 GB across ~594k files.
116
+ *
117
+ * Deleting a live entry is safe: a missing or unreadable code-cache entry only
118
+ * makes V8 recompile that module. Same oldest-first, delete-to-`lowWater` policy
119
+ * and the same never-throws contract as `sweepCache`.
120
+ */
121
+ export function sweepCompileCache(dir, maxBytes, lowWater = Math.floor(maxBytes * 0.75)) {
122
+ ensureBuiltins();
123
+ let versionDirs;
124
+ try {
125
+ versionDirs = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory());
126
+ } catch {
127
+ return { scanned: 0, deleted: 0, freed: 0 };
128
+ }
129
+
130
+ const entries = [];
131
+ let total = 0;
132
+ for (const vd of versionDirs) {
133
+ const vpath = join(dir, vd.name);
134
+ let names;
135
+ try {
136
+ names = readdirSync(vpath);
137
+ } catch {
138
+ continue;
139
+ }
140
+ for (const name of names) {
141
+ const path = join(vpath, name);
142
+ const s = statSync(path, { throwIfNoEntry: false });
143
+ if (!s || !s.isFile()) continue;
144
+ const size = diskBytes(s);
145
+ entries.push({ path, size, mtime: s.mtimeMs });
146
+ total += size;
147
+ }
148
+ }
149
+
150
+ if (total <= maxBytes) {
151
+ return { scanned: entries.length, deleted: 0, freed: 0 };
152
+ }
153
+
154
+ entries.sort((a, b) => a.mtime - b.mtime);
155
+ let deleted = 0;
156
+ let freed = 0;
157
+ for (const e of entries) {
158
+ if (total <= lowWater) break;
159
+ try {
160
+ unlinkSync(e.path);
161
+ total -= e.size;
162
+ freed += e.size;
163
+ deleted++;
164
+ } catch {
165
+ // Concurrent sweep or an open handle (Windows): skip, retried next sweep.
166
+ }
167
+ }
168
+
169
+ // `rmdir` refuses a non-empty directory, so this can only ever remove one the
170
+ // eviction above actually emptied — never a version dir still holding entries.
171
+ for (const vd of versionDirs) {
172
+ try {
173
+ rmdirSync(join(dir, vd.name));
174
+ } catch {
175
+ // Still populated, or in use by a concurrent run.
176
+ }
177
+ }
178
+
179
+ return { scanned: entries.length, deleted, freed };
180
+ }
@@ -0,0 +1,57 @@
1
+ // Compat-tier floor bootstrap: threads `node:module`'s `createRequire` into the
2
+ // modules that fetch their `node:` builtins via `process.getBuiltinModule`
3
+ // (transform-core.mjs, worker-polyfill.mjs) on the narrow FLOOR where that API is
4
+ // absent — WITHOUT any globalThis surface.
5
+ //
6
+ // WHY those modules can't fetch the builtin themselves: both are loaded on the fast
7
+ // tier via Node's `require(esm)`, which instantiates an ES module by walking its
8
+ // STATIC IMPORT graph through whatever ESM loader chain is registered — including the
9
+ // USER's `--experimental-loader` / `module.register` hooks. A static `import {
10
+ // createRequire } from "node:module"` in either file therefore routed the builtin
11
+ // through the user chain, and a user resolve/load hook that rejects or rewrites
12
+ // `node:module` exploded nub's own load (observed against es-module/test-esm-example-
13
+ // loader and the loader-chaining corpus). So they fetch builtins via
14
+ // `process.getBuiltinModule` (synchronous, OFF the loader chain, no static import).
15
+ //
16
+ // `process.getBuiltinModule` only exists from Node 22.3 / 20.16 / 18.20.4. On the
17
+ // narrow FLOOR below that (18.19.x, 20.11–20.15, 22.0–22.2) it is `undefined`, so the
18
+ // floor needs another way to reach `node:module`'s `createRequire`. This file is that
19
+ // fallback: it holds the LONE static `import { createRequire } from "node:module"`
20
+ // and hands the value to transform-core / worker-polyfill through their module-scoped
21
+ // SETTERS (no globalThis surface — a `globalThis.__nub*` sentinel is the same brand
22
+ // leak as a NUB_* env var, enumerable in user code AND worker realms, so it is
23
+ // forbidden; this threading honors the same enumeration-invisibility contract every
24
+ // other nub polyfill keeps).
25
+ //
26
+ // WHY THIS IS LEAK-SAFE WHERE the static import in transform-core/worker-polyfill WAS
27
+ // NOT: this module is imported ONLY by the compat-tier entries (preload.mjs and
28
+ // preload-async-hooks.mjs), AHEAD of transform-core/worker-polyfill in their source
29
+ // order. The FAST tier (preload.cjs) loads those via `require(esm)` directly and never
30
+ // touches preload.mjs / preload-async-hooks.mjs — so this file's static `node:module`
31
+ // import never enters the fast-tier `require(esm)` graph, and the user loader chain
32
+ // can never observe it. On the compat tier the loader hooks run in nub's OWN worker
33
+ // (preload-async-hooks) or on the main thread before any user `--loader` could
34
+ // intercept a bare `node:` builtin, both off the user chain — so the static import is
35
+ // harmless exactly where it's reachable.
36
+ //
37
+ // IMPORT ORDERING (load-bearing): the compat entries import this file BEFORE
38
+ // transform-core/worker-polyfill, but ES modules evaluate the importEE before the
39
+ // importer's body — so transform-core's body has ALREADY run by the time the setter
40
+ // calls below fire (during THIS module's evaluation). That is fine: transform-core
41
+ // acquires its floor builtins lazily and `setBootstrapCreateRequire` triggers that
42
+ // acquisition immediately, so every binding is ready before the entry body and long
43
+ // before any hook fires. On Node WITH getBuiltinModule this file is a near no-op (the
44
+ // setters are called but those modules never consult `_bootstrapCreateRequire`).
45
+ import { createRequire } from "node:module";
46
+ import { setBootstrapCreateRequire as setTransformCoreCreateRequire } from "./transform-core.mjs";
47
+
48
+ // The floor's `createRequire`, exported so the compat entries (and any future floor
49
+ // consumer) can thread it elsewhere without re-importing `node:module` into their own
50
+ // static graph.
51
+ export { createRequire };
52
+
53
+ // Thread it into transform-core unconditionally (the setter no-ops the floor branch
54
+ // on Node WITH getBuiltinModule). worker-polyfill's setter is wired by the entries
55
+ // themselves, AFTER they import worker-polyfill, since worker-polyfill is loaded later
56
+ // in the entry's flow (via dynamic import) than this static import runs.
57
+ setTransformCoreCreateRequire(createRequire);
@@ -0,0 +1,24 @@
1
+ // Standalone-loader addon plumbing — MUST evaluate before transform-core.mjs.
2
+ //
3
+ // transform-core loads the `nub-native` N-API addon at its own module evaluation
4
+ // (fast tier: eagerly, the moment the module body runs), probing a sibling
5
+ // `./addons/nub-native.node` first. Under the nub CLI that sibling always exists
6
+ // (the extracted runtime dir); in the standalone loader package the addon rides a
7
+ // per-platform npm package instead, so this module resolves it and hands the
8
+ // absolute path over via the internal `__NUB_ADDON_PATH` plumbing var — see
9
+ // ensureAddonEnv in loader-platform.cjs for the probe-order and worker-thread
10
+ // rationale.
11
+ //
12
+ // Why a separate side-effect module: ESM evaluates imports in source order, so the
13
+ // entry importing THIS file before transform-core is what guarantees the env var is
14
+ // set in time (the same ordering trick compile-cache-restore.mjs uses for
15
+ // NODE_COMPILE_CACHE). The createRequire import MUST come from `node:module`
16
+ // directly, NOT from floor-builtin.mjs: floor-builtin statically imports
17
+ // transform-core (to thread the floor's createRequire into it), so reaching
18
+ // createRequire through it would evaluate transform-core — and run its addon
19
+ // probe — before this module's body sets the env var. That ordering bug shipped
20
+ // in the first cut and only the dev tree's sibling addons/ dir masked it.
21
+ import { createRequire } from "node:module";
22
+
23
+ const __require = createRequire(import.meta.url);
24
+ __require("./loader-platform.cjs").ensureAddonEnv(__require);
@@ -0,0 +1,197 @@
1
+ // Standalone Nub loader — the arming logic behind `node --import <pkg>` /
2
+ // `node --require <pkg>`, consumed the way tsx/ts-node are. Slim by design: it
3
+ // arms ONLY the resolve + transpile surface (TS/JSX/`using`-lowering, tsconfig
4
+ // `paths`, extension probing, data-format imports) from the shared
5
+ // transform-core / preload-common machinery, and none of the CLI runtime's
6
+ // process augmentation — no polyfills, no Temporal/Worker/navigator globals, no
7
+ // watch IPC, no user preload chain, no version marker. A file that runs under
8
+ // `node --import <pkg>` must behave identically minus TS-just-works.
9
+ //
10
+ // Import order is load-bearing (ESM evaluates imports in source order):
11
+ // 1. loader-addon-env.mjs — resolves the per-platform addon package and sets
12
+ // the internal `__NUB_ADDON_PATH` plumbing var BEFORE transform-core's
13
+ // module body probes for the addon.
14
+ // 2. floor-builtin.mjs — threads `createRequire` into transform-core on the
15
+ // narrow pre-`process.getBuiltinModule` floor (18.19.x, 20.11–20.15,
16
+ // 22.0–22.2); a no-op elsewhere.
17
+ // 3. transform-core.mjs — the tier-agnostic resolve+transpile core, shared
18
+ // verbatim with the nub CLI. It has ZERO static imports by construction, so
19
+ // routing it through a user's loader chain leaks nothing (R11).
20
+ import "./loader-addon-env.mjs";
21
+ import { createRequire } from "./floor-builtin.mjs";
22
+ import * as core from "./transform-core.mjs";
23
+
24
+ const __require = createRequire(import.meta.url);
25
+ const module_ = __require("node:module");
26
+ const { fileURLToPath } = __require("node:url");
27
+ const { dirname, isAbsolute, resolve: resolvePath, sep } = __require("node:path");
28
+ const common = __require("./preload-common.cjs");
29
+
30
+ // One arming record per module instance (= per realm: the main thread and each
31
+ // user worker thread evaluate this module separately via inherited execArgv).
32
+ // `esmMode` records which hook surface the ESM side took, because the CJS side's
33
+ // classic-transpile decision depends on it.
34
+ const armed = { esm: false, cjs: false, esmMode: null };
35
+
36
+ const OWN_DIR = dirname(fileURLToPath(import.meta.url)) + sep;
37
+
38
+ // The loader package's own published name, for recognizing our own `--import`
39
+ // token in the foreign-loader scan. In the published package this file sits next
40
+ // to package.json; in the dev tree (runtime/) there is none, and path-prefix
41
+ // matching covers that case.
42
+ const OWN_PKG_NAME = (() => {
43
+ try {
44
+ const raw = __require("node:fs").readFileSync(
45
+ fileURLToPath(new URL("./package.json", import.meta.url)),
46
+ "utf8",
47
+ );
48
+ const name = JSON.parse(raw).name;
49
+ return typeof name === "string" && name.length > 0 ? name : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ })();
54
+
55
+ // nub's own preload chainer rides `--import` too; same marker preload-common uses.
56
+ const NUB_CHAIN_MARKER = /[\\/]\.nub[\\/]preload-chain\./;
57
+
58
+ // Is this `--import`/`--loader` value one of OUR OWN entrypoints (or nub's
59
+ // chainer), as opposed to a genuinely foreign async loader (tsx, ts-node, an OTel
60
+ // attach)? The distinction preload-common's own scan does not need to make — the
61
+ // CLI's fast tier is delivered by `--require`, so for it ANY `--import` is
62
+ // foreign — but the standalone loader IS an `--import`, so a value-blind scan
63
+ // would classify the loader itself as foreign and force the async tier on every
64
+ // run in the broken-compose band.
65
+ function isOwnLoaderToken(value) {
66
+ if (!value) return false;
67
+ if (NUB_CHAIN_MARKER.test(value)) return true;
68
+ if (OWN_PKG_NAME && (value === OWN_PKG_NAME || value.startsWith(`${OWN_PKG_NAME}/`))) {
69
+ return true;
70
+ }
71
+ try {
72
+ const p = value.startsWith("file:") ? fileURLToPath(value) : value;
73
+ if (isAbsolute(p) && (resolvePath(p) + sep).startsWith(OWN_DIR)) return true;
74
+ // A relative dev-tree form (`--import ./runtime/loader-register.mjs`)
75
+ // resolves from the CWD, matching how Node resolved it.
76
+ if (p.startsWith(".") && (resolvePath(process.cwd(), p) + sep).startsWith(OWN_DIR)) {
77
+ return true;
78
+ }
79
+ } catch {
80
+ // Unparseable value — treat as foreign; over-selection of the async tier is
81
+ // safe (it is always correct, just slower to start).
82
+ }
83
+ return false;
84
+ }
85
+
86
+ // A foreign async ESM loader riding THIS process's startup flags, via either
87
+ // delivery channel (execArgv or NODE_OPTIONS). Same two-channel scan as
88
+ // preload-common's computeForeignAsyncLoaderFlagPresent, but value-aware so our
89
+ // own token is excluded.
90
+ function foreignAsyncLoaderPresent() {
91
+ const tokens = [];
92
+ const argv = Array.isArray(process.execArgv) ? process.execArgv : [];
93
+ for (let i = 0; i < argv.length; i++) {
94
+ const a = argv[i];
95
+ if (typeof a !== "string") continue;
96
+ for (const flag of ["--import", "--loader", "--experimental-loader"]) {
97
+ if (a === flag) {
98
+ if (typeof argv[i + 1] === "string") tokens.push(argv[i + 1]);
99
+ } else if (a.startsWith(`${flag}=`)) {
100
+ tokens.push(a.slice(flag.length + 1));
101
+ }
102
+ }
103
+ }
104
+ const opts = process.env.NODE_OPTIONS;
105
+ if (typeof opts === "string" && opts !== "") {
106
+ const re = /(?:^|\s)--(?:experimental-)?(?:import|loader)(?:=|\s)("[^"]*"|\S*)/g;
107
+ for (const match of opts.matchAll(re)) {
108
+ tokens.push((match[1] || "").replace(/^"|"$/g, ""));
109
+ }
110
+ }
111
+ return tokens.some((t) => t && !isOwnLoaderToken(t));
112
+ }
113
+
114
+ // Arm the loader. `esm` = the ESM hook surface (module.registerHooks on the fast
115
+ // tier, the module.register loader worker on the compat tier); `cjs` = the
116
+ // CommonJS require() surface (Module._resolveFilename + the classic transpile
117
+ // shim where the tier needs it). Idempotent per surface, so `--import <pkg>` plus
118
+ // `--require <pkg>/cjs` in one invocation arms each exactly once.
119
+ export function arm({ esm = true, cjs = true } = {}) {
120
+ // Electron: the sync load hook deadlocks Electron's main-process module
121
+ // bootstrap, and its app JS is pre-bundled (the bundler owns TS) — same bail,
122
+ // same reason as the CLI preload (issue #246).
123
+ if (process.versions.electron) return;
124
+
125
+ const wantEsm = esm && !armed.esm;
126
+ const wantCjs = cjs && !armed.cjs;
127
+ if (!wantEsm && !wantCjs) return;
128
+
129
+ const [major = 0, minor = 0] = process.versions.node
130
+ .split(".")
131
+ .map((n) => parseInt(n, 10));
132
+ if (major < 18 || (major === 18 && minor < 19)) {
133
+ process.stderr.write(
134
+ `The Nub loader requires Node 18.19 or newer; got ${process.versions.node}. Hooks are inactive.\n`,
135
+ );
136
+ return;
137
+ }
138
+
139
+ // The loader ships no polyfill packages, so the clobber map's synthetic
140
+ // modules (re-exports of globals the CLI runtime installs) would hand users
141
+ // `undefined`. A user who installed @js-temporal/polyfill or urlpattern-polyfill
142
+ // themselves must get the real package — clear the map before any hook runs.
143
+ core.CLOBBER_MAP.clear();
144
+
145
+ // No-op unless the nub CLI's watch mode spawned this process (it sets
146
+ // WATCH_REPORT_DEPENDENCIES); wiring it keeps `nub watch` restarts correct when
147
+ // the loader runs under it.
148
+ const watchReporting = common.installWatchReporting(core);
149
+
150
+ const hasSyncHooks = typeof module_.registerHooks === "function";
151
+ // On 22.15.0–24.11.0 an async `module.register` loader's resolveSync/loadSync
152
+ // are unimplemented stubs, so nub's sync hooks composing with a foreign async
153
+ // loader (tsx, ts-node, an OTel ESM attach) would crash resolution. Register
154
+ // via the async path there instead so both loaders compose all-async — the same
155
+ // tier decision the CLI preload makes, minus counting ourselves as foreign.
156
+ const forceAsync = common.nodeHookComposeBroken() && foreignAsyncLoaderPresent();
157
+
158
+ if (wantEsm) {
159
+ if (hasSyncHooks && !forceAsync) {
160
+ const { resolve, load } = common.makeHooks(core, watchReporting);
161
+ module_.registerHooks({ resolve, load });
162
+ armed.esmMode = "sync";
163
+ } else {
164
+ // Compat tier (18.19–22.14, 23.0–23.4) or forced-async composition: hooks
165
+ // run in a dedicated loader worker. It imports transform-core statically in
166
+ // its own thread and finds the addon via the inherited __NUB_ADDON_PATH.
167
+ // The data payload carries the clobber-map clear into that thread's OWN
168
+ // transform-core instance — the clear() above only reaches this realm's.
169
+ common.registerLoaderWorker("./preload-async-hooks.mjs", import.meta.url, {
170
+ data: { standaloneLoader: true },
171
+ });
172
+ armed.esmMode = "worker";
173
+ }
174
+ armed.esm = true;
175
+ }
176
+
177
+ if (wantCjs) {
178
+ // Classic require.extensions transpile is needed only where the sync
179
+ // registerHooks load hook does not already transpile require()'d TS: on the
180
+ // sync tier it does (and the classic shim would shadow native require(esm),
181
+ // throwing bogus ERR_REQUIRE_ESM on ESM `.ts` — see preload.cjs); elsewhere
182
+ // install it unless Node has native TypeScript (mirrors preload.mjs).
183
+ const classic = armed.esmMode === "sync" ? false : !process.features?.typescript;
184
+ common.installCjsRequireHooks(core, classic);
185
+ armed.cjs = true;
186
+ }
187
+
188
+ // Bounded transpile-cache eviction, same cheap-probe shape as the CLI entries:
189
+ // schedule the deferred sweep only on the once-a-day run where one is due.
190
+ if (core.sweepDue()) {
191
+ setImmediate(() => {
192
+ try {
193
+ core.maybeSweepCache();
194
+ } catch {}
195
+ });
196
+ }
197
+ }
package/loader-esm.mjs ADDED
@@ -0,0 +1,6 @@
1
+ // `node --import <pkg>/esm` — arms the ESM hook surface only (tsx/esm's shape).
2
+ // `import` of TS/JSX/data formats works; a bare `require()` of the same files is
3
+ // left to Node.
4
+ import { arm } from "./loader-entry.mjs";
5
+
6
+ arm({ esm: true, cjs: false });
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ // Standalone-loader addon location: platform → `@nubjs/loader-<platform>` package
3
+ // selection, plus the resolver that turns the selected package into an absolute
4
+ // `nub-native.node` path. Mirrors npm/nub/platform.js (same musl detection, same
5
+ // platform matrix) but for the loader's per-platform addon packages, which carry
6
+ // the ~6 MB N-API addon instead of the full CLI binary. CommonJS so both the ESM
7
+ // side-effect module (loader-addon-env.mjs) and any CJS entry can share it.
8
+
9
+ const PLATFORMS = {
10
+ "darwin-arm64": "@nubjs/loader-darwin-arm64",
11
+ "darwin-x64": "@nubjs/loader-darwin-x64",
12
+ "linux-x64": "@nubjs/loader-linux-x64",
13
+ "linux-x64-musl": "@nubjs/loader-linux-x64-musl",
14
+ "linux-arm64": "@nubjs/loader-linux-arm64",
15
+ "linux-arm64-musl": "@nubjs/loader-linux-arm64-musl",
16
+ "win32-x64": "@nubjs/loader-win32-x64",
17
+ "win32-arm64": "@nubjs/loader-win32-arm64",
18
+ };
19
+
20
+ // True on a musl Linux (Alpine, etc.). Primary signal: Node's own diagnostic
21
+ // report — `header.glibcVersionRuntime` is present on glibc and absent on musl.
22
+ // Fallback: `ldd --version`, whose merged output contains "musl" there (the
23
+ // stderr-only read shipped wrong once in npm/nub — check the merged output).
24
+ function isMusl() {
25
+ if (process.platform !== "linux") return false;
26
+ try {
27
+ const report = process.report.getReport();
28
+ const header = (typeof report === "string" ? JSON.parse(report) : report).header;
29
+ if (header && "glibcVersionRuntime" in header) {
30
+ return !header.glibcVersionRuntime;
31
+ }
32
+ } catch {
33
+ // process.report unavailable — fall through to ldd.
34
+ }
35
+ try {
36
+ const out = require("child_process").execSync("ldd --version 2>&1", { encoding: "utf8" });
37
+ return out.includes("musl");
38
+ } catch (e) {
39
+ const out = `${(e && e.stdout) || ""}${(e && e.stderr) || ""}`;
40
+ return out.includes("musl");
41
+ }
42
+ }
43
+
44
+ function platformKey() {
45
+ const base = `${process.platform}-${process.arch}`;
46
+ return isMusl() ? `${base}-musl` : base;
47
+ }
48
+
49
+ // Absolute path to this platform's `nub-native.node`, resolved from the loader
50
+ // package's own dependency tree via the caller-supplied `require` (created from a
51
+ // file inside the package, so the node_modules walk starts next to the platform
52
+ // packages regardless of hoisting). Returns null when the platform package is not
53
+ // installed — an unsupported platform, or optionalDependencies pruned.
54
+ function resolveAddonPath(requireFromPackage) {
55
+ const pkg = PLATFORMS[platformKey()];
56
+ if (!pkg) return null;
57
+ try {
58
+ return requireFromPackage.resolve(`${pkg}/nub-native.node`);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Make the addon reachable for every transform-core instance in this process tree
65
+ // by setting the internal `__NUB_ADDON_PATH` plumbing var (probed LAST by
66
+ // transform-core, after its relative candidates, so a nested nub CLI always wins
67
+ // with its own bundled addon). Worker threads inherit process.env, which is what
68
+ // carries the path into the compat tier's loader worker. A sibling
69
+ // `addons/nub-native.node` (the dev tree, or a bundled layout) means the relative
70
+ // probe wins anyway and no env is needed. Idempotent; safe to call from both the
71
+ // ESM side-effect module and the CJS `--require` fallback.
72
+ function ensureAddonEnv(requireFromPackage) {
73
+ try {
74
+ const { statSync } = require("node:fs");
75
+ const sibling = require("node:path").join(__dirname, "addons", "nub-native.node");
76
+ const s = statSync(sibling, { throwIfNoEntry: false });
77
+ if (s !== undefined && s.isFile()) return true;
78
+ } catch {
79
+ // fall through to the platform-package probe
80
+ }
81
+ // OVERWRITE an inherited value with our own resolution, never trust it first:
82
+ // the env var survives into child processes, and a nested process running a
83
+ // DIFFERENT version of this package must bind the addon shipped alongside its
84
+ // own JS, not the parent's — version-skewed addon and JS is the failure mode
85
+ // this file exists to avoid. The inherited value is used only as a last
86
+ // resort, when this package's own platform dependency is missing.
87
+ const resolved = resolveAddonPath(requireFromPackage);
88
+ if (resolved) {
89
+ process.env.__NUB_ADDON_PATH = resolved;
90
+ return true;
91
+ }
92
+ if (process.env.__NUB_ADDON_PATH) {
93
+ // Wrong-but-working is the one shape that must not be silent: the inherited
94
+ // addon may be a different version than this package's JS.
95
+ process.stderr.write(
96
+ `The Nub loader could not resolve its own native addon and is falling back to` +
97
+ ` an inherited one (${process.env.__NUB_ADDON_PATH}), which may be a different version.\n`,
98
+ );
99
+ return true;
100
+ }
101
+ process.stderr.write(
102
+ `The Nub loader could not find its native addon for ${process.platform}-${process.arch}` +
103
+ ` — the platform package may be missing (optionalDependencies pruned, or an` +
104
+ ` unsupported platform). TypeScript transpilation is inactive.\n`,
105
+ );
106
+ return false;
107
+ }
108
+
109
+ module.exports = { PLATFORMS, platformKey, resolveAddonPath, ensureAddonEnv };