@telorun/kernel 0.75.0 → 0.76.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 (62) hide show
  1. package/dist/bundle/module-artifact.d.ts +21 -4
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +42 -17
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/bundle/module-manifest.d.ts +5 -1
  6. package/dist/bundle/module-manifest.d.ts.map +1 -1
  7. package/dist/bundle/module-manifest.js +5 -1
  8. package/dist/bundle/module-manifest.js.map +1 -1
  9. package/dist/controller-loader.d.ts +3 -2
  10. package/dist/controller-loader.d.ts.map +1 -1
  11. package/dist/controller-loader.js +10 -9
  12. package/dist/controller-loader.js.map +1 -1
  13. package/dist/controller-loaders/bundle-loader.d.ts +51 -4
  14. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/bundle-loader.js +260 -11
  16. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  17. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  18. package/dist/controller-loaders/napi-loader.js +8 -1
  19. package/dist/controller-loaders/napi-loader.js.map +1 -1
  20. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  21. package/dist/controller-loaders/npm-loader.js +65 -3
  22. package/dist/controller-loaders/npm-loader.js.map +1 -1
  23. package/dist/controller-loaders/sibling-libraries.d.ts +93 -0
  24. package/dist/controller-loaders/sibling-libraries.d.ts.map +1 -0
  25. package/dist/controller-loaders/sibling-libraries.js +111 -0
  26. package/dist/controller-loaders/sibling-libraries.js.map +1 -0
  27. package/dist/controller-loaders/source-bundle-builder.d.ts +25 -2
  28. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
  29. package/dist/controller-loaders/source-bundle-builder.js +232 -26
  30. package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
  31. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  32. package/dist/controllers/resource-definition/resource-definition-controller.js +5 -1
  33. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/kernel.d.ts +11 -1
  38. package/dist/kernel.d.ts.map +1 -1
  39. package/dist/kernel.js +37 -4
  40. package/dist/kernel.js.map +1 -1
  41. package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
  42. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  43. package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
  44. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  45. package/dist/resource-context.d.ts +5 -0
  46. package/dist/resource-context.d.ts.map +1 -1
  47. package/dist/resource-context.js +6 -0
  48. package/dist/resource-context.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/bundle/module-artifact.ts +50 -20
  51. package/src/bundle/module-manifest.ts +14 -1
  52. package/src/controller-loader.ts +25 -7
  53. package/src/controller-loaders/bundle-loader.ts +326 -10
  54. package/src/controller-loaders/napi-loader.ts +8 -1
  55. package/src/controller-loaders/npm-loader.ts +73 -3
  56. package/src/controller-loaders/sibling-libraries.ts +171 -0
  57. package/src/controller-loaders/source-bundle-builder.ts +281 -24
  58. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
  59. package/src/index.ts +1 -0
  60. package/src/kernel.ts +50 -11
  61. package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
  62. package/src/resource-context.ts +8 -0
@@ -1,6 +1,6 @@
1
1
  import {
2
- matchControllerLayers,
3
- selectorKey,
2
+ codeLayerFor,
3
+ matchCodeLayers,
4
4
  singletonLayer,
5
5
  splitIntegrity,
6
6
  describeSelector,
@@ -161,7 +161,8 @@ export class ModuleArtifact {
161
161
  }
162
162
 
163
163
  /**
164
- * Materialize the layer carrying `selector` exactly, plus the `common` layer.
164
+ * Materialize the code layers carrying `selector` exactly `controller` and
165
+ * `library` both — plus the `common` layer.
165
166
  *
166
167
  * Looked up by exact selector key rather than by re-matching the host: the
167
168
  * candidate being resolved already *is* one selector, and it is by construction
@@ -171,31 +172,60 @@ export class ModuleArtifact {
171
172
  * whichever came first regardless of which candidate asked — materializing the
172
173
  * wrong layer and then reporting "bundle not found".
173
174
  *
175
+ * **Both code roles**, because a module's controller entry points and its
176
+ * library entry point are one file whenever it declares both: the file lands in
177
+ * the `library` layer (the weaker precondition — a consumer must reach it
178
+ * without loading this module's controllers), so a controller resolution that
179
+ * fetched only its own role would find nothing. Which of the two holds a given
180
+ * `path=` is not the loader's business; both being on disk is.
181
+ *
174
182
  * The `common` layer rides along because it is the sink for files no candidate
175
183
  * claimed — an undeclared sidecar an entry point loads at runtime. Pulling it
176
- * with any controller layer is what makes a forgotten declaration cost bytes
184
+ * with any code layer is what makes a forgotten declaration cost bytes
177
185
  * instead of a module-not-found at import.
178
186
  *
179
- * Returns `undefined` when the artifact ships no layer for this selector, which
180
- * is how a loader learns to fall through to the next candidate.
187
+ * Returns `undefined` when the artifact ships no code layer for this selector,
188
+ * which is how a loader learns to fall through to the next candidate.
181
189
  */
182
190
  async materializeController(
183
191
  selector: ArtifactSelector,
184
192
  ): Promise<ResolvedControllerLayer | undefined> {
185
- const key = selectorKey(selector);
186
- const layer = this.layers.find(
187
- (l) => l.role === "controller" && l.selector !== undefined && selectorKey(l.selector) === key,
188
- );
189
- if (!layer) return undefined;
193
+ return this.materializeCode(selector, ["controller", "library"]);
194
+ }
195
+
196
+ /**
197
+ * Materialize the `library` layer for `selector`, plus `common`.
198
+ *
199
+ * What a *consumer's* bundle loader calls when it resolves a sibling module's
200
+ * declared specifier: the sibling's controllers are irrelevant there — only its
201
+ * library entry point is being imported — so this asks for exactly one role.
202
+ */
203
+ async materializeLibrary(
204
+ selector: ArtifactSelector,
205
+ ): Promise<ResolvedControllerLayer | undefined> {
206
+ return this.materializeCode(selector, ["library"]);
207
+ }
208
+
209
+ private async materializeCode(
210
+ selector: ArtifactSelector,
211
+ roles: ReadonlyArray<"controller" | "library">,
212
+ ): Promise<ResolvedControllerLayer | undefined> {
213
+ const wanted = roles
214
+ .map((role) => codeLayerFor(this.layers, role, selector))
215
+ .filter((l): l is ArtifactLayer => l !== undefined);
216
+ if (wanted.length === 0) return undefined;
190
217
  const common = await this.materializeCommonTracked();
191
- const controller = await this.materializeTracked(layer);
192
- // Either half is a transfer the caller waited on: the common layer's bytes
193
- // come down on this call too, so they are as much of a wait as the
194
- // controller layer's.
195
- return {
196
- layer: controller.layer,
197
- transferred: controller.transferred || (common?.transferred ?? false),
198
- };
218
+ let transferred = common?.transferred ?? false;
219
+ const files: string[] = [];
220
+ for (const layer of wanted) {
221
+ // Every half is a transfer the caller waited on: the common layer's bytes
222
+ // come down on this call too, so they are as much of a wait as the code
223
+ // layer's.
224
+ const resolved = await this.materializeTracked(layer);
225
+ transferred = resolved.transferred || transferred;
226
+ files.push(...resolved.layer.files);
227
+ }
228
+ return { layer: { dir: this.dir, files: files.sort() }, transferred };
199
229
  }
200
230
 
201
231
  /**
@@ -238,7 +268,7 @@ export class ModuleArtifact {
238
268
  */
239
269
  async materializeAll(target: PlatformTarget): Promise<MaterializedLayer[]> {
240
270
  const wanted = [
241
- ...matchControllerLayers(this.layers, target),
271
+ ...matchCodeLayers(this.layers, target),
242
272
  singletonLayer(this.layers, "assets"),
243
273
  singletonLayer(this.layers, "common"),
244
274
  ].filter((l): l is ArtifactLayer => l !== undefined);
@@ -1,4 +1,9 @@
1
- import { parseLayerIndex, type ArtifactLayer } from "@telorun/analyzer";
1
+ import {
2
+ parseLayerIndex,
3
+ readLibraryCandidates,
4
+ type ArtifactLayer,
5
+ type LibraryCandidate,
6
+ } from "@telorun/analyzer";
2
7
  import { defaultCustomTags } from "@telorun/templating";
3
8
  import { parseAllDocuments, type Document } from "yaml";
4
9
 
@@ -32,6 +37,10 @@ export interface OwnerManifest {
32
37
  /** Ordered `.gitignore`-style patterns the author claimed as the lazily
33
38
  * materialized `assets` layer. */
34
39
  assetPatterns: string[];
40
+ /** The entry points this module offers a sibling's controller bundle, one per
41
+ * format, each naming the bare specifier it is imported by. Empty for a module
42
+ * nothing imports the source of. */
43
+ library: LibraryCandidate[];
35
44
  /** True when the owner doc declares a non-empty `files:` list. */
36
45
  declaresFiles: boolean;
37
46
  /** Descriptive provenance a transport projects into its backend's metadata
@@ -64,6 +73,10 @@ export function readOwnerManifest(text: string): OwnerManifest {
64
73
  version: str(md.version),
65
74
  layers: parsed?.layers === undefined ? undefined : parseLayerIndex(parsed.layers),
66
75
  assetPatterns: patterns(parsed?.assets),
76
+ // Malformed entries are dropped here and reported by the analyzer, which is
77
+ // the surface that can point at the line. A dropped candidate leaves the
78
+ // specifier unresolved at load, which fails loudly rather than silently.
79
+ library: readLibraryCandidates(parsed).candidates,
67
80
  declaresFiles: Array.isArray(parsed?.files) && parsed.files.length > 0,
68
81
  description: str(md.description),
69
82
  repository: str(md.repository),
@@ -1,6 +1,10 @@
1
1
  import { ControllerInstance, RuntimeError, type Logger } from "@telorun/sdk";
2
2
  import { BundleControllerLoader } from "./controller-loaders/bundle-loader.js";
3
3
  import type { ModuleArtifact } from "./bundle/module-artifact.js";
4
+ import {
5
+ NO_SIBLING_LIBRARIES,
6
+ type SiblingLibraryMap,
7
+ } from "./controller-loaders/sibling-libraries.js";
4
8
  import { ControllerEnvMissingError, NapiControllerLoader } from "./controller-loaders/napi-loader.js";
5
9
  import { NpmControllerLoader } from "./controller-loaders/npm-loader.js";
6
10
  import { ControllerPolicy, DEFAULT_POLICY, POLICY_WILDCARD } from "./runtime-registry.js";
@@ -118,7 +122,7 @@ export class ControllerLoader {
118
122
  });
119
123
  if (options.log) this.npmLoader.setLogger(options.log);
120
124
  this.napiLoader = new NapiControllerLoader();
121
- this.bundleLoader = new BundleControllerLoader(options.cacheRoot);
125
+ this.bundleLoader = new BundleControllerLoader(options.cacheRoot, options.log);
122
126
  }
123
127
 
124
128
  async load(
@@ -126,6 +130,7 @@ export class ControllerLoader {
126
130
  baseUri: string,
127
131
  policy?: ControllerPolicy,
128
132
  artifact?: ModuleArtifact,
133
+ libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
129
134
  ): Promise<ControllerInstance> {
130
135
  if (!purlCandidates || purlCandidates.length === 0) {
131
136
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
@@ -144,7 +149,7 @@ export class ControllerLoader {
144
149
  await this.emit?.({ name: "ControllerLoading", payload: { purl } });
145
150
  const startedAt = Date.now();
146
151
  try {
147
- const { instance, source } = await this.dispatchOne(purl, baseUri, artifact);
152
+ const { instance, source } = await this.dispatchOne(purl, baseUri, artifact, libraries);
148
153
  await this.emit?.({
149
154
  name: "ControllerLoaded",
150
155
  payload: { purl, source, durationMs: Date.now() - startedAt },
@@ -199,6 +204,7 @@ export class ControllerLoader {
199
204
  baseUri: string,
200
205
  policy?: ControllerPolicy,
201
206
  artifact?: ModuleArtifact,
207
+ libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
202
208
  ): Promise<ResolvedController> {
203
209
  if (!purlCandidates || purlCandidates.length === 0) {
204
210
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
@@ -214,7 +220,12 @@ export class ControllerLoader {
214
220
  const errors: string[] = [];
215
221
  for (const purl of ordered) {
216
222
  try {
217
- const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri, artifact);
223
+ const { source, importInstance } = await this.dispatchResolveOne(
224
+ purl,
225
+ baseUri,
226
+ artifact,
227
+ libraries,
228
+ );
218
229
  return { purl, source, importInstance };
219
230
  } catch (err) {
220
231
  if (err instanceof ControllerEnvMissingError) {
@@ -233,16 +244,23 @@ export class ControllerLoader {
233
244
  private async dispatchOne(
234
245
  purl: string,
235
246
  baseUri: string,
236
- artifact?: ModuleArtifact,
247
+ artifact: ModuleArtifact | undefined,
248
+ libraries: SiblingLibraryMap,
237
249
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
238
- const { source, importInstance } = await this.dispatchResolveOne(purl, baseUri, artifact);
250
+ const { source, importInstance } = await this.dispatchResolveOne(
251
+ purl,
252
+ baseUri,
253
+ artifact,
254
+ libraries,
255
+ );
239
256
  return { instance: await importInstance(), source };
240
257
  }
241
258
 
242
259
  private async dispatchResolveOne(
243
260
  purl: string,
244
261
  baseUri: string,
245
- artifact?: ModuleArtifact,
262
+ artifact: ModuleArtifact | undefined,
263
+ libraries: SiblingLibraryMap,
246
264
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
247
265
  if (purl.startsWith("pkg:npm")) {
248
266
  return this.npmLoader.resolve(purl, baseUri);
@@ -251,7 +269,7 @@ export class ControllerLoader {
251
269
  return this.napiLoader.resolve(purl, baseUri);
252
270
  }
253
271
  if (purl.startsWith("pkg:telo")) {
254
- return this.bundleLoader.resolve(purl, baseUri, artifact);
272
+ return this.bundleLoader.resolve(purl, baseUri, artifact, libraries);
255
273
  }
256
274
  throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
257
275
  }
@@ -1,5 +1,5 @@
1
1
  import { describeSelector, selectorFromQualifiers, selectorMatches } from "@telorun/analyzer";
2
- import { ControllerInstance, RuntimeError } from "@telorun/sdk";
2
+ import { ControllerInstance, RuntimeError, type Logger } from "@telorun/sdk";
3
3
  import { existsSync, readFileSync } from "fs";
4
4
  import * as fs from "fs/promises";
5
5
  import { createRequire } from "module";
@@ -10,7 +10,16 @@ import { hostPlatformTarget, type ModuleArtifact } from "../bundle/module-artifa
10
10
  import type { ControllerResolveSource } from "../controller-loader.js";
11
11
  import { ControllerEnvMissingError } from "./napi-loader.js";
12
12
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
13
- import { buildControllerFromSource, canBuildFromSource } from "./source-bundle-builder.js";
13
+ import {
14
+ buildControllerFromSource,
15
+ canBuildFromSource,
16
+ type SiblingLibrary,
17
+ } from "./source-bundle-builder.js";
18
+ import {
19
+ NO_SIBLING_LIBRARIES,
20
+ type ResolvedSiblingLibrary,
21
+ type SiblingLibraryMap,
22
+ } from "./sibling-libraries.js";
14
23
 
15
24
  /** A base URI whose files are already on disk: a `file://` URL or a bare
16
25
  * absolute path. Everything else (`oci://`, `http(s)://`, `memory://`) names a
@@ -45,6 +54,11 @@ async function pathExists(filePath: string): Promise<boolean> {
45
54
  const realmLinkedDirs = new Set<string>();
46
55
  async function ensureRealmSymlinks(bundleDir: string): Promise<void> {
47
56
  if (realmLinkedDirs.has(bundleDir)) return;
57
+ await linkRealmNames(bundleDir);
58
+ realmLinkedDirs.add(bundleDir);
59
+ }
60
+
61
+ async function linkRealmNames(bundleDir: string): Promise<void> {
48
62
  const req = createRequire(import.meta.url);
49
63
  for (const name of REALM_COLLAPSE_NAMES) {
50
64
  let pkgRoot: string | null = null;
@@ -84,9 +98,168 @@ async function ensureRealmSymlinks(bundleDir: string): Promise<void> {
84
98
  // import surfaces the resolution failure.
85
99
  }
86
100
  }
87
- realmLinkedDirs.add(bundleDir);
88
101
  }
89
102
 
103
+ /**
104
+ * Make each sibling module's declared specifier resolve, from this bundle, to
105
+ * that module's own library entry point.
106
+ *
107
+ * The realm collapse above points a closed, kernel-owned name at the kernel's own
108
+ * copy. This is the same move one step out: the name is declared by the library
109
+ * (`library: [pkg:telo/local/js?…&specifier=@telorun/sql]`), and the copy comes
110
+ * from that module's artifact rather than from the kernel. What it buys is the
111
+ * same thing — resolution *and* identity — which here means one module scope for
112
+ * `@telorun/sql` across its own six controllers and every dependent, instead of
113
+ * one copy per bundle.
114
+ *
115
+ * A **synthesized package** rather than a symlink to the module's directory: a
116
+ * published artifact ships files, not a `package.json`, so there is nothing to
117
+ * link to that standard resolution would accept. The generated shim re-exports
118
+ * the materialized entry by absolute URL, and every consumer's shim re-exports
119
+ * the *same* file — Node keys its module registry by resolved URL, so the scope
120
+ * stays single however many shims point at it.
121
+ *
122
+ * Written into `node_modules/` beside the bundle, which is per module for a
123
+ * published artifact and per content-addressed build for a working copy, so two
124
+ * dependents that legitimately resolve different versions of one library never
125
+ * write over each other.
126
+ *
127
+ * **A slot something else owns is never written.** The bundle directory is not
128
+ * always the loader's: the prebuilt-`path=` branch imports out of a working copy,
129
+ * where `node_modules/@telorun/sql` is a package manager's symlink INTO the
130
+ * library's own source tree — writing through it would replace that package's
131
+ * real `package.json`. So a slot is written only when it is absent or carries the
132
+ * marker this loader stamps, which is the posture `linkRealmNames` already takes
133
+ * ("a real file/dir in the slot is left untouched"). A foreign package in the slot
134
+ * already resolves the specifier to real code; what it costs is the single-scope
135
+ * property, so it is reported rather than passed over in silence.
136
+ */
137
+ const SHIM_MARKER = "x-telo-generated";
138
+
139
+ async function ensureLibraryShims(
140
+ bundleDir: string,
141
+ entries: ReadonlyArray<{ specifier: string; entryFile: string }>,
142
+ cacheRoot: string | undefined,
143
+ log?: Logger,
144
+ ): Promise<void> {
145
+ for (const { specifier, entryFile } of entries) {
146
+ const dir = path.join(bundleDir, "node_modules", ...specifier.split("/"));
147
+ if (!(await isWritableShimSlot(dir, cacheRoot))) {
148
+ log?.debug("left an existing package in a sibling-library slot", {
149
+ "telo.library.specifier": specifier,
150
+ "telo.library.slot": dir,
151
+ "telo.library.entry": entryFile,
152
+ });
153
+ continue;
154
+ }
155
+ const target = pathToFileURL(entryFile).href;
156
+ await writeIfChanged(
157
+ path.join(dir, "package.json"),
158
+ `${JSON.stringify(
159
+ {
160
+ name: specifier,
161
+ version: "0.0.0",
162
+ type: "module",
163
+ exports: { ".": "./index.mjs" },
164
+ [SHIM_MARKER]: "sibling-library-shim",
165
+ },
166
+ null,
167
+ 2,
168
+ )}\n`,
169
+ );
170
+ // `export *` and nothing else: these entry points export named bindings, and
171
+ // a re-exported `default` that does not exist is a hard syntax-level error at
172
+ // import rather than an absent binding.
173
+ await writeIfChanged(path.join(dir, "index.mjs"), `export * from ${JSON.stringify(target)};\n`);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Whether this loader may write the shim slot at `dir`.
179
+ *
180
+ * **Location first.** Every legitimate write site is inside the loader's own
181
+ * cache root — a bundle built from source lives under `<cache>/controller-src/`,
182
+ * and a published module's layers extract under `<cache>/manifests/` — so a slot
183
+ * there is ours whatever it currently holds. That is what keeps a shim written by
184
+ * an earlier kernel version (before the marker existed, or with different
185
+ * contents) updatable rather than mistaken for someone else's package.
186
+ *
187
+ * **Marker second**, for a slot outside the cache: the prebuilt-`path=` branch
188
+ * imports out of a working copy, where `node_modules/@telorun/sql` is a package
189
+ * manager's symlink straight into the sibling's own source tree. Reading the
190
+ * `package.json` **through** whatever is there settles it — a symlink resolves to
191
+ * the target's, which carries no marker — so one read covers both a link and a
192
+ * real installed package without caring which it was.
193
+ */
194
+ async function isWritableShimSlot(dir: string, cacheRoot: string | undefined): Promise<boolean> {
195
+ if (cacheRoot) {
196
+ const root = path.resolve(cacheRoot) + path.sep;
197
+ if (path.resolve(dir).startsWith(root)) return true;
198
+ }
199
+ try {
200
+ const parsed = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")) as Record<
201
+ string,
202
+ unknown
203
+ >;
204
+ return parsed[SHIM_MARKER] !== undefined;
205
+ } catch {
206
+ // Nothing readable there. A symlink with no package.json behind it is still
207
+ // someone else's, so refuse that too rather than writing through it.
208
+ try {
209
+ return !(await fs.lstat(dir)).isSymbolicLink();
210
+ } catch {
211
+ return true;
212
+ }
213
+ }
214
+ }
215
+
216
+ /** Everything a bundle's directory needs before the bundle is imported: the
217
+ * kernel-owned realm names, and one shim per sibling library. */
218
+ async function prepareBundleDir(
219
+ bundleDir: string,
220
+ shims: ReadonlyArray<{ specifier: string; entryFile: string }>,
221
+ cacheRoot: string | undefined,
222
+ log?: Logger,
223
+ ): Promise<void> {
224
+ await ensureRealmSymlinks(bundleDir);
225
+ await ensureLibraryShims(bundleDir, shims, cacheRoot, log);
226
+ }
227
+
228
+ /** The externals a build of `format` code takes from a sibling-library map: the
229
+ * specifier esbuild must not inline, and the source tree the post-build check
230
+ * proves was not reached by another route. A published sibling ships no sources,
231
+ * so it is externalized with no tree to check — there is nothing there to
232
+ * inline. */
233
+ function buildExternals(libraries: SiblingLibraryMap, format: string): SiblingLibrary[] {
234
+ const out: SiblingLibrary[] = [];
235
+ for (const library of libraries.values()) {
236
+ if (library.selector.format !== format) continue;
237
+ const sourceDir =
238
+ library.moduleDir && library.localPath
239
+ ? path.dirname(path.resolve(library.moduleDir, library.localPath))
240
+ : undefined;
241
+ out.push({ specifier: library.specifier, ...(sourceDir ? { sourceDir } : {}) });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** Write a generated file only when its content would change, through a private
247
+ * temp file and an atomic rename — several kernels may populate one cache
248
+ * directory at once, and a reader must see a whole file or none. */
249
+ async function writeIfChanged(file: string, content: string): Promise<void> {
250
+ try {
251
+ if ((await fs.readFile(file, "utf8")) === content) return;
252
+ } catch {
253
+ // Absent or unreadable — write it.
254
+ }
255
+ await fs.mkdir(path.dirname(file), { recursive: true });
256
+ const tmp = `${file}.${process.pid}.${shimCounter++}.tmp`;
257
+ await fs.writeFile(tmp, content);
258
+ await fs.rename(tmp, file);
259
+ }
260
+
261
+ let shimCounter = 0;
262
+
90
263
  /**
91
264
  * Walk up from a resolved entry file to the directory whose package.json `name`
92
265
  * matches — the package root to symlink (so the symlinked package.json `exports`
@@ -159,17 +332,147 @@ export class BundleControllerLoader {
159
332
  /** Where a dev build from `local_path` is cached (`<cache-root>/controller-src`).
160
333
  * Absent for callers that resolved no cache root, which simply disables the
161
334
  * source path — a prebuilt `path=` still loads. */
162
- constructor(private readonly cacheRoot?: string) {}
335
+ constructor(
336
+ private readonly cacheRoot?: string,
337
+ /** Reports what resolution had to leave alone — a sibling-library slot an
338
+ * installer already owns, which resolves but not to this module's own copy. */
339
+ private readonly log?: Logger,
340
+ ) {}
163
341
 
164
342
  async load(
165
343
  purl: string,
166
344
  baseUri: string,
167
345
  artifact?: ModuleArtifact,
346
+ libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
168
347
  ): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
169
- const { source, importInstance } = await this.resolve(purl, baseUri, artifact);
348
+ const { source, importInstance } = await this.resolve(purl, baseUri, artifact, libraries);
170
349
  return { instance: await importInstance(), source };
171
350
  }
172
351
 
352
+ /**
353
+ * Resolve every sibling library this bundle imports to a file on disk, and
354
+ * prepare the module scope each one will run in.
355
+ *
356
+ * Filtered to the candidate's own format: a `js` bundle imports the `js` entry
357
+ * point, and a Rust crate of the same module — a different specifier entirely —
358
+ * is not its business. The host platform gate is the same one the candidate
359
+ * itself passed, since a library layer is selected exactly as a controller
360
+ * layer is.
361
+ */
362
+ private async libraryEntries(
363
+ libraries: SiblingLibraryMap,
364
+ format: string,
365
+ purl: string,
366
+ seen: Set<string>,
367
+ ): Promise<Array<{ specifier: string; entryFile: string }>> {
368
+ const host = hostPlatformTarget();
369
+ const out: Array<{ specifier: string; entryFile: string }> = [];
370
+ for (const library of libraries.values()) {
371
+ if (library.selector.format !== format) continue;
372
+ if (!selectorMatches(library.selector, host)) continue;
373
+ out.push({
374
+ specifier: library.specifier,
375
+ entryFile: await this.prepareLibrary(library, format, purl, seen),
376
+ });
377
+ }
378
+ return out;
379
+ }
380
+
381
+ /**
382
+ * The file a sibling's specifier resolves to, with that file's own imports made
383
+ * resolvable in turn.
384
+ *
385
+ * A library is delivered exactly as a controller is, so it takes the same two
386
+ * routes: a published module's entry point comes out of its `library` layer,
387
+ * and a working copy's is built from `local_path` so an edit is picked up with
388
+ * no build step. The recursion is real — a library that imports another library
389
+ * needs its own shims beside it — and `seen` bounds it at one visit per module.
390
+ */
391
+ private async prepareLibrary(
392
+ library: ResolvedSiblingLibrary,
393
+ format: string,
394
+ purl: string,
395
+ seen: Set<string>,
396
+ ): Promise<string> {
397
+ const entryFile = await this.libraryEntryFile(library, format, purl);
398
+ const dir = path.dirname(entryFile);
399
+ if (!seen.has(library.moduleSource)) {
400
+ seen.add(library.moduleSource);
401
+ const nested = await this.libraryEntries(library.libraries, format, purl, seen);
402
+ await ensureLibraryShims(dir, nested, this.cacheRoot, this.log);
403
+ }
404
+ // A library entry imports `@telorun/sdk` like any controller does.
405
+ await ensureRealmSymlinks(dir);
406
+ return entryFile;
407
+ }
408
+
409
+ /**
410
+ * The file a library's specifier resolves to.
411
+ *
412
+ * Every failure here is `ControllerEnvMissingError`, and that is a choice worth
413
+ * defending: it is not "this host lacks an environment" in the ordinary sense.
414
+ * But a library is resolved **per format**, so a failure is scoped to ONE
415
+ * candidate — the `js` library being absent says nothing about whether a
416
+ * `napi` candidate of the same kind can run, and the candidate list is exactly
417
+ * the mechanism for trying it. Failing hard would abort a list a sibling
418
+ * candidate could still satisfy. It also matches how the controller path
419
+ * already treats the same shapes: a missing bundle file and a selector the
420
+ * artifact ships no layer for are both env-missing there. What must never be
421
+ * masked this way is a *build* failure or a malformed bundle, and neither is
422
+ * reachable from here — those keep their hard codes. Each message names the
423
+ * sibling module and the action, and the aggregated
424
+ * `ERR_CONTROLLER_NOT_FOUND` carries every one of them.
425
+ */
426
+ private async libraryEntryFile(
427
+ library: ResolvedSiblingLibrary,
428
+ format: string,
429
+ purl: string,
430
+ ): Promise<string> {
431
+ if (library.artifact) {
432
+ const resolved = await library.artifact.materializeLibrary(library.selector);
433
+ if (!resolved) {
434
+ throw new ControllerEnvMissingError(
435
+ `pkg:telo controller "${purl}" imports "${library.specifier}", but module ` +
436
+ `${library.moduleSource} ships no ${format} library layer for it ` +
437
+ `(has: ${library.artifact.describeLayers()}). Republish that module.`,
438
+ );
439
+ }
440
+ return path.resolve(resolved.layer.dir, library.path);
441
+ }
442
+
443
+ if (!library.moduleDir) {
444
+ throw new ControllerEnvMissingError(
445
+ `pkg:telo controller "${purl}" imports "${library.specifier}", but module ` +
446
+ `${library.moduleSource} has no local directory to resolve its library entry point in.`,
447
+ );
448
+ }
449
+
450
+ // Working copy: the source is authoritative, exactly as it is for a
451
+ // controller — a stale checked-in bundle would otherwise shadow the edit.
452
+ const source = library.localPath
453
+ ? path.resolve(library.moduleDir, library.localPath)
454
+ : undefined;
455
+ if (
456
+ source !== undefined &&
457
+ this.cacheRoot !== undefined &&
458
+ (await pathExists(source)) &&
459
+ (await canBuildFromSource())
460
+ ) {
461
+ return buildControllerFromSource(
462
+ source,
463
+ this.cacheRoot,
464
+ buildExternals(library.libraries, format),
465
+ );
466
+ }
467
+
468
+ const prebuilt = path.resolve(library.moduleDir, library.path);
469
+ if (await pathExists(prebuilt)) return prebuilt;
470
+ throw new ControllerEnvMissingError(
471
+ `pkg:telo controller "${purl}" imports "${library.specifier}", whose entry point is not at ` +
472
+ `"${prebuilt}"${source ? ` and whose source "${source}" cannot be built here` : ""}.`,
473
+ );
474
+ }
475
+
173
476
  /**
174
477
  * Resolve without importing: parse + validate the PURL, reject a candidate this
175
478
  * host cannot run, materialize the layer that carries it, confirm the file
@@ -181,6 +484,7 @@ export class BundleControllerLoader {
181
484
  purl: string,
182
485
  baseUri: string,
183
486
  artifact?: ModuleArtifact,
487
+ libraries: SiblingLibraryMap = NO_SIBLING_LIBRARIES,
184
488
  ): Promise<{ source: ControllerResolveSource; importInstance: () => Promise<ControllerInstance> }> {
185
489
  let parsed: PackageURL;
186
490
  try {
@@ -252,6 +556,14 @@ export class BundleControllerLoader {
252
556
  cacheRoot !== undefined &&
253
557
  (await pathExists(sourceFile)) &&
254
558
  (await canBuildFromSource());
559
+ // Every sibling library this bundle imports, resolved before the bundle is:
560
+ // its `import { KeyedClaim } from "@telorun/kv-store"` has to have a file
561
+ // behind it, and which file that is depends on the import graph rather than
562
+ // on anything inside the bundle. Done at resolve time with the other
563
+ // fail-fast checks, so an unresolvable library reports itself as a candidate
564
+ // this host cannot run rather than as an opaque module-not-found at import.
565
+ const shims = await this.libraryEntries(libraries, format, purl, new Set());
566
+
255
567
  if (buildFromSource) {
256
568
  // The same file the prebuilt branch would import, kept as the fallback for
257
569
  // an environment that stops being able to build between resolve and first
@@ -266,14 +578,18 @@ export class BundleControllerLoader {
266
578
  importInstance: async () => {
267
579
  let built: string;
268
580
  try {
269
- built = await buildControllerFromSource(sourceFile!, cacheRoot!);
581
+ built = await buildControllerFromSource(
582
+ sourceFile!,
583
+ cacheRoot!,
584
+ buildExternals(libraries, format),
585
+ );
270
586
  } catch (err) {
271
587
  if (!(err instanceof ControllerEnvMissingError)) throw err;
272
588
  if (!(await pathExists(prebuilt))) throw err;
273
- await ensureRealmSymlinks(path.dirname(prebuilt));
589
+ await prepareBundleDir(path.dirname(prebuilt), shims, this.cacheRoot, this.log);
274
590
  return importControllerModule(prebuilt, purl, fragment);
275
591
  }
276
- await ensureRealmSymlinks(path.dirname(built));
592
+ await prepareBundleDir(path.dirname(built), shims, this.cacheRoot, this.log);
277
593
  return importControllerModule(built, purl, fragment);
278
594
  },
279
595
  };
@@ -321,9 +637,9 @@ export class BundleControllerLoader {
321
637
  );
322
638
  }
323
639
 
324
- // Make bare `@telorun/sdk` (etc.) resolve to the kernel's copy before
640
+ // Make bare `@telorun/sdk` (etc.) and every sibling library resolve before
325
641
  // importing the bundle, so authors write normal imports.
326
- await ensureRealmSymlinks(path.dirname(absFile));
642
+ await prepareBundleDir(path.dirname(absFile), shims, this.cacheRoot, this.log);
327
643
 
328
644
  return {
329
645
  source,
@@ -4,6 +4,7 @@ import * as fs from "fs/promises";
4
4
  import { createRequire } from "module";
5
5
  import { PackageURL } from "packageurl-js";
6
6
  import * as path from "path";
7
+ import { fileURLToPath } from "url";
7
8
  import { promisify } from "util";
8
9
 
9
10
  import { hostEnv } from "../host-env.js";
@@ -151,7 +152,13 @@ export class NapiControllerLoader {
151
152
  );
152
153
  }
153
154
 
154
- const baseUriPath = baseUri.startsWith("file://") ? baseUri.slice("file://".length) : baseUri;
155
+ // `fileURLToPath`, never a slice of the prefix. A file URL's path is not a
156
+ // filesystem path: on Windows `file:///D:/a/telo/telo.yaml` sliced at seven
157
+ // characters leaves `/D:/a/telo/telo.yaml`, whose leading slash makes
158
+ // `path.resolve` graft the cwd's drive on and produce `D:\D:\a\telo\…`. It
159
+ // also leaves percent-escapes undecoded on every platform, so a manifest
160
+ // under a directory with a space resolved to a path that does not exist.
161
+ const baseUriPath = baseUri.startsWith("file://") ? fileURLToPath(baseUri) : baseUri;
155
162
  const manifestDir = path.dirname(baseUriPath);
156
163
  const cratePath = path.resolve(manifestDir, localPath);
157
164