@telorun/kernel 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controller-loader.d.ts +11 -3
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +2 -2
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +128 -13
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +764 -216
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/generated/runtime-deps.json +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +57 -5
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +137 -23
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +50 -0
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -0
- package/dist/manifest-sources/local-manifest-cache-source.js +227 -0
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -0
- package/dist/resource-context.d.ts +1 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +3 -0
- package/dist/resource-context.js.map +1 -1
- package/package.json +16 -5
- package/src/controller-loader.ts +13 -3
- package/src/controller-loaders/npm-loader.ts +843 -229
- package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
- package/src/index.ts +6 -0
- package/src/kernel.ts +157 -22
- package/src/manifest-sources/local-manifest-cache-source.ts +256 -0
- package/src/resource-context.ts +4 -0
|
@@ -47,6 +47,7 @@ class ResourceDefinition implements ResourceInstance {
|
|
|
47
47
|
// here at the call site.
|
|
48
48
|
const loader = new ControllerLoader({
|
|
49
49
|
emit: (e) => ctx.emit(e.name, e.payload),
|
|
50
|
+
entryUrl: ctx.getEntryUrl(),
|
|
50
51
|
});
|
|
51
52
|
const controllerInstance = await loader.load(
|
|
52
53
|
this.resource.controllers,
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,12 @@ export { ControllerLoader } from "./controller-loader.js";
|
|
|
2
2
|
export { ControllerRegistry } from "./controller-registry.js";
|
|
3
3
|
export { EvaluationContext } from "./evaluation-context.js";
|
|
4
4
|
export { LocalFileSource } from "./manifest-sources/local-file-source.js";
|
|
5
|
+
export {
|
|
6
|
+
LocalManifestCacheSource,
|
|
7
|
+
cachePathForCanonical,
|
|
8
|
+
resolveEntryDir,
|
|
9
|
+
writeManifestCache,
|
|
10
|
+
} from "./manifest-sources/local-manifest-cache-source.js";
|
|
5
11
|
export { MemorySource } from "./manifest-sources/memory-source.js";
|
|
6
12
|
export { EventStream } from "./event-stream.js";
|
|
7
13
|
export { ExecutionContext } from "./execution-context.js";
|
package/src/kernel.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AnalysisRegistry,
|
|
3
|
+
flattenForAnalyzer,
|
|
4
|
+
flattenLoadedModule,
|
|
3
5
|
isModuleKind,
|
|
4
6
|
Loader,
|
|
5
7
|
StaticAnalyzer,
|
|
@@ -48,6 +50,24 @@ function findEnclosingPolicy(ctx: IEvaluationContext): ControllerPolicy | undefi
|
|
|
48
50
|
return findEnclosingModule(ctx)?.getControllerPolicy();
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
function throwInvalidState(operation: string, reason: string): never {
|
|
54
|
+
throw new RuntimeError(
|
|
55
|
+
"ERR_KERNEL_STATE_INVALID",
|
|
56
|
+
`Cannot ${operation}(): ${reason}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseRef(ref: string): { kind: string; name: string } {
|
|
61
|
+
const lastDot = ref.lastIndexOf(".");
|
|
62
|
+
if (lastDot <= 0 || lastDot === ref.length - 1) {
|
|
63
|
+
throw new RuntimeError(
|
|
64
|
+
"ERR_INVALID_VALUE",
|
|
65
|
+
`Invalid resource reference '${ref}': expected '<Kind>.<Name>' (e.g. 'Http.Server.Main') or pass { kind, name } directly.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return { kind: ref.slice(0, lastDot), name: ref.slice(lastDot + 1) };
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
export interface KernelOptions {
|
|
52
72
|
stdin?: NodeJS.ReadableStream;
|
|
53
73
|
stdout?: NodeJS.WritableStream;
|
|
@@ -96,6 +116,13 @@ export class Kernel implements IKernel {
|
|
|
96
116
|
private readonly sharedSchemaValidator = new SchemaValidator();
|
|
97
117
|
private rootContext!: ModuleContext;
|
|
98
118
|
private staticManifests: ResourceManifest[] = [];
|
|
119
|
+
private _entryUrl?: string;
|
|
120
|
+
// Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
|
|
121
|
+
// teardown() is the only idempotent method; everything else throws on misuse.
|
|
122
|
+
private _bootCalled = false;
|
|
123
|
+
private _isBooted = false;
|
|
124
|
+
private _targetsRan = false;
|
|
125
|
+
private _isTornDown = false;
|
|
99
126
|
|
|
100
127
|
readonly stdin: NodeJS.ReadableStream;
|
|
101
128
|
readonly stdout: NodeJS.WritableStream;
|
|
@@ -140,12 +167,15 @@ export class Kernel implements IKernel {
|
|
|
140
167
|
this.registry.registerDefinition(definition);
|
|
141
168
|
}
|
|
142
169
|
|
|
143
|
-
loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
|
|
144
|
-
|
|
170
|
+
async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
|
|
171
|
+
const lm = await this.loader.loadModule(url, options);
|
|
172
|
+
return flattenLoadedModule(lm);
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
loadManifests(url: string): Promise<ResourceManifest[]> {
|
|
148
|
-
|
|
175
|
+
async loadManifests(url: string): Promise<ResourceManifest[]> {
|
|
176
|
+
const graph = await this.loader.loadGraph(url);
|
|
177
|
+
if (graph.errors.length > 0) throw graph.errors[0].error;
|
|
178
|
+
return flattenForAnalyzer(graph);
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
/** Returns the live analysis registry backed by this kernel's known definitions and aliases.
|
|
@@ -194,6 +224,7 @@ export class Kernel implements IKernel {
|
|
|
194
224
|
*/
|
|
195
225
|
async load(url: string): Promise<void> {
|
|
196
226
|
const sourceUrl = await this.loader.resolveEntryPoint(url);
|
|
227
|
+
this._entryUrl = sourceUrl;
|
|
197
228
|
this.rootContext = new ModuleContext(
|
|
198
229
|
sourceUrl,
|
|
199
230
|
{},
|
|
@@ -218,7 +249,11 @@ export class Kernel implements IKernel {
|
|
|
218
249
|
|
|
219
250
|
// Static analysis pre-flight: validates schemas and invocation context compatibility.
|
|
220
251
|
// All errors are fatal — kernel does not start if analysis fails.
|
|
221
|
-
const
|
|
252
|
+
const analysisGraph = await this.loader.loadGraph(sourceUrl);
|
|
253
|
+
if (analysisGraph.errors.length > 0) {
|
|
254
|
+
throw analysisGraph.errors[0].error;
|
|
255
|
+
}
|
|
256
|
+
const staticManifests = flattenForAnalyzer(analysisGraph);
|
|
222
257
|
this.staticManifests = staticManifests;
|
|
223
258
|
|
|
224
259
|
// Register module identities for x-telo-ref resolution (Phase 3 prerequisite).
|
|
@@ -256,8 +291,11 @@ export class Kernel implements IKernel {
|
|
|
256
291
|
);
|
|
257
292
|
}
|
|
258
293
|
|
|
259
|
-
// Load runtime configuration — root module gets access to host env
|
|
260
|
-
|
|
294
|
+
// Load runtime configuration — root module gets access to host env.
|
|
295
|
+
// Imports are loaded separately via the import-controller; this load is
|
|
296
|
+
// entry-only with compile-time CEL.
|
|
297
|
+
const lm = await this.loader.loadModule(sourceUrl, { compile: true });
|
|
298
|
+
const allManifests = flattenLoadedModule(lm);
|
|
261
299
|
|
|
262
300
|
// Phase 2: normalize inline resources — extract inline values from x-telo-ref slots
|
|
263
301
|
// into first-class named manifests and replace them in-place with {kind, name} references.
|
|
@@ -278,9 +316,25 @@ export class Kernel implements IKernel {
|
|
|
278
316
|
}
|
|
279
317
|
|
|
280
318
|
/**
|
|
281
|
-
*
|
|
319
|
+
* Initialize every resource declared in the manifest. Does not run targets
|
|
320
|
+
* and does not wait — returns as soon as the kernel is ready to accept
|
|
321
|
+
* `invoke()` calls.
|
|
322
|
+
*
|
|
323
|
+
* Throws ERR_KERNEL_STATE_INVALID if `load()` was not called first, on
|
|
324
|
+
* second call, or after teardown.
|
|
282
325
|
*/
|
|
283
|
-
async
|
|
326
|
+
async boot(): Promise<void> {
|
|
327
|
+
if (this._isTornDown) {
|
|
328
|
+
throwInvalidState("boot", "kernel has been torn down");
|
|
329
|
+
}
|
|
330
|
+
if (this._bootCalled) {
|
|
331
|
+
throwInvalidState("boot", "boot() already called");
|
|
332
|
+
}
|
|
333
|
+
if (this._entryUrl === undefined) {
|
|
334
|
+
throwInvalidState("boot", "load() has not been called");
|
|
335
|
+
}
|
|
336
|
+
this._bootCalled = true;
|
|
337
|
+
|
|
284
338
|
// Call register hooks for controllers actually loaded at this point (built-ins).
|
|
285
339
|
// User-module kinds load their controllers during Phase 3 (Telo.Definition.init),
|
|
286
340
|
// and registerController() fires their register hook there.
|
|
@@ -321,25 +375,103 @@ export class Kernel implements IKernel {
|
|
|
321
375
|
this.rootContext.setInitOrder(order);
|
|
322
376
|
}
|
|
323
377
|
|
|
324
|
-
|
|
378
|
+
await this.rootContext.initializeResources();
|
|
379
|
+
await this.eventBus.emit("Kernel.Initialized", {});
|
|
380
|
+
|
|
381
|
+
this._isBooted = true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Run the manifest's `targets` (Telo.Service / Telo.Runnable instances).
|
|
386
|
+
* Emits Kernel.Starting before, Kernel.Started after.
|
|
387
|
+
*
|
|
388
|
+
* Throws ERR_KERNEL_STATE_INVALID if called before `boot()` completes, after
|
|
389
|
+
* teardown, or a second time.
|
|
390
|
+
*/
|
|
391
|
+
async runTargets(): Promise<void> {
|
|
392
|
+
if (this._isTornDown) {
|
|
393
|
+
throwInvalidState("runTargets", "kernel has been torn down");
|
|
394
|
+
}
|
|
395
|
+
if (!this._isBooted) {
|
|
396
|
+
throwInvalidState("runTargets", "boot() has not completed");
|
|
397
|
+
}
|
|
398
|
+
if (this._targetsRan) {
|
|
399
|
+
throwInvalidState("runTargets", "runTargets() already called");
|
|
400
|
+
}
|
|
401
|
+
this._targetsRan = true;
|
|
402
|
+
|
|
403
|
+
await this.eventBus.emit("Kernel.Starting", {});
|
|
404
|
+
await this.rootContext.runTargets();
|
|
405
|
+
await this.eventBus.emit("Kernel.Started", {});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Tear down every initialized resource. Emits Kernel.Stopping before,
|
|
410
|
+
* Kernel.Stopped after. Idempotent — second call is a no-op (does not
|
|
411
|
+
* re-emit). Tolerates partial state — a boot() that threw mid-init still
|
|
412
|
+
* cleans up whichever resources had initialized.
|
|
413
|
+
*/
|
|
414
|
+
async teardown(): Promise<void> {
|
|
415
|
+
if (this._isTornDown) return;
|
|
416
|
+
this._isTornDown = true;
|
|
417
|
+
|
|
418
|
+
await this.eventBus.emit("Kernel.Stopping", {});
|
|
419
|
+
if (this.rootContext) {
|
|
420
|
+
await this.rootContext.teardownResources();
|
|
421
|
+
}
|
|
422
|
+
await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Convenience: boot → runTargets → waitForIdle → teardown. The try wraps
|
|
427
|
+
* boot() and runTargets() too — init-time failures still drive teardown and
|
|
428
|
+
* still emit Kernel.Stopping / Kernel.Stopped, matching pre-split semantics.
|
|
429
|
+
*/
|
|
430
|
+
async start(): Promise<void> {
|
|
325
431
|
try {
|
|
326
|
-
await this.
|
|
327
|
-
await this.
|
|
328
|
-
await this.eventBus.emit("Kernel.Starting", {});
|
|
329
|
-
await this.rootContext.runTargets();
|
|
330
|
-
await this.eventBus.emit("Kernel.Started", {});
|
|
432
|
+
await this.boot();
|
|
433
|
+
await this.runTargets();
|
|
331
434
|
await this.waitForIdle();
|
|
332
435
|
} finally {
|
|
333
|
-
await this.
|
|
334
|
-
await this.rootContext.teardownResources();
|
|
335
|
-
await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
|
|
436
|
+
await this.teardown();
|
|
336
437
|
}
|
|
337
438
|
}
|
|
338
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Invoke a Telo.Invocable resource by `<kind>.<name>` (dot-form) or
|
|
442
|
+
* `{ kind, name }`. Resolves through the root module context, so the same
|
|
443
|
+
* dispatch, error path, and event emission that controller-to-controller
|
|
444
|
+
* invokes use apply here too.
|
|
445
|
+
*/
|
|
446
|
+
async invoke<TInputs = any, TOutput = any>(
|
|
447
|
+
ref: string | { kind: string; name: string },
|
|
448
|
+
inputs: TInputs,
|
|
449
|
+
): Promise<TOutput> {
|
|
450
|
+
if (this._isTornDown) {
|
|
451
|
+
throwInvalidState("invoke", "kernel has been torn down");
|
|
452
|
+
}
|
|
453
|
+
if (!this._isBooted) {
|
|
454
|
+
throwInvalidState("invoke", "boot() has not completed");
|
|
455
|
+
}
|
|
456
|
+
const parsed = typeof ref === "string" ? parseRef(ref) : ref;
|
|
457
|
+
return (await this.rootContext.invoke(parsed.kind, parsed.name, inputs)) as TOutput;
|
|
458
|
+
}
|
|
459
|
+
|
|
339
460
|
async emitRuntimeEvent(event: string, payload?: any): Promise<void> {
|
|
340
461
|
await this.eventBus.emit(event, payload);
|
|
341
462
|
}
|
|
342
463
|
|
|
464
|
+
/**
|
|
465
|
+
* URL of the entry manifest passed to `load()`, or `undefined` before
|
|
466
|
+
* `load()` has been called. Used by controllers and the controller-loader
|
|
467
|
+
* to anchor per-manifest install roots so every resource in the process
|
|
468
|
+
* shares a single `node_modules` tree (and therefore one realpath for
|
|
469
|
+
* `@telorun/sdk`).
|
|
470
|
+
*/
|
|
471
|
+
getEntryUrl(): string | undefined {
|
|
472
|
+
return this._entryUrl;
|
|
473
|
+
}
|
|
474
|
+
|
|
343
475
|
get exitCode(): number {
|
|
344
476
|
return this._exitCode;
|
|
345
477
|
}
|
|
@@ -383,11 +515,14 @@ export class Kernel implements IKernel {
|
|
|
383
515
|
}
|
|
384
516
|
|
|
385
517
|
/**
|
|
386
|
-
* Force-resolve waitForIdle()
|
|
387
|
-
* Used
|
|
388
|
-
*
|
|
518
|
+
* Force-resolve any pending `waitForIdle()` even when holds are still active.
|
|
519
|
+
* Used by external signal handlers (SIGINT/SIGTERM) to unblock graceful exit
|
|
520
|
+
* so `start()`'s waitForIdle returns and its finally clause runs `teardown()`.
|
|
521
|
+
*
|
|
522
|
+
* Does not tear down on its own — call `teardown()` directly if you're not
|
|
523
|
+
* inside `start()`.
|
|
389
524
|
*/
|
|
390
|
-
|
|
525
|
+
forceIdle(): void {
|
|
391
526
|
const resolvers = this.idleResolvers.splice(0);
|
|
392
527
|
for (const resolve of resolvers) resolve();
|
|
393
528
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { LoadedGraph, ManifestSource } from "@telorun/analyzer";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { statSync } from "fs";
|
|
4
|
+
import * as fs from "fs/promises";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
|
+
|
|
8
|
+
const CACHE_SUBDIR = ".telo/manifests";
|
|
9
|
+
const HTTP_NAMESPACE = "__http";
|
|
10
|
+
const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
|
|
11
|
+
const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
|
|
12
|
+
const QUERY_HASH_LENGTH = 12;
|
|
13
|
+
|
|
14
|
+
/** Verify that `candidate` resolves to a path under `root`. Returns the
|
|
15
|
+
* candidate path on success, `null` when any segment escapes the root.
|
|
16
|
+
* Guards against `..` segments inside module refs or HTTP pathnames. */
|
|
17
|
+
function joinUnder(root: string, ...segments: string[]): string | null {
|
|
18
|
+
if (segments.some((s) => s === "")) return null;
|
|
19
|
+
const candidate = path.join(root, ...segments);
|
|
20
|
+
const resolved = path.resolve(candidate);
|
|
21
|
+
const resolvedRoot = path.resolve(root);
|
|
22
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
|
|
29
|
+
* already point at a YAML file, append `/telo.yaml`. Used by both the
|
|
30
|
+
* reader and writer so a raw import URL and the canonical source it
|
|
31
|
+
* resolves to map to the same cache path. */
|
|
32
|
+
function normalizePathname(url: string, parsed: URL): string {
|
|
33
|
+
let pathname = parsed.pathname;
|
|
34
|
+
if (!url.includes(".yaml")) {
|
|
35
|
+
pathname = pathname.endsWith("/")
|
|
36
|
+
? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
|
|
37
|
+
: `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
38
|
+
}
|
|
39
|
+
return pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compute a short hash of `search + hash` so two URLs that differ only in
|
|
43
|
+
* query / fragment do not collide at the same cache path. Inserted before
|
|
44
|
+
* the final extension so the filename stays readable. */
|
|
45
|
+
function disambiguatePath(pathname: string, search: string, hash: string): string {
|
|
46
|
+
if (!search && !hash) return pathname;
|
|
47
|
+
const digest = createHash("sha256")
|
|
48
|
+
.update(search + hash)
|
|
49
|
+
.digest("hex")
|
|
50
|
+
.slice(0, QUERY_HASH_LENGTH);
|
|
51
|
+
const ext = path.extname(pathname);
|
|
52
|
+
const base = pathname.slice(0, pathname.length - ext.length);
|
|
53
|
+
return `${base}.${digest}${ext}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Single source of truth for URL → cache path. Used identically by the
|
|
57
|
+
* reader (cache lookup) and writer (install-time persistence). For any
|
|
58
|
+
* given import URL — registry ref, direct registry URL, or arbitrary
|
|
59
|
+
* HTTP — both sides land on the same file.
|
|
60
|
+
*
|
|
61
|
+
* Returns `null` for unsupported URLs (file://, memory://, relative paths)
|
|
62
|
+
* or for path-traversal attempts that would escape `cacheRoot`. */
|
|
63
|
+
function cachePathForUrl(
|
|
64
|
+
url: string,
|
|
65
|
+
cacheRoot: string,
|
|
66
|
+
registryUrl: string,
|
|
67
|
+
): string | null {
|
|
68
|
+
const trimmedRegistry = registryUrl.replace(/\/+$/, "");
|
|
69
|
+
|
|
70
|
+
// 1. Registry ref form: namespace/name@version
|
|
71
|
+
if (
|
|
72
|
+
!url.startsWith("http://") &&
|
|
73
|
+
!url.startsWith("https://") &&
|
|
74
|
+
!url.startsWith("/") &&
|
|
75
|
+
!url.startsWith(".") &&
|
|
76
|
+
!url.startsWith("file://") &&
|
|
77
|
+
!url.startsWith("memory://") &&
|
|
78
|
+
url.includes("@") &&
|
|
79
|
+
url.includes("/")
|
|
80
|
+
) {
|
|
81
|
+
const atIdx = url.lastIndexOf("@");
|
|
82
|
+
if (atIdx <= 0 || atIdx === url.length - 1) return null;
|
|
83
|
+
const modulePath = url.slice(0, atIdx);
|
|
84
|
+
if (!modulePath.includes("/")) return null;
|
|
85
|
+
const version = url.slice(atIdx + 1).replace(/^v/, "");
|
|
86
|
+
if (!version) return null;
|
|
87
|
+
return joinUnder(cacheRoot, modulePath, version, DEFAULT_MANIFEST_FILENAME);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. HTTP(S) URL — could be a direct registry URL or arbitrary external.
|
|
91
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
92
|
+
let parsed: URL;
|
|
93
|
+
try {
|
|
94
|
+
parsed = new URL(url);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const pathname = normalizePathname(url, parsed);
|
|
99
|
+
|
|
100
|
+
// 2a. URL is on the configured registry, with no query/fragment:
|
|
101
|
+
// fold into the registry layout so the writer that received a
|
|
102
|
+
// registry-ref canonical and the reader that sees a direct URL
|
|
103
|
+
// both resolve to the same file.
|
|
104
|
+
const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
|
|
105
|
+
if (
|
|
106
|
+
!parsed.search &&
|
|
107
|
+
!parsed.hash &&
|
|
108
|
+
(normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))
|
|
109
|
+
) {
|
|
110
|
+
const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
|
|
111
|
+
if (!rel) return null;
|
|
112
|
+
return joinUnder(cacheRoot, ...rel.split("/"));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2b. Arbitrary HTTP(S) URL → __http subtree, with a short query-hash
|
|
116
|
+
// suffix when query / fragment are present to prevent collisions.
|
|
117
|
+
const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
118
|
+
const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
|
|
119
|
+
return joinUnder(cacheRoot, HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
|
|
127
|
+
* Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
|
|
128
|
+
* makes boot hermetic, a miss falls through to the network source unchanged.
|
|
129
|
+
*
|
|
130
|
+
* Populated by `writeManifestCache` at install time.
|
|
131
|
+
*/
|
|
132
|
+
export class LocalManifestCacheSource implements ManifestSource {
|
|
133
|
+
private readonly cacheRoot: string;
|
|
134
|
+
private readonly registryUrl: string;
|
|
135
|
+
|
|
136
|
+
constructor(entryDir: string, registryUrl: string = DEFAULT_REGISTRY_URL) {
|
|
137
|
+
this.cacheRoot = path.join(entryDir, CACHE_SUBDIR);
|
|
138
|
+
this.registryUrl = registryUrl;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
supports(url: string): boolean {
|
|
142
|
+
return this.tryMap(url) !== null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async read(url: string): Promise<{ text: string; source: string }> {
|
|
146
|
+
const mapped = this.tryMap(url);
|
|
147
|
+
if (!mapped) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`LocalManifestCacheSource does not support '${url}' (cache miss or unsupported scheme)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const text = await fs.readFile(mapped, "utf-8");
|
|
153
|
+
return { text, source: pathToFileURL(mapped).href };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
resolveRelative(base: string, relative: string): string {
|
|
157
|
+
// Once `read()` serves a file the canonical `source` is a file:// URL, so
|
|
158
|
+
// any further include: / sibling resolution flows through LocalFileSource.
|
|
159
|
+
// This method exists only for completeness; if the loader ever invokes it
|
|
160
|
+
// with a cache-mapped base, fall back to file-URL semantics.
|
|
161
|
+
const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
|
|
162
|
+
return new URL(relative, baseDir).href;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private tryMap(url: string): string | null {
|
|
166
|
+
const candidate = cachePathForUrl(url, this.cacheRoot, this.registryUrl);
|
|
167
|
+
if (!candidate) return null;
|
|
168
|
+
// Require a regular file. A directory, dangling symlink, or stat failure
|
|
169
|
+
// (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
|
|
170
|
+
// so the next source in the chain still gets a chance to serve the URL.
|
|
171
|
+
try {
|
|
172
|
+
return statSync(candidate).isFile() ? candidate : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Map a graph's canonical `source` URL to the on-disk cache file path it
|
|
181
|
+
* should be written to (writer side). Returns `null` for sources that do
|
|
182
|
+
* not need caching — file:// (already on disk), memory:// (transient), or
|
|
183
|
+
* any path that would escape the cache root.
|
|
184
|
+
*
|
|
185
|
+
* Uses the same mapping function as `LocalManifestCacheSource`, so the
|
|
186
|
+
* writer and reader always agree on where every URL lives.
|
|
187
|
+
*/
|
|
188
|
+
export function cachePathForCanonical(
|
|
189
|
+
canonicalSource: string,
|
|
190
|
+
entryDir: string,
|
|
191
|
+
registryUrl: string,
|
|
192
|
+
): string | null {
|
|
193
|
+
const cacheRoot = path.join(entryDir, CACHE_SUBDIR);
|
|
194
|
+
return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Persist every manifest file reachable from `graph` (owners + partials) to
|
|
199
|
+
* `<entryDir>/.telo/manifests/`, except the entry manifest itself and any
|
|
200
|
+
* file:// or memory:// sources (already on disk or transient).
|
|
201
|
+
*
|
|
202
|
+
* Idempotent: rewrites any existing file with the freshly fetched bytes so
|
|
203
|
+
* a partial re-install converges. Never deletes entries — stale versions
|
|
204
|
+
* stay until `.telo/manifests/` is removed by hand, matching the
|
|
205
|
+
* `.telo/npm/` convention.
|
|
206
|
+
*
|
|
207
|
+
* Returns the list of paths written, for diagnostics.
|
|
208
|
+
*/
|
|
209
|
+
export async function writeManifestCache(
|
|
210
|
+
graph: LoadedGraph,
|
|
211
|
+
entryDir: string,
|
|
212
|
+
registryUrl: string = DEFAULT_REGISTRY_URL,
|
|
213
|
+
): Promise<string[]> {
|
|
214
|
+
const written: string[] = [];
|
|
215
|
+
const seen = new Set<string>();
|
|
216
|
+
|
|
217
|
+
for (const [, module] of graph.modules) {
|
|
218
|
+
for (const file of [module.owner, ...module.partials]) {
|
|
219
|
+
if (file.source === graph.rootSource) continue;
|
|
220
|
+
if (seen.has(file.source)) continue;
|
|
221
|
+
seen.add(file.source);
|
|
222
|
+
|
|
223
|
+
const target = cachePathForCanonical(file.source, entryDir, registryUrl);
|
|
224
|
+
if (!target) continue;
|
|
225
|
+
|
|
226
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
227
|
+
await fs.writeFile(target, file.text, "utf-8");
|
|
228
|
+
written.push(target);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return written;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Resolve the entry-anchor directory for the manifest cache.
|
|
236
|
+
*
|
|
237
|
+
* For a file path or `file://` URL: returns the containing directory.
|
|
238
|
+
* For a directory path: returns the directory itself.
|
|
239
|
+
* For an HTTP(S) URL: returns `null` (no local anchor; cache writes skipped). */
|
|
240
|
+
export function resolveEntryDir(entryPath: string): string | null {
|
|
241
|
+
if (entryPath.startsWith("http://") || entryPath.startsWith("https://")) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
let absolute: string;
|
|
245
|
+
if (entryPath.startsWith("file://")) {
|
|
246
|
+
absolute = fileURLToPath(entryPath);
|
|
247
|
+
} else {
|
|
248
|
+
absolute = path.resolve(entryPath);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const stat = statSync(absolute);
|
|
252
|
+
return stat.isDirectory() ? absolute : path.dirname(absolute);
|
|
253
|
+
} catch {
|
|
254
|
+
return path.dirname(absolute);
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/resource-context.ts
CHANGED
|
@@ -262,6 +262,10 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
262
262
|
return this.moduleContext.getControllerPolicy();
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
getEntryUrl(): string | undefined {
|
|
266
|
+
return this.kernel.getEntryUrl();
|
|
267
|
+
}
|
|
268
|
+
|
|
265
269
|
on(event: string, handler: (payload?: any) => void | Promise<void>): void {
|
|
266
270
|
this.kernel.on(event, handler);
|
|
267
271
|
}
|