@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 +21 -0
- package/README.md +48 -1
- package/cache-evict.mjs +180 -0
- package/floor-builtin.mjs +57 -0
- package/loader-addon-env.mjs +24 -0
- package/loader-entry.mjs +197 -0
- package/loader-esm.mjs +6 -0
- package/loader-platform.cjs +109 -0
- package/loader-register.cjs +33 -0
- package/loader-register.mjs +5 -0
- package/package.json +57 -3
- package/pnp-util.cjs +62 -0
- package/preload-async-hooks.mjs +133 -0
- package/preload-common.cjs +1725 -0
- package/transform-core.mjs +1028 -0
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
|
-
|
|
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.
|
package/cache-evict.mjs
ADDED
|
@@ -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);
|
package/loader-entry.mjs
ADDED
|
@@ -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,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 };
|