@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.
- package/dist/bundle/module-artifact.d.ts +21 -4
- package/dist/bundle/module-artifact.d.ts.map +1 -1
- package/dist/bundle/module-artifact.js +42 -17
- package/dist/bundle/module-artifact.js.map +1 -1
- package/dist/bundle/module-manifest.d.ts +5 -1
- package/dist/bundle/module-manifest.d.ts.map +1 -1
- package/dist/bundle/module-manifest.js +5 -1
- package/dist/bundle/module-manifest.js.map +1 -1
- package/dist/controller-loader.d.ts +3 -2
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +10 -9
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-loader.d.ts +51 -4
- package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-loader.js +260 -11
- package/dist/controller-loaders/bundle-loader.js.map +1 -1
- package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
- package/dist/controller-loaders/napi-loader.js +8 -1
- package/dist/controller-loaders/napi-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +65 -3
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controller-loaders/sibling-libraries.d.ts +93 -0
- package/dist/controller-loaders/sibling-libraries.d.ts.map +1 -0
- package/dist/controller-loaders/sibling-libraries.js +111 -0
- package/dist/controller-loaders/sibling-libraries.js.map +1 -0
- package/dist/controller-loaders/source-bundle-builder.d.ts +25 -2
- package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
- package/dist/controller-loaders/source-bundle-builder.js +232 -26
- package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +5 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +11 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +37 -4
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/resource-context.d.ts +5 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +6 -0
- package/dist/resource-context.js.map +1 -1
- package/package.json +2 -2
- package/src/bundle/module-artifact.ts +50 -20
- package/src/bundle/module-manifest.ts +14 -1
- package/src/controller-loader.ts +25 -7
- package/src/controller-loaders/bundle-loader.ts +326 -10
- package/src/controller-loaders/napi-loader.ts +8 -1
- package/src/controller-loaders/npm-loader.ts +73 -3
- package/src/controller-loaders/sibling-libraries.ts +171 -0
- package/src/controller-loaders/source-bundle-builder.ts +281 -24
- package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
- package/src/index.ts +1 -0
- package/src/kernel.ts +50 -11
- package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
- package/src/resource-context.ts +8 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
layer
|
|
197
|
-
|
|
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
|
-
...
|
|
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 {
|
|
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),
|
package/src/controller-loader.ts
CHANGED
|
@@ -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(
|
|
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
|
|
247
|
+
artifact: ModuleArtifact | undefined,
|
|
248
|
+
libraries: SiblingLibraryMap,
|
|
237
249
|
): Promise<{ instance: ControllerInstance; source: ControllerResolveSource }> {
|
|
238
|
-
const { source, importInstance } = await this.dispatchResolveOne(
|
|
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
|
|
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 {
|
|
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(
|
|
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(
|
|
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
|
|
589
|
+
await prepareBundleDir(path.dirname(prebuilt), shims, this.cacheRoot, this.log);
|
|
274
590
|
return importControllerModule(prebuilt, purl, fragment);
|
|
275
591
|
}
|
|
276
|
-
await
|
|
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.)
|
|
640
|
+
// Make bare `@telorun/sdk` (etc.) and every sibling library resolve before
|
|
325
641
|
// importing the bundle, so authors write normal imports.
|
|
326
|
-
await
|
|
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
|
-
|
|
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
|
|