@telorun/kernel 0.9.2 → 0.11.0
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/dist/controller-loader.d.ts +11 -3
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +2 -2
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +128 -13
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +764 -216
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/generated/runtime-deps.json +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +57 -5
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +137 -23
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +50 -0
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -0
- package/dist/manifest-sources/local-manifest-cache-source.js +227 -0
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -0
- package/dist/resource-context.d.ts +1 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +3 -0
- package/dist/resource-context.js.map +1 -1
- package/package.json +16 -5
- package/src/controller-loader.ts +13 -3
- package/src/controller-loaders/npm-loader.ts +843 -229
- package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
- package/src/index.ts +6 -0
- package/src/kernel.ts +157 -22
- package/src/manifest-sources/local-manifest-cache-source.ts +256 -0
- package/src/resource-context.ts +4 -0
|
@@ -1,261 +1,809 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
|
-
import
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
3
|
import * as fs from "fs/promises";
|
|
4
|
+
import { createRequire } from "module";
|
|
4
5
|
import * as os from "os";
|
|
5
6
|
import { PackageURL } from "packageurl-js";
|
|
6
7
|
import * as path from "path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
9
|
import { promisify } from "util";
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
import { ControllerEnvMissingError } from "./napi-loader.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
13
|
+
/**
|
|
14
|
+
* Package-manager binary used for `<root>/.telo/npm/` installs. Captured once
|
|
15
|
+
* at module load so a mid-process env mutation can't change which binary
|
|
16
|
+
* runs for half an install batch — the kernel's controller installs are
|
|
17
|
+
* sequenced, but predictability beats late-binding here. Override via
|
|
18
|
+
* `TELO_PKG_MANAGER` (e.g. `pnpm`, `bun`).
|
|
19
|
+
*/
|
|
20
|
+
const PACKAGE_MANAGER = process.env.TELO_PKG_MANAGER ?? "npm";
|
|
21
|
+
/**
|
|
22
|
+
* Maximum age before a held lock is considered abandoned. `npm install` on a
|
|
23
|
+
* cold cache for a tree with one or two controllers is comfortably under a
|
|
24
|
+
* minute on modern hardware; tuning higher would let zombie locks persist.
|
|
25
|
+
*/
|
|
26
|
+
const LOCK_STALE_MS = 60000;
|
|
27
|
+
/**
|
|
28
|
+
* Total wall-clock cap for waiting on the install lock — enough for a slow
|
|
29
|
+
* first install on a peer process to finish, short enough that a deadlocked
|
|
30
|
+
* CI job fails loudly rather than hanging for hours. The retry interval
|
|
31
|
+
* trades wakeup latency vs. wasted polls; 500ms is well below the lock
|
|
32
|
+
* holder's typical hold time.
|
|
33
|
+
*/
|
|
34
|
+
const LOCK_WAIT_MAX_MS = 5 * 60000;
|
|
35
|
+
const LOCK_RETRY_MS = 500;
|
|
36
|
+
/**
|
|
37
|
+
* The npm-loader maintains a single install root per kernel process at
|
|
38
|
+
* `<entry-manifest-dir>/.telo/npm/`. Every controller — registry tag or
|
|
39
|
+
* `local_path` — is installed via `npm install <spec>` into this root, then
|
|
40
|
+
* imported from `<root>/node_modules/<pkg>`. This collapses two parallel
|
|
41
|
+
* module realms (kernel-side @telorun/sdk vs. controller-side @telorun/sdk)
|
|
42
|
+
* into one: the kernel's own SDK is wired in as a `file:` dep, npm/pnpm
|
|
43
|
+
* symlink it, and Node's ESM resolver follows the symlink to the same
|
|
44
|
+
* realpath as the kernel — so `Stream` (and any other class-identity-sensitive
|
|
45
|
+
* type) has a single constructor across the process.
|
|
46
|
+
*
|
|
47
|
+
* Per-kernel state lives on each `NpmControllerLoader` instance (one is
|
|
48
|
+
* constructed per `ControllerLoader`, which is itself constructed per
|
|
49
|
+
* `Telo.Definition.init`). The instance caches the install-root materialization
|
|
50
|
+
* promise (`rootReady`), the set of installed specs (`installedSpecs`), an
|
|
51
|
+
* in-flight install map for single-flight dedupe (`inFlight`), and a snapshot
|
|
52
|
+
* of the install root's `dependencies` map (`rootDeps`) so per-controller
|
|
53
|
+
* fast paths can hit without re-reading `package.json`. Cross-kernel and
|
|
54
|
+
* cross-process safety is provided by a filesystem lock on `<root>/.lock`.
|
|
55
|
+
*/
|
|
14
56
|
export class NpmControllerLoader {
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
/**
|
|
59
|
+
* Per-process cache of "this controller's package + version is already
|
|
60
|
+
* installed in the root and matches our spec" so concurrent loads of the
|
|
61
|
+
* same definition skip the lock + re-check round-trip after the first
|
|
62
|
+
* caller wins. Keyed by absolute spec (e.g. `@telorun/sdk@file:/abs/path`
|
|
63
|
+
* or `@scope/pkg@^1.2.3`).
|
|
64
|
+
*/
|
|
65
|
+
this.installedSpecs = new Set();
|
|
66
|
+
/**
|
|
67
|
+
* In-flight installs keyed by canonical spec. Two callers racing for the
|
|
68
|
+
* same spec share one `npm install <spec>` invocation rather than each
|
|
69
|
+
* acquiring the fs-lock and reinstalling.
|
|
70
|
+
*/
|
|
71
|
+
this.inFlight = new Map();
|
|
72
|
+
/**
|
|
73
|
+
* Snapshot of the install root's `dependencies` map at the time of last
|
|
74
|
+
* read. Set during `materializeInstallRoot()` and refreshed when an
|
|
75
|
+
* install adds a new spec. Lets the per-controller fast path decide on
|
|
76
|
+
* "already installed?" without re-reading and re-parsing `package.json`
|
|
77
|
+
* for every load — a single test typically touches 5–10 controllers,
|
|
78
|
+
* and a test suite spawns one Kernel per fixture, so the multiplier is
|
|
79
|
+
* `tests * controllers` file reads otherwise.
|
|
80
|
+
*/
|
|
81
|
+
this.rootDeps = {};
|
|
82
|
+
this.entryUrl = options.entryUrl;
|
|
83
|
+
}
|
|
84
|
+
async load(purl, baseUri) {
|
|
85
|
+
const parsed = PackageURL.fromString(purl);
|
|
86
|
+
if (!parsed.name) {
|
|
87
|
+
throw new Error(`Invalid PURL '${purl}': missing package name`);
|
|
88
|
+
}
|
|
89
|
+
const packageName = parsed.namespace ? `${parsed.namespace}/${parsed.name}` : parsed.name;
|
|
90
|
+
const installRoot = await this.ensureInstallRoot();
|
|
91
|
+
const resolved = await resolveInstallSpec(parsed, packageName, baseUri);
|
|
92
|
+
const source = await this.installPackage(installRoot, packageName, resolved.spec, resolved.kind);
|
|
93
|
+
const instance = await loadFromInstall(installRoot, packageName, parsed.subpath ?? null, purl);
|
|
94
|
+
return { instance, source };
|
|
95
|
+
}
|
|
15
96
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
97
|
+
* Compute the install root from the entry URL, write a baseline package.json
|
|
98
|
+
* pinning the kernel's own runtime deps as `file:` references, run a single
|
|
99
|
+
* `npm install` once, and return the absolute root path. Memoized for the
|
|
100
|
+
* lifetime of this loader instance.
|
|
19
101
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
packageRoot = nodeModulesPath;
|
|
40
|
-
source = "node_modules";
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
source = await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
|
|
44
|
-
packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
102
|
+
ensureInstallRoot() {
|
|
103
|
+
if (this.rootReady)
|
|
104
|
+
return this.rootReady;
|
|
105
|
+
this.rootReady = this.materializeInstallRoot().catch((err) => {
|
|
106
|
+
// Reset on failure so a follow-up call retries rather than caches the rejection.
|
|
107
|
+
this.rootReady = undefined;
|
|
108
|
+
throw err;
|
|
109
|
+
});
|
|
110
|
+
return this.rootReady;
|
|
111
|
+
}
|
|
112
|
+
async materializeInstallRoot() {
|
|
113
|
+
if (!this.entryUrl) {
|
|
114
|
+
// Throw the env-missing variant so a mixed candidate list (e.g.
|
|
115
|
+
// `pkg:npm/... + pkg:cargo/...`) still falls back to the next
|
|
116
|
+
// candidate. A plain Error would abort the whole resolve chain;
|
|
117
|
+
// callers that intentionally drive the napi loader without supplying
|
|
118
|
+
// an entry URL would otherwise see a hard failure.
|
|
119
|
+
throw new ControllerEnvMissingError("NpmControllerLoader requires an entryUrl. Pass one to the constructor (kernel: " +
|
|
120
|
+
"Kernel.load() records this; CLI: pass the manifest path).");
|
|
47
121
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
122
|
+
const entryUrlStr = this.entryUrl;
|
|
123
|
+
// The install root is anchored next to the entry manifest on disk. For an
|
|
124
|
+
// http(s):// entry URL there is no such anchor — `path.resolve` would
|
|
125
|
+
// silently turn `http://host/x.yaml` into something like
|
|
126
|
+
// `<cwd>/http:/host/x.yaml`, materializing a `.telo/npm` tree in an
|
|
127
|
+
// unrelated directory. Reject the case loudly until we ship an explicit
|
|
128
|
+
// strategy (e.g. a hash-keyed cache under `~/.cache/telo`) for HTTP-sourced
|
|
129
|
+
// manifests; today nothing in the workspace exercises this path.
|
|
130
|
+
const entryPath = parseFileUrlOrThrow(entryUrlStr);
|
|
131
|
+
const entryDir = path.dirname(path.resolve(entryPath));
|
|
132
|
+
const installRoot = path.join(entryDir, ".telo", "npm");
|
|
133
|
+
// Build the install-root package.json: kernel-runtime deps as `file:` refs,
|
|
134
|
+
// `overrides` + `pnpm.overrides` pinning every name to `$<name>`. This is
|
|
135
|
+
// the realm-collapse mechanism — npm's `file:` symlinks the kernel's own
|
|
136
|
+
// package locations, the resolver follows symlinks to the realpath, and
|
|
137
|
+
// every controller transitively resolving these names lands on the
|
|
138
|
+
// identical module instance the kernel itself is using.
|
|
139
|
+
const runtimeDeps = await loadRuntimeDeps();
|
|
140
|
+
const dependencies = {};
|
|
141
|
+
const overrides = {};
|
|
142
|
+
for (const name of runtimeDeps) {
|
|
143
|
+
const resolvedPkgRoot = await resolveKernelPackageRoot(name);
|
|
144
|
+
if (!resolvedPkgRoot) {
|
|
145
|
+
// A kernel runtime dep that can't be resolved at boot is unusual but
|
|
146
|
+
// not fatal — the realm-collapse story degrades to "rely on registry
|
|
147
|
+
// resolution + overrides" for that name. Don't crash the loader.
|
|
148
|
+
continue;
|
|
57
149
|
}
|
|
150
|
+
dependencies[name] = `file:${resolvedPkgRoot}`;
|
|
151
|
+
overrides[name] = `$${name}`;
|
|
58
152
|
}
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
153
|
+
const packageJson = {
|
|
154
|
+
name: "telo-runtime-install",
|
|
155
|
+
private: true,
|
|
156
|
+
version: "0.0.0",
|
|
157
|
+
dependencies,
|
|
158
|
+
overrides,
|
|
159
|
+
pnpm: { overrides },
|
|
160
|
+
};
|
|
161
|
+
const packageJsonPath = path.join(installRoot, "package.json");
|
|
162
|
+
const stateFile = path.join(installRoot, ".telo-state.json");
|
|
163
|
+
const newHash = sha256(JSON.stringify(packageJson));
|
|
164
|
+
await fs.mkdir(installRoot, { recursive: true });
|
|
165
|
+
// Lock-free fast path: if the on-disk state matches what we'd write,
|
|
166
|
+
// there's nothing to install and nothing to serialize. Every fresh
|
|
167
|
+
// Kernel re-enters this code path (e.g. test suites that spawn one
|
|
168
|
+
// Kernel per test), so paying even an `fs.open(.lock, 'wx')` here per
|
|
169
|
+
// test is measurable. Reads-only checks are safe without the lock —
|
|
170
|
+
// the writer side updates package.json + .telo-state.json + node_modules
|
|
171
|
+
// under a held lock, so any read that observes a matching hash plus
|
|
172
|
+
// an existing node_modules has observed a fully materialized tree.
|
|
173
|
+
if ((await readJsonField(stateFile, "rootHash")) === newHash &&
|
|
174
|
+
(await pathExists(path.join(installRoot, "node_modules")))) {
|
|
175
|
+
// Lock-free fast path: nothing to do beyond seeding the in-process caches.
|
|
176
|
+
await this.seedDepCaches(installRoot, dependencies);
|
|
177
|
+
return installRoot;
|
|
63
178
|
}
|
|
64
|
-
|
|
179
|
+
await withInstallLock(installRoot, async () => {
|
|
180
|
+
// Re-check inside the lock: a peer may have completed the install
|
|
181
|
+
// between the fast-path miss and our acquisition.
|
|
182
|
+
const existingHash = await readJsonField(stateFile, "rootHash");
|
|
183
|
+
if (existingHash === newHash && (await pathExists(path.join(installRoot, "node_modules")))) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
|
|
187
|
+
await runPackageManager(installRoot, ["install", "--no-audit", "--no-fund", "--silent"]);
|
|
188
|
+
await fs.writeFile(stateFile, JSON.stringify({ rootHash: newHash }, null, 2) + "\n");
|
|
189
|
+
});
|
|
190
|
+
await this.seedDepCaches(installRoot, dependencies);
|
|
191
|
+
return installRoot;
|
|
65
192
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Populate the in-process caches that drive the per-controller fast path:
|
|
195
|
+
* `installedSpecs` (so duplicate calls from the same Kernel return immediately)
|
|
196
|
+
* and `rootDeps` (so the fast path doesn't re-read `package.json` per load).
|
|
197
|
+
* `dependencies` is the freshly-built map we know is on disk; it's used as a
|
|
198
|
+
* fallback when the actual file-read fails (e.g. immediately after a hot
|
|
199
|
+
* reload where the writer is still racing).
|
|
200
|
+
*/
|
|
201
|
+
async seedDepCaches(installRoot, dependencies) {
|
|
202
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
203
|
+
this.installedSpecs.add(`${name}@${spec}`);
|
|
204
|
+
}
|
|
205
|
+
this.rootDeps = (await readPackageDeps(installRoot)) ?? { ...dependencies };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Install one controller spec into the existing root. Single-flight per
|
|
209
|
+
* spec within the process; cross-process safety is the fs-lock around the
|
|
210
|
+
* `npm install` call. If the spec is already present in the manifest's
|
|
211
|
+
* package.json (under `dependencies`), skip — reusing the previous install.
|
|
212
|
+
*
|
|
213
|
+
* `kind` distinguishes a `local_path` source (loader synthesized `file:`
|
|
214
|
+
* spec) from a registry tag. The CLI silences progress for both `cache`
|
|
215
|
+
* and `local`; `npm-install` is the only event surfaced. Folding this
|
|
216
|
+
* decision in here means the caller no longer needs to override the
|
|
217
|
+
* returned source after the fact.
|
|
218
|
+
*/
|
|
219
|
+
async installPackage(installRoot, packageName, spec, kind) {
|
|
220
|
+
const cacheKey = `${packageName}@${spec}`;
|
|
221
|
+
if (this.installedSpecs.has(cacheKey))
|
|
222
|
+
return "cache";
|
|
223
|
+
const inFlight = this.inFlight.get(cacheKey);
|
|
224
|
+
if (inFlight) {
|
|
225
|
+
await inFlight;
|
|
226
|
+
return "cache";
|
|
227
|
+
}
|
|
228
|
+
// Lock-free fast path: consult the in-process snapshot of the install
|
|
229
|
+
// root's `dependencies` map (seeded by `materializeInstallRoot`). If the
|
|
230
|
+
// spec matches what's already wired in and the package directory exists,
|
|
231
|
+
// there's nothing to do — no lock, no fork, no per-load file read. This
|
|
232
|
+
// is the dominant case for warm test suites: every Kernel touches the
|
|
233
|
+
// same controllers and a fresh-on-disk read per (Kernel × controller)
|
|
234
|
+
// dominates wall time even when the actual installs are already done.
|
|
235
|
+
//
|
|
236
|
+
// Normalize specs before comparing because npm rewrites absolute `file:`
|
|
237
|
+
// deps to relative paths inside the install root's `package.json`. The
|
|
238
|
+
// loader passes absolute paths (resolved against the declaring library's
|
|
239
|
+
// baseUri); the on-disk record is `file:../../foo`. Without normalization
|
|
240
|
+
// every fast-path read is a string-mismatch and the loader would fall
|
|
241
|
+
// through to a real `npm install` per controller per Kernel — turning a
|
|
242
|
+
// 1ms hit into a 150–200ms one.
|
|
243
|
+
const targetPath = path.join(installRoot, "node_modules", ...packageName.split("/"));
|
|
244
|
+
const cachedSpec = this.rootDeps[packageName];
|
|
245
|
+
if (cachedSpec !== undefined &&
|
|
246
|
+
normalizeFileSpec(cachedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
|
|
247
|
+
(await pathExists(targetPath))) {
|
|
248
|
+
this.installedSpecs.add(cacheKey);
|
|
73
249
|
return "cache";
|
|
74
250
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
251
|
+
const work = (async () => {
|
|
252
|
+
await withInstallLock(installRoot, async () => {
|
|
253
|
+
// Re-check inside the lock: a peer process may have installed the
|
|
254
|
+
// spec between the fast-path miss and our acquisition. Normalize
|
|
255
|
+
// the on-disk record to absolute form before comparing — npm
|
|
256
|
+
// rewrites `file:` deps to be relative to the install root.
|
|
257
|
+
const lockedSpec = await readDepSpec(installRoot, packageName);
|
|
258
|
+
if (lockedSpec !== undefined &&
|
|
259
|
+
normalizeFileSpec(lockedSpec, installRoot) === normalizeFileSpec(spec, installRoot) &&
|
|
260
|
+
(await pathExists(targetPath))) {
|
|
261
|
+
this.rootDeps[packageName] = lockedSpec;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
await runPackageManager(installRoot, [
|
|
265
|
+
"install",
|
|
266
|
+
"--no-audit",
|
|
267
|
+
"--no-fund",
|
|
268
|
+
"--silent",
|
|
269
|
+
"--save",
|
|
270
|
+
spec,
|
|
271
|
+
]);
|
|
272
|
+
// Re-read what npm actually wrote (it normalizes `file:` paths to
|
|
273
|
+
// be relative to the install root). Caching the spec in its on-disk
|
|
274
|
+
// form keeps subsequent fast-path comparisons stable.
|
|
275
|
+
const written = await readDepSpec(installRoot, packageName);
|
|
276
|
+
if (written !== undefined)
|
|
277
|
+
this.rootDeps[packageName] = written;
|
|
278
|
+
else
|
|
279
|
+
this.rootDeps[packageName] = spec;
|
|
280
|
+
});
|
|
281
|
+
})();
|
|
282
|
+
this.inFlight.set(cacheKey, work);
|
|
283
|
+
try {
|
|
284
|
+
await work;
|
|
79
285
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"install",
|
|
83
|
-
"--no-audit",
|
|
84
|
-
"--no-fund",
|
|
85
|
-
"--silent",
|
|
86
|
-
"--prefix",
|
|
87
|
-
installDir,
|
|
88
|
-
packageSpec,
|
|
89
|
-
];
|
|
90
|
-
await execFileAsync("npm", args);
|
|
91
|
-
return "npm-install";
|
|
92
|
-
}
|
|
93
|
-
getPackageName(packageSpec) {
|
|
94
|
-
if (packageSpec.startsWith("@")) {
|
|
95
|
-
const lastAt = packageSpec.lastIndexOf("@");
|
|
96
|
-
return lastAt > 0 ? packageSpec.slice(0, lastAt) : packageSpec;
|
|
286
|
+
finally {
|
|
287
|
+
this.inFlight.delete(cacheKey);
|
|
97
288
|
}
|
|
98
|
-
|
|
99
|
-
|
|
289
|
+
this.installedSpecs.add(cacheKey);
|
|
290
|
+
// First-touch local installs report `local` (silenced in CLI progress);
|
|
291
|
+
// fresh registry installs report `npm-install` (the only branch a
|
|
292
|
+
// user-facing "downloading…" line should ever surface for).
|
|
293
|
+
return kind === "local" ? "local" : "npm-install";
|
|
100
294
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Read the realm-collapse name list shipped with the kernel under
|
|
298
|
+
* `dist/generated/runtime-deps.json`. The list is small and stable (today:
|
|
299
|
+
* `@telorun/sdk`); see `scripts/generate-runtime-deps.mjs` for the rationale
|
|
300
|
+
* about which packages belong here. Returns an empty array if the file is
|
|
301
|
+
* unreadable — the npm-loader then degrades to "no realm collapse," which is
|
|
302
|
+
* still a working install path; only `Stream`-flavoured class-identity bugs
|
|
303
|
+
* resurface.
|
|
304
|
+
*/
|
|
305
|
+
async function loadRuntimeDeps() {
|
|
306
|
+
const here = fileURLToPath(import.meta.url);
|
|
307
|
+
// Walk up from this file's location to the kernel-package root (the dir
|
|
308
|
+
// that contains package.json). In dev: `kernel/nodejs/`. In published:
|
|
309
|
+
// the installed package root inside `node_modules/@telorun/kernel/`.
|
|
310
|
+
// Walk-until-root rather than a fixed depth — the directory tree depth
|
|
311
|
+
// depends on whether we're in a workspace or installed tree.
|
|
312
|
+
const pkgDir = await walkUpToPackageRoot(path.dirname(here));
|
|
313
|
+
if (!pkgDir)
|
|
314
|
+
return [];
|
|
315
|
+
const generated = path.join(pkgDir, "dist", "generated", "runtime-deps.json");
|
|
316
|
+
if (!(await pathExists(generated)))
|
|
317
|
+
return [];
|
|
318
|
+
// The file is generated by `scripts/generate-runtime-deps.mjs`; if it
|
|
319
|
+
// exists but is malformed, that's a kernel-build bug. Don't swallow —
|
|
320
|
+
// surface so the cause is debuggable. Realm collapse is the whole point
|
|
321
|
+
// of this code path; quietly degrading to "no realm collapse" would
|
|
322
|
+
// silently re-introduce the very bug the file fixes.
|
|
323
|
+
const data = JSON.parse(await fs.readFile(generated, "utf8"));
|
|
324
|
+
if (!Array.isArray(data?.names)) {
|
|
325
|
+
throw new Error(`[telo] ${generated} is malformed: expected { names: string[] } at top level. ` +
|
|
326
|
+
`Re-run \`node scripts/generate-runtime-deps.mjs <pkg-dir>\` to regenerate.`);
|
|
104
327
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
328
|
+
return data.names;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Walk up from `from` until the directory contains a `package.json`.
|
|
332
|
+
* Returns null at filesystem root if none found.
|
|
333
|
+
*/
|
|
334
|
+
async function walkUpToPackageRoot(from) {
|
|
335
|
+
let dir = from;
|
|
336
|
+
while (true) {
|
|
337
|
+
if (await pathExists(path.join(dir, "package.json")))
|
|
338
|
+
return dir;
|
|
339
|
+
const parent = path.dirname(dir);
|
|
340
|
+
if (parent === dir)
|
|
341
|
+
return null;
|
|
342
|
+
dir = parent;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Resolve a kernel-runtime dep name to the realpath of its package directory.
|
|
347
|
+
* Anchored on this module so resolution mirrors the kernel's own.
|
|
348
|
+
*
|
|
349
|
+
* Uses two strategies because well-encapsulated packages (e.g. `@telorun/sdk`)
|
|
350
|
+
* declare a strict `exports` map that doesn't expose `./package.json`:
|
|
351
|
+
* 1. Try `require.resolve("<name>/package.json")` directly — works for
|
|
352
|
+
* packages without an exports map or with a permissive one.
|
|
353
|
+
* 2. Fall back to resolving the package's main entry and walking up to
|
|
354
|
+
* the nearest `package.json` whose `name` field matches.
|
|
355
|
+
*
|
|
356
|
+
* Returns null when neither strategy locates the package — `require.resolve`
|
|
357
|
+
* itself throws `MODULE_NOT_FOUND` if the package isn't installed at all,
|
|
358
|
+
* which we treat as "no realm-collapse for this name." Other errors (e.g. a
|
|
359
|
+
* corrupt package.json) propagate so the caller can surface the real cause.
|
|
360
|
+
*/
|
|
361
|
+
async function resolveKernelPackageRoot(name) {
|
|
362
|
+
try {
|
|
363
|
+
const pkgJsonPath = requireFromHere.resolve(`${name}/package.json`);
|
|
364
|
+
return path.dirname(pkgJsonPath);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (err?.code !== "MODULE_NOT_FOUND" && err?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
|
368
|
+
throw err;
|
|
114
369
|
}
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
370
|
+
// exports map withholds package.json — fall through to mainEntry strategy.
|
|
371
|
+
}
|
|
372
|
+
let mainEntry;
|
|
373
|
+
try {
|
|
374
|
+
mainEntry = requireFromHere.resolve(name);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
if (err?.code === "MODULE_NOT_FOUND")
|
|
378
|
+
return null;
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
let dir = path.dirname(mainEntry);
|
|
382
|
+
while (true) {
|
|
383
|
+
const pkgJsonPath = path.join(dir, "package.json");
|
|
384
|
+
if (await pathExists(pkgJsonPath)) {
|
|
385
|
+
// Confirm the package.json's `name` field matches — we may have walked
|
|
386
|
+
// past the target into a parent's package.json. A malformed package.json
|
|
387
|
+
// here is a real bug (a corrupted node_modules) so let it propagate.
|
|
388
|
+
const pkg = JSON.parse(await fs.readFile(pkgJsonPath, "utf8"));
|
|
389
|
+
if (pkg?.name === name)
|
|
390
|
+
return dir;
|
|
130
391
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
392
|
+
const parent = path.dirname(dir);
|
|
393
|
+
if (parent === dir)
|
|
394
|
+
return null;
|
|
395
|
+
dir = parent;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Acquire a process-portable lock on `<root>/.lock` and execute fn while
|
|
400
|
+
* holding it. Implementation: `fs.open(path, 'wx')` is atomic on POSIX and
|
|
401
|
+
* Windows; concurrent processes serialize naturally. PID + start time live
|
|
402
|
+
* inside the lock file so a crashed-holder lock can be detected and reclaimed.
|
|
403
|
+
*
|
|
404
|
+
* The lock guards the install-root manifest write, the package-manager
|
|
405
|
+
* invocation, and any state-file writes. It does NOT serialize *reads* of
|
|
406
|
+
* already-installed controllers — those run lock-free against a stable tree.
|
|
407
|
+
*/
|
|
408
|
+
async function withInstallLock(installRoot, fn) {
|
|
409
|
+
const lockPath = path.join(installRoot, ".lock");
|
|
410
|
+
await fs.mkdir(installRoot, { recursive: true });
|
|
411
|
+
const lockBody = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now() });
|
|
412
|
+
let handle = null;
|
|
413
|
+
const waitedSince = Date.now();
|
|
414
|
+
while (true) {
|
|
415
|
+
try {
|
|
416
|
+
handle = await fs.open(lockPath, "wx");
|
|
417
|
+
await handle.writeFile(lockBody);
|
|
418
|
+
break;
|
|
138
419
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (err?.code !== "EEXIST")
|
|
422
|
+
throw err;
|
|
423
|
+
// Lock exists. Inspect its mtime + PID. If older than LOCK_STALE_MS and
|
|
424
|
+
// the holding PID isn't alive (or is on another host), reclaim.
|
|
425
|
+
if (await isLockStale(lockPath)) {
|
|
426
|
+
await fs.rm(lockPath, { force: true });
|
|
427
|
+
continue;
|
|
145
428
|
}
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
429
|
+
if (Date.now() - waitedSince > LOCK_WAIT_MAX_MS) {
|
|
430
|
+
throw new Error(`[telo] timed out waiting for install lock at ${lockPath} ` +
|
|
431
|
+
`(held >${LOCK_WAIT_MAX_MS / 60000} min). ` +
|
|
432
|
+
`Inspect the lock file or remove it manually if no other Telo process is running.`);
|
|
151
433
|
}
|
|
434
|
+
await sleep(LOCK_RETRY_MS);
|
|
152
435
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
const directPath = path.resolve(packageRoot, entryValue);
|
|
172
|
-
if (await this.pathExists(directPath)) {
|
|
173
|
-
return this.resolveForRuntime(directPath, packageRoot);
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
return await fn();
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
// The fd close races nothing important: if it fails, the FD is reaped on
|
|
442
|
+
// process exit. The unlink is the dangerous one — a non-ENOENT failure
|
|
443
|
+
// (permissions, read-only mount) means every subsequent kernel waits
|
|
444
|
+
// LOCK_STALE_MS before reclaiming. Surface it so the cause is visible
|
|
445
|
+
// rather than hiding behind a silent five-minute hang.
|
|
446
|
+
await handle.close().catch(() => { });
|
|
447
|
+
try {
|
|
448
|
+
await fs.rm(lockPath, { force: true });
|
|
174
449
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return withJs;
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (err?.code !== "ENOENT") {
|
|
452
|
+
process.stderr.write(`[telo] failed to release install lock at ${lockPath}: ${err?.message ?? err}\n`);
|
|
179
453
|
}
|
|
180
454
|
}
|
|
181
|
-
throw new Error(`Controller entry "${entryValue}" could not be resolved in ${packageRoot}`);
|
|
182
455
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const target = exportsField[key];
|
|
189
|
-
return this.resolveExportTargetValue(target);
|
|
456
|
+
}
|
|
457
|
+
async function isLockStale(lockPath) {
|
|
458
|
+
let stat;
|
|
459
|
+
try {
|
|
460
|
+
stat = await fs.stat(lockPath);
|
|
190
461
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
462
|
+
catch (err) {
|
|
463
|
+
// Race: lock vanished while we inspected it. The next open() will succeed.
|
|
464
|
+
if (err?.code === "ENOENT")
|
|
465
|
+
return false;
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
const age = Date.now() - stat.mtimeMs;
|
|
469
|
+
if (age < LOCK_STALE_MS)
|
|
470
|
+
return false;
|
|
471
|
+
let body;
|
|
472
|
+
try {
|
|
473
|
+
body = await fs.readFile(lockPath, "utf8");
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
if (err?.code === "ENOENT")
|
|
477
|
+
return false;
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
// A zero-byte or unparseable lock file is interpreted as stale: a previous
|
|
481
|
+
// holder crashed mid-write (empty body) or got partially flushed (truncated
|
|
482
|
+
// JSON). Treating either as held would deadlock; throwing here would block
|
|
483
|
+
// every controller load behind a broken file the operator may not even
|
|
484
|
+
// notice exists.
|
|
485
|
+
if (!body)
|
|
486
|
+
return true;
|
|
487
|
+
let parsed;
|
|
488
|
+
try {
|
|
489
|
+
parsed = JSON.parse(body);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
if (!parsed?.pid)
|
|
495
|
+
return true;
|
|
496
|
+
if (parsed.host && parsed.host !== os.hostname())
|
|
497
|
+
return false; // different host: assume held
|
|
498
|
+
try {
|
|
499
|
+
// signal 0 throws if the PID isn't alive (or is owned by a different user)
|
|
500
|
+
process.kill(parsed.pid, 0);
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
// ESRCH = no such process. EPERM = exists but we can't signal it; treat as alive.
|
|
505
|
+
return err?.code === "ESRCH";
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function runPackageManager(cwd, args) {
|
|
509
|
+
try {
|
|
510
|
+
await execFileAsync(PACKAGE_MANAGER, args, { cwd, maxBuffer: 32 * 1024 * 1024 });
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
const isMissing = err?.code === "ENOENT" ||
|
|
514
|
+
/not found|command not recognized/i.test(err?.message ?? "");
|
|
515
|
+
if (isMissing) {
|
|
516
|
+
throw new Error(`[telo] '${PACKAGE_MANAGER}' not found on PATH. Telo's controller installer requires a ` +
|
|
517
|
+
`JavaScript package manager (npm or pnpm). Install Node.js (which bundles npm) or set ` +
|
|
518
|
+
`TELO_PKG_MANAGER to a different binary name.`);
|
|
206
519
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
520
|
+
const stderr = err?.stderr ? `\n${err.stderr}` : "";
|
|
521
|
+
throw new Error(`[telo] '${PACKAGE_MANAGER} ${args.join(" ")}' failed in ${cwd}:${stderr}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async function readJsonField(filePath, field) {
|
|
525
|
+
try {
|
|
526
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
527
|
+
const parsed = JSON.parse(text);
|
|
528
|
+
return parsed?.[field];
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Read the install root's `dependencies[<packageName>]` value, or undefined
|
|
536
|
+
* if package.json is missing/unreadable or the dep isn't listed. Used by the
|
|
537
|
+
* lock-free fast path to decide whether a controller is already wired in.
|
|
538
|
+
*/
|
|
539
|
+
async function readDepSpec(installRoot, packageName) {
|
|
540
|
+
try {
|
|
541
|
+
const text = await fs.readFile(path.join(installRoot, "package.json"), "utf8");
|
|
542
|
+
const pkg = JSON.parse(text);
|
|
543
|
+
const value = pkg?.dependencies?.[packageName];
|
|
544
|
+
return typeof value === "string" ? value : undefined;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Convert an entry-manifest URL to its on-disk path, throwing a descriptive
|
|
552
|
+
* error for any URL scheme that doesn't map to a local filesystem location.
|
|
553
|
+
* The install-root anchoring story requires a real directory next to the
|
|
554
|
+
* manifest; non-file schemes (e.g. `http://`, `https://`) have no such
|
|
555
|
+
* anchor and would otherwise silently produce a junk path via
|
|
556
|
+
* `path.resolve("http://host/x")`.
|
|
557
|
+
*
|
|
558
|
+
* Bare paths without a scheme are accepted as-is — callers that hand the
|
|
559
|
+
* loader an absolute filesystem path are common and there's no ambiguity
|
|
560
|
+
* to surface.
|
|
561
|
+
*/
|
|
562
|
+
function parseFileUrlOrThrow(entryUrl) {
|
|
563
|
+
if (entryUrl.startsWith("file://"))
|
|
564
|
+
return fileURLToPath(entryUrl);
|
|
565
|
+
// A scheme is anything before the first `://` — distinguish a URL from a
|
|
566
|
+
// bare filesystem path (which has no `://`).
|
|
567
|
+
const schemeMatch = entryUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
|
|
568
|
+
if (schemeMatch) {
|
|
569
|
+
// Env-missing rather than a hard error: the dispatcher should advance
|
|
570
|
+
// to the next candidate when an HTTP-sourced manifest is paired with a
|
|
571
|
+
// `pkg:cargo` (or other non-npm) fallback. Hard-failing would lock the
|
|
572
|
+
// whole resolve chain to this branch.
|
|
573
|
+
throw new ControllerEnvMissingError(`[telo] entry URL scheme '${schemeMatch[1]}' is not supported by the npm controller ` +
|
|
574
|
+
`loader. The install root must live next to a local manifest; HTTP-sourced manifests ` +
|
|
575
|
+
`have no such anchor. Resolve the manifest to disk first, or use file:// directly. ` +
|
|
576
|
+
`(entryUrl: ${entryUrl})`);
|
|
577
|
+
}
|
|
578
|
+
return entryUrl;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Normalize a `file:` spec to an absolute path so two specs that point at the
|
|
582
|
+
* same source — one absolute (what the loader synthesizes from `local_path`)
|
|
583
|
+
* and one relative (what npm rewrites into the install root's package.json) —
|
|
584
|
+
* compare equal. Specs that aren't `file:` deps pass through unchanged, since
|
|
585
|
+
* registry tags compare exactly.
|
|
586
|
+
*/
|
|
587
|
+
function normalizeFileSpec(spec, installRoot) {
|
|
588
|
+
if (!spec.startsWith("file:"))
|
|
589
|
+
return spec;
|
|
590
|
+
const filePath = spec.slice("file:".length);
|
|
591
|
+
if (path.isAbsolute(filePath))
|
|
592
|
+
return `file:${path.resolve(filePath)}`;
|
|
593
|
+
return `file:${path.resolve(installRoot, filePath)}`;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Snapshot the install root's full `dependencies` map so the per-controller
|
|
597
|
+
* fast path can lookup specs without re-reading package.json on every load.
|
|
598
|
+
* Returns null when the file is missing or unreadable; callers decide whether
|
|
599
|
+
* to seed from a synthesized map instead.
|
|
600
|
+
*/
|
|
601
|
+
async function readPackageDeps(installRoot) {
|
|
602
|
+
try {
|
|
603
|
+
const text = await fs.readFile(path.join(installRoot, "package.json"), "utf8");
|
|
604
|
+
const pkg = JSON.parse(text);
|
|
605
|
+
const deps = pkg?.dependencies;
|
|
606
|
+
if (!deps || typeof deps !== "object")
|
|
607
|
+
return {};
|
|
608
|
+
const out = {};
|
|
609
|
+
for (const [k, v] of Object.entries(deps)) {
|
|
610
|
+
if (typeof v === "string")
|
|
611
|
+
out[k] = v;
|
|
219
612
|
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
220
616
|
return null;
|
|
221
617
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
618
|
+
}
|
|
619
|
+
function sha256(s) {
|
|
620
|
+
return crypto.createHash("sha256").update(s).digest("hex");
|
|
621
|
+
}
|
|
622
|
+
async function pathExists(filePath) {
|
|
623
|
+
try {
|
|
624
|
+
await fs.access(filePath);
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function sleep(ms) {
|
|
632
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Decide what `npm install <spec>` should look like for this PURL. Returns
|
|
636
|
+
* a discriminated union so the caller and the installer agree on the kind
|
|
637
|
+
* of install without re-deriving it from the spec string. `local_path`
|
|
638
|
+
* resolves relative to the *declaring library's* baseUri (each
|
|
639
|
+
* Telo.Definition's manifest URL); the loader installs the absolute path
|
|
640
|
+
* via `npm install file:<abs>` so realm-collapse still applies even for
|
|
641
|
+
* source already on disk.
|
|
642
|
+
*/
|
|
643
|
+
async function resolveInstallSpec(parsed, packageName, baseUri) {
|
|
644
|
+
const localPath = parsed.qualifiers?.local_path;
|
|
645
|
+
const isLocalManifest = !!baseUri && !baseUri.startsWith("http://") && !baseUri.startsWith("https://");
|
|
646
|
+
if (localPath && isLocalManifest) {
|
|
647
|
+
const baseUriPath = baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri;
|
|
648
|
+
const absolutePath = path.resolve(path.dirname(baseUriPath), localPath);
|
|
649
|
+
if (await pathExists(absolutePath)) {
|
|
650
|
+
return { kind: "local", spec: `file:${absolutePath}`, absolutePath };
|
|
225
651
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
652
|
+
}
|
|
653
|
+
const spec = parsed.version ? `${packageName}@${parsed.version}` : packageName;
|
|
654
|
+
return { kind: "registry", spec };
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Import the controller module out of the install root and validate its
|
|
658
|
+
* shape. Splits cleanly from the install logic so each phase has one job:
|
|
659
|
+
* `installPackage` decides whether work is needed, this function is pure
|
|
660
|
+
* resolve-and-import once the tree is in place.
|
|
661
|
+
*/
|
|
662
|
+
async function loadFromInstall(installRoot, packageName, subpath, purl) {
|
|
663
|
+
const packageRoot = path.join(installRoot, "node_modules", ...packageName.split("/"));
|
|
664
|
+
const entry = subpath ? `./${subpath}` : ".";
|
|
665
|
+
const entryFile = await resolvePackageEntry(packageRoot, entry);
|
|
666
|
+
// ESM dynamic `import()` accepts either a relative specifier or a `file://`
|
|
667
|
+
// URL — but NOT a bare absolute filesystem path. On POSIX the `/abs/path`
|
|
668
|
+
// form works by happy accident; on Windows `C:\path\to\file.js` is rejected
|
|
669
|
+
// outright. Convert through `pathToFileURL` so the loader behaves the same
|
|
670
|
+
// on both platforms.
|
|
671
|
+
//
|
|
672
|
+
// Dynamic `import()` always resolves to a module namespace object on
|
|
673
|
+
// success; it never returns null/undefined. The only meaningful contract
|
|
674
|
+
// check is whether the module exports at least one of the controller hooks.
|
|
675
|
+
const instance = await import(pathToFileURL(entryFile).href);
|
|
676
|
+
if (!instance.create && !instance.register) {
|
|
677
|
+
throw new Error(`Invalid controller loaded from "${purl}": exports neither create() nor register()`);
|
|
678
|
+
}
|
|
679
|
+
return instance;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Default ESM resolver conditions, in priority order. The `bun` condition
|
|
683
|
+
* comes first when running under Bun so dev workspaces resolve to `src/*.ts`
|
|
684
|
+
* directly without a build step. The remaining conditions are the standard
|
|
685
|
+
* Node.js ESM keys; the loader doesn't honour `node` because we always
|
|
686
|
+
* fall back to `import`/`default` for Node anyway.
|
|
687
|
+
*/
|
|
688
|
+
const DEFAULT_RESOLVER_CONDITIONS = typeof globalThis.Bun !== "undefined"
|
|
689
|
+
? ["bun", "import", "default", "require"]
|
|
690
|
+
: ["import", "default", "require"];
|
|
691
|
+
/**
|
|
692
|
+
* Cap on `exports` map traversal depth. A pathological consumer-authored
|
|
693
|
+
* package.json with cycles through array/object trees would otherwise loop
|
|
694
|
+
* forever (the data is third-party — `package.json` files we read out of an
|
|
695
|
+
* arbitrary npm install). 16 levels is far past any plausible legitimate map.
|
|
696
|
+
*/
|
|
697
|
+
const EXPORTS_MAX_DEPTH = 16;
|
|
698
|
+
/**
|
|
699
|
+
* Resolve a package's entry file using its package.json `exports` map first,
|
|
700
|
+
* then the `module`/`main` fields, then a direct path lookup. The conditions
|
|
701
|
+
* list is supplied by the caller so the resolver stays generic — today the
|
|
702
|
+
* only branch is Bun-vs-Node, but worker/browser/electron/deno selections
|
|
703
|
+
* compose the same way.
|
|
704
|
+
*/
|
|
705
|
+
async function resolvePackageEntry(packageRoot, entry, conditions = DEFAULT_RESOLVER_CONDITIONS) {
|
|
706
|
+
const packageJsonPath = path.join(packageRoot, "package.json");
|
|
707
|
+
let packageJson = null;
|
|
708
|
+
if (await pathExists(packageJsonPath)) {
|
|
709
|
+
// A package.json that exists but won't parse is a real on-disk problem
|
|
710
|
+
// (corrupted install, half-written file from an interrupted `npm install`,
|
|
711
|
+
// hand-edited typo). Surface it — silently falling through to the
|
|
712
|
+
// direct-path branch produces an opaque "missing create or register"
|
|
713
|
+
// error instead of "your package.json is broken."
|
|
714
|
+
const raw = await fs.readFile(packageJsonPath, "utf8");
|
|
715
|
+
try {
|
|
716
|
+
packageJson = JSON.parse(raw);
|
|
230
717
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
718
|
+
catch (err) {
|
|
719
|
+
throw new Error(`[telo] failed to parse ${packageJsonPath}: ` +
|
|
720
|
+
(err instanceof Error ? err.message : String(err)));
|
|
234
721
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
722
|
+
}
|
|
723
|
+
const entryValue = entry.trim();
|
|
724
|
+
const exportTarget = resolvePackageExportTarget(packageJson?.exports, entryValue, conditions);
|
|
725
|
+
if (exportTarget) {
|
|
726
|
+
const hit = await tryResolveFile(path.resolve(packageRoot, exportTarget));
|
|
727
|
+
if (hit)
|
|
728
|
+
return hit;
|
|
729
|
+
}
|
|
730
|
+
if ((entryValue === "." || entryValue === "./") && packageJson) {
|
|
731
|
+
for (const field of ["module", "main"]) {
|
|
732
|
+
const target = packageJson[field];
|
|
733
|
+
if (typeof target !== "string")
|
|
734
|
+
continue;
|
|
735
|
+
const hit = await tryResolveFile(path.resolve(packageRoot, target));
|
|
736
|
+
if (hit)
|
|
737
|
+
return hit;
|
|
248
738
|
}
|
|
249
|
-
return null;
|
|
250
739
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
740
|
+
const direct = await tryResolveFile(path.resolve(packageRoot, entryValue));
|
|
741
|
+
if (direct)
|
|
742
|
+
return direct;
|
|
743
|
+
throw new Error(`Controller entry "${entryValue}" could not be resolved in ${packageRoot}`);
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Try a path verbatim, then with `.js` appended if it has no extension.
|
|
747
|
+
* Returns the first path that exists, or null if neither does. Centralizes
|
|
748
|
+
* the extension-fallback rule so it's not repeated at every call site.
|
|
749
|
+
*/
|
|
750
|
+
async function tryResolveFile(absPath) {
|
|
751
|
+
if (await pathExists(absPath))
|
|
752
|
+
return absPath;
|
|
753
|
+
if (!path.extname(absPath)) {
|
|
754
|
+
const withJs = `${absPath}.js`;
|
|
755
|
+
if (await pathExists(withJs))
|
|
756
|
+
return withJs;
|
|
757
|
+
}
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
function resolvePackageExportTarget(exportsField, entry, conditions) {
|
|
761
|
+
if (!exportsField)
|
|
762
|
+
return null;
|
|
763
|
+
const key = entry === "." || entry === "./" ? "." : entry;
|
|
764
|
+
return resolveExportTargetValue(exportsField[key], conditions, 0);
|
|
765
|
+
}
|
|
766
|
+
function resolveExportTargetValue(target, conditions, depth) {
|
|
767
|
+
if (depth > EXPORTS_MAX_DEPTH)
|
|
768
|
+
return null;
|
|
769
|
+
if (!target)
|
|
770
|
+
return null;
|
|
771
|
+
if (typeof target === "string")
|
|
772
|
+
return target;
|
|
773
|
+
if (Array.isArray(target)) {
|
|
774
|
+
for (const item of target) {
|
|
775
|
+
const resolved = resolveExportTargetValue(item, conditions, depth + 1);
|
|
776
|
+
if (resolved)
|
|
777
|
+
return resolved;
|
|
255
778
|
}
|
|
256
|
-
|
|
257
|
-
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
if (typeof target === "object") {
|
|
782
|
+
for (const key of conditions) {
|
|
783
|
+
if (target[key] !== undefined) {
|
|
784
|
+
const resolved = resolveExportTargetValue(target[key], conditions, depth + 1);
|
|
785
|
+
if (resolved)
|
|
786
|
+
return resolved;
|
|
787
|
+
}
|
|
258
788
|
}
|
|
259
789
|
}
|
|
790
|
+
return null;
|
|
260
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Pure helpers exported under a stable namespace for unit tests. The class
|
|
794
|
+
* itself isn't useful in isolation (it requires a package manager binary, a
|
|
795
|
+
* filesystem, and a network for non-local specs); these are the deterministic
|
|
796
|
+
* parts that benefit from cheap unit coverage and have no side effects.
|
|
797
|
+
*
|
|
798
|
+
* Not part of the kernel's public API — consumers should not import these.
|
|
799
|
+
*/
|
|
800
|
+
export const __testing__ = {
|
|
801
|
+
normalizeFileSpec,
|
|
802
|
+
resolvePackageExportTarget,
|
|
803
|
+
resolveExportTargetValue,
|
|
804
|
+
tryResolveFile,
|
|
805
|
+
walkUpToPackageRoot,
|
|
806
|
+
EXPORTS_MAX_DEPTH,
|
|
807
|
+
DEFAULT_RESOLVER_CONDITIONS,
|
|
808
|
+
};
|
|
261
809
|
//# sourceMappingURL=npm-loader.js.map
|