@telorun/kernel 0.9.1 → 0.10.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.
@@ -1,261 +1,809 @@
1
1
  import { execFile } from "child_process";
2
- import { createHash } from "crypto";
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
- const homedir = os.homedir();
9
- const cacheRoot = process.env.TELO_CACHE_DIR
10
- ? path.resolve(process.env.TELO_CACHE_DIR)
11
- : path.join(homedir, ".cache", "telo");
12
- const npmCacheRoot = path.join(cacheRoot, "npm");
13
- const isBun = typeof globalThis.Bun !== "undefined";
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
- * Resolve a `pkg:npm/...` PURL to a controller module instance. Tries, in order:
17
- * a relative `local_path` qualifier, the workspace's `node_modules`, and finally
18
- * an isolated install under `~/.cache/telo/npm/<hash>`.
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
- async load(purl, baseUri) {
21
- const [, namespace, name, versionSpec, qualifiers, entry] = PackageURL.parseString(purl);
22
- const localPath = qualifiers?.get("local_path");
23
- const cacheKey = createHash("sha256").update(purl).digest("hex").slice(0, 12);
24
- const installDir = path.join(npmCacheRoot, cacheKey);
25
- let packageRoot;
26
- let source;
27
- const isLocalManifest = baseUri && !baseUri.startsWith("http://") && !baseUri.startsWith("https://");
28
- if (localPath && isLocalManifest) {
29
- const baseUriPath = baseUri.startsWith("file://") ? baseUri.slice("file://".length) : baseUri;
30
- const manifestDir = path.dirname(baseUriPath);
31
- const resolvedLocalPath = path.resolve(manifestDir, localPath);
32
- if (await this.pathExists(resolvedLocalPath)) {
33
- packageRoot = resolvedLocalPath;
34
- source = "local";
35
- }
36
- else {
37
- const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
38
- if (nodeModulesPath) {
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
- else {
49
- const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
50
- if (nodeModulesPath) {
51
- packageRoot = nodeModulesPath;
52
- source = "node_modules";
53
- }
54
- else {
55
- source = await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
56
- packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
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 entryFile = await this.resolvePackageEntry(packageRoot, entry ? `./${entry}` : ".");
60
- const instance = await import(entryFile);
61
- if (!instance || (!instance.create && !instance.register)) {
62
- throw new Error(`Invalid controller loaded from "${purl}": missing create or register function`);
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
- return { instance, source };
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
- async ensureNpmPackageInstalled(installDir, packageSpec) {
67
- const packageName = this.getPackageName(packageSpec.startsWith(".") || path.isAbsolute(packageSpec)
68
- ? await this.getLocalPackageName(packageSpec)
69
- : packageSpec);
70
- const packageRoot = this.getInstalledPackageRoot(installDir, packageName);
71
- const packageJsonPath = path.join(packageRoot, "package.json");
72
- if (await this.pathExists(packageJsonPath)) {
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
- await fs.mkdir(installDir, { recursive: true });
76
- const rootPackageJson = path.join(installDir, "package.json");
77
- if (!(await this.pathExists(rootPackageJson))) {
78
- await fs.writeFile(rootPackageJson, JSON.stringify({ name: "telo-cache", private: true }, null, 2));
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
- const execFileAsync = promisify(execFile);
81
- const args = [
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
- const [name] = packageSpec.split("@");
99
- return name;
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
- getInstalledPackageRoot(installDir, packageName) {
102
- const nameParts = packageName.split("/");
103
- return path.join(installDir, "node_modules", ...nameParts);
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
- async getLocalPackageName(packagePath) {
106
- const packageJsonPath = path.join(packagePath, "package.json");
107
- if (!(await this.pathExists(packageJsonPath))) {
108
- throw new Error(`Local package missing package.json: ${packagePath}`);
109
- }
110
- const content = await fs.readFile(packageJsonPath, "utf8");
111
- const parsed = JSON.parse(content);
112
- if (!parsed?.name) {
113
- throw new Error(`Local package missing name in package.json: ${packagePath}`);
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
- return parsed.name;
116
- }
117
- async resolvePackageEntry(packageRoot, entry, packageName) {
118
- const packageJsonPath = path.join(packageRoot, "package.json");
119
- let resolvedPackageName = packageName;
120
- let packageJson = null;
121
- if (!resolvedPackageName && (await this.pathExists(packageJsonPath))) {
122
- const content = await fs.readFile(packageJsonPath, "utf8");
123
- try {
124
- packageJson = JSON.parse(content);
125
- resolvedPackageName = packageJson?.name;
126
- }
127
- catch {
128
- resolvedPackageName = packageName;
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
- else if (await this.pathExists(packageJsonPath)) {
132
- try {
133
- packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
134
- }
135
- catch {
136
- packageJson = null;
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
- const entryValue = entry.trim();
140
- const exportTarget = this.resolvePackageExportTarget(packageJson?.exports, entryValue);
141
- if (exportTarget) {
142
- const resolved = path.resolve(packageRoot, exportTarget);
143
- if (await this.pathExists(resolved)) {
144
- return this.resolveForRuntime(resolved, packageRoot);
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 (!path.extname(resolved)) {
147
- const withJs = `${resolved}.js`;
148
- if (await this.pathExists(withJs)) {
149
- return withJs;
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
- if ((entryValue === "." || entryValue === "./") && packageJson) {
154
- const mainFields = ["module", "main"];
155
- for (const field of mainFields) {
156
- const target = packageJson[field];
157
- if (typeof target === "string") {
158
- const resolved = path.resolve(packageRoot, target);
159
- if (await this.pathExists(resolved)) {
160
- return this.resolveForRuntime(resolved, packageRoot);
161
- }
162
- if (!path.extname(resolved)) {
163
- const withJs = `${resolved}.js`;
164
- if (await this.pathExists(withJs)) {
165
- return withJs;
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
- if (!path.extname(directPath)) {
176
- const withJs = `${directPath}.js`;
177
- if (await this.pathExists(withJs)) {
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
- resolvePackageExportTarget(exportsField, entry) {
184
- if (!exportsField) {
185
- return null;
186
- }
187
- const key = entry === "." || entry === "./" ? "." : entry;
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
- resolveExportTargetValue(target) {
192
- if (!target) {
193
- return null;
194
- }
195
- if (typeof target === "string") {
196
- return target;
197
- }
198
- if (Array.isArray(target)) {
199
- for (const item of target) {
200
- const resolved = this.resolveExportTargetValue(item);
201
- if (resolved) {
202
- return resolved;
203
- }
204
- }
205
- return null;
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
- if (typeof target === "object") {
208
- const preferredKeys = isBun
209
- ? ["bun", "import", "default", "require"]
210
- : ["import", "default", "require"];
211
- for (const key of preferredKeys) {
212
- if (target[key]) {
213
- const resolved = this.resolveExportTargetValue(target[key]);
214
- if (resolved) {
215
- return resolved;
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
- async resolveForRuntime(resolvedPath, packageRoot) {
223
- if (isBun || !resolvedPath.endsWith(".ts")) {
224
- return resolvedPath;
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
- const relative = path.relative(packageRoot, resolvedPath);
227
- const distEquivalent = path.resolve(packageRoot, relative.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"));
228
- if (await this.pathExists(distEquivalent)) {
229
- return distEquivalent;
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
- const jsPath = resolvedPath.replace(/\.ts$/, ".js");
232
- if (await this.pathExists(jsPath)) {
233
- return jsPath;
718
+ catch (err) {
719
+ throw new Error(`[telo] failed to parse ${packageJsonPath}: ` +
720
+ (err instanceof Error ? err.message : String(err)));
234
721
  }
235
- return resolvedPath;
236
- }
237
- async findInNodeModules(packageName) {
238
- const nameParts = packageName.split("/");
239
- const candidates = [
240
- path.join(process.cwd(), "node_modules", ...nameParts),
241
- path.join(process.cwd(), "node_modules", ".pnpm", "node_modules", ...nameParts),
242
- ];
243
- for (const candidate of candidates) {
244
- const packageJsonPath = path.join(candidate, "package.json");
245
- if (await this.pathExists(packageJsonPath)) {
246
- return candidate;
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
- async pathExists(filePath) {
252
- try {
253
- await fs.access(filePath);
254
- return true;
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
- catch {
257
- return false;
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