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