@telorun/kernel 0.41.0 → 0.43.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/files-integrity.d.ts +25 -0
- package/dist/bundle/files-integrity.d.ts.map +1 -0
- package/dist/bundle/files-integrity.js +40 -0
- package/dist/bundle/files-integrity.js.map +1 -0
- package/dist/bundle/module-manifest.d.ts +22 -0
- package/dist/bundle/module-manifest.d.ts.map +1 -0
- package/dist/bundle/module-manifest.js +33 -0
- package/dist/bundle/module-manifest.js.map +1 -0
- package/dist/bundle/tar.d.ts +20 -0
- package/dist/bundle/tar.d.ts.map +1 -0
- package/dist/bundle/tar.js +63 -0
- package/dist/bundle/tar.js.map +1 -0
- package/dist/controller-loaders/bundle-builder.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-builder.js +13 -2
- package/dist/controller-loaders/bundle-builder.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts +3 -0
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +7 -2
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +8 -2
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.d.ts +2 -2
- package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
- package/dist/manifest-sources/local-manifest-cache-source.js +24 -90
- package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
- package/dist/transports/oci/docker-credentials.d.ts +10 -0
- package/dist/transports/oci/docker-credentials.d.ts.map +1 -0
- package/dist/transports/oci/docker-credentials.js +75 -0
- package/dist/transports/oci/docker-credentials.js.map +1 -0
- package/dist/transports/oci/oci-client.d.ts +51 -0
- package/dist/transports/oci/oci-client.d.ts.map +1 -0
- package/dist/transports/oci/oci-client.js +170 -0
- package/dist/transports/oci/oci-client.js.map +1 -0
- package/dist/transports/oci/oci-ref.d.ts +21 -0
- package/dist/transports/oci/oci-ref.d.ts.map +1 -0
- package/dist/transports/oci/oci-ref.js +36 -0
- package/dist/transports/oci/oci-ref.js.map +1 -0
- package/dist/transports/oci/oci-transport.d.ts +29 -0
- package/dist/transports/oci/oci-transport.d.ts.map +1 -0
- package/dist/transports/oci/oci-transport.js +142 -0
- package/dist/transports/oci/oci-transport.js.map +1 -0
- package/dist/transports/registry-transport.d.ts +23 -0
- package/dist/transports/registry-transport.d.ts.map +1 -0
- package/dist/transports/registry-transport.js +225 -0
- package/dist/transports/registry-transport.js.map +1 -0
- package/dist/transports/transport-registry.d.ts +40 -0
- package/dist/transports/transport-registry.d.ts.map +1 -0
- package/dist/transports/transport-registry.js +70 -0
- package/dist/transports/transport-registry.js.map +1 -0
- package/dist/transports/transport.d.ts +92 -0
- package/dist/transports/transport.d.ts.map +1 -0
- package/dist/transports/transport.js +2 -0
- package/dist/transports/transport.js.map +1 -0
- package/package.json +6 -3
- package/src/bundle/files-integrity.ts +46 -0
- package/src/bundle/module-manifest.ts +49 -0
- package/src/bundle/tar.ts +78 -0
- package/src/controller-loaders/bundle-builder.ts +14 -2
- package/src/controllers/module/import-controller.ts +7 -2
- package/src/index.ts +22 -0
- package/src/kernel.ts +7 -2
- package/src/manifest-sources/local-manifest-cache-source.ts +25 -98
- package/src/transports/oci/docker-credentials.ts +85 -0
- package/src/transports/oci/oci-client.ts +237 -0
- package/src/transports/oci/oci-ref.ts +53 -0
- package/src/transports/oci/oci-transport.ts +192 -0
- package/src/transports/registry-transport.ts +281 -0
- package/src/transports/transport-registry.ts +91 -0
- package/src/transports/transport.ts +112 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MANIFEST_FILENAME,
|
|
3
|
+
HttpSource,
|
|
4
|
+
IntegrityError,
|
|
5
|
+
RegistrySource,
|
|
6
|
+
isRegistryRef,
|
|
7
|
+
parseModuleRef,
|
|
8
|
+
splitIntegrity,
|
|
9
|
+
type ManifestSource,
|
|
10
|
+
} from "@telorun/analyzer";
|
|
11
|
+
import { createHash } from "crypto";
|
|
12
|
+
|
|
13
|
+
import { computeFilesIntegrity, injectFilesIntegrity } from "../bundle/files-integrity.js";
|
|
14
|
+
import { readOwnerManifest } from "../bundle/module-manifest.js";
|
|
15
|
+
import { makeTarGz, readTarGz, toPayloadFiles } from "../bundle/tar.js";
|
|
16
|
+
import type {
|
|
17
|
+
FetchedArtifact,
|
|
18
|
+
PublishBundle,
|
|
19
|
+
PublishOptions,
|
|
20
|
+
PublishResult,
|
|
21
|
+
SiblingIdentity,
|
|
22
|
+
Transport,
|
|
23
|
+
} from "./transport.js";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
|
|
26
|
+
const HTTP_NAMESPACE = "__http";
|
|
27
|
+
const QUERY_HASH_LENGTH = 12;
|
|
28
|
+
const MAX_PUSH_ATTEMPTS = 4;
|
|
29
|
+
const PUSH_BASE_DELAY_MS = 1000;
|
|
30
|
+
|
|
31
|
+
/** Registry / object-storage backends treat 408/425/429/5xx as transient. */
|
|
32
|
+
function isRetryableStatus(status: number): boolean {
|
|
33
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sleep(ms: number): Promise<void> {
|
|
37
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Mirror `HttpSource.read`'s `fetchUrl` derivation: when the URL does not
|
|
41
|
+
* already point at a YAML file, append `/telo.yaml`, so a raw import URL and
|
|
42
|
+
* the canonical source it resolves to map to the same cache path. */
|
|
43
|
+
function normalizePathname(rawUrl: string, parsed: URL): string {
|
|
44
|
+
let pathname = parsed.pathname;
|
|
45
|
+
if (!rawUrl.includes(".yaml")) {
|
|
46
|
+
pathname = pathname.endsWith("/")
|
|
47
|
+
? `${pathname}${DEFAULT_MANIFEST_FILENAME}`
|
|
48
|
+
: `${pathname}/${DEFAULT_MANIFEST_FILENAME}`;
|
|
49
|
+
}
|
|
50
|
+
return pathname;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Short hash of `search + hash` so two URLs that differ only in query /
|
|
54
|
+
* fragment do not collide at the same cache path. */
|
|
55
|
+
function disambiguatePath(pathname: string, search: string, hash: string): string {
|
|
56
|
+
if (!search && !hash) return pathname;
|
|
57
|
+
const digest = createHash("sha256")
|
|
58
|
+
.update(search + hash)
|
|
59
|
+
.digest("hex")
|
|
60
|
+
.slice(0, QUERY_HASH_LENGTH);
|
|
61
|
+
const dotIdx = pathname.lastIndexOf(".");
|
|
62
|
+
const slashIdx = pathname.lastIndexOf("/");
|
|
63
|
+
const ext = dotIdx > slashIdx ? pathname.slice(dotIdx) : "";
|
|
64
|
+
const base = pathname.slice(0, pathname.length - ext.length);
|
|
65
|
+
return `${base}.${digest}${ext}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface VersionsResponse {
|
|
69
|
+
versions?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The default HTTP transport: bare `namespace/name@version` registry refs and
|
|
73
|
+
* direct `https://…` URLs, resolving against `registry.telo.run` (or a
|
|
74
|
+
* configured registry). Its resolution `source` composes the browser-safe
|
|
75
|
+
* `RegistrySource` / `HttpSource` from `analyzer`; the Node-only management
|
|
76
|
+
* methods live here. This is the fallback transport for any ref that carries
|
|
77
|
+
* no owning scheme, so `oci://` (or a future `s3://`) never falls through to
|
|
78
|
+
* it — those refs are claimed by their own transport's `supports()`. */
|
|
79
|
+
export class RegistryTransport implements Transport {
|
|
80
|
+
private readonly registrySource: RegistrySource;
|
|
81
|
+
private readonly httpSource: HttpSource;
|
|
82
|
+
readonly source: ManifestSource;
|
|
83
|
+
|
|
84
|
+
constructor(private readonly registryUrl: string = DEFAULT_REGISTRY_URL) {
|
|
85
|
+
this.registrySource = new RegistrySource(registryUrl);
|
|
86
|
+
this.httpSource = new HttpSource();
|
|
87
|
+
const pick = (ref: string): ManifestSource =>
|
|
88
|
+
this.httpSource.supports(ref) ? this.httpSource : this.registrySource;
|
|
89
|
+
this.source = {
|
|
90
|
+
supports: (url) => this.supports(url),
|
|
91
|
+
read: (url) => pick(url).read(url),
|
|
92
|
+
resolveRelative: (base, relative) => pick(base).resolveRelative(base, relative),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
supports(ref: string): boolean {
|
|
97
|
+
const { base } = splitIntegrity(ref);
|
|
98
|
+
return base.startsWith("http://") || base.startsWith("https://") || isRegistryRef(ref);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cacheLocation(ref: string): string[] | null {
|
|
102
|
+
const url = splitIntegrity(ref).base;
|
|
103
|
+
const trimmedRegistry = this.registryUrl.replace(/\/+$/, "");
|
|
104
|
+
|
|
105
|
+
// 1. Registry ref form: namespace/name@version
|
|
106
|
+
if (isRegistryRef(url)) {
|
|
107
|
+
let parsed: ReturnType<typeof parseModuleRef>;
|
|
108
|
+
try {
|
|
109
|
+
parsed = parseModuleRef(url);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return [parsed.modulePath, parsed.version, DEFAULT_MANIFEST_FILENAME];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. HTTP(S) URL — a direct registry URL or arbitrary external.
|
|
117
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
118
|
+
let parsed: URL;
|
|
119
|
+
try {
|
|
120
|
+
parsed = new URL(url);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const pathname = normalizePathname(url, parsed);
|
|
125
|
+
|
|
126
|
+
// 2a. On the configured registry, no query/fragment: fold into the
|
|
127
|
+
// registry layout so a ref and a direct URL land on the same file.
|
|
128
|
+
const normalizedUrl = `${parsed.protocol}//${parsed.host}${pathname}`;
|
|
129
|
+
if (
|
|
130
|
+
!parsed.search &&
|
|
131
|
+
!parsed.hash &&
|
|
132
|
+
(normalizedUrl === trimmedRegistry || normalizedUrl.startsWith(`${trimmedRegistry}/`))
|
|
133
|
+
) {
|
|
134
|
+
const rel = normalizedUrl.slice(trimmedRegistry.length + 1);
|
|
135
|
+
if (!rel) return null;
|
|
136
|
+
return rel.split("/");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2b. Arbitrary HTTP(S) → __http subtree, query-hash suffix on collision.
|
|
140
|
+
const cleanPath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
141
|
+
const disambiguated = disambiguatePath(cleanPath, parsed.search, parsed.hash);
|
|
142
|
+
return [HTTP_NAMESPACE, parsed.host, ...disambiguated.split("/")];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async listVersions(ref: string): Promise<string[] | null> {
|
|
149
|
+
// Only bare registry refs are version-enumerable — a direct `https://` URL
|
|
150
|
+
// has no version-list endpoint.
|
|
151
|
+
if (!isRegistryRef(ref)) return null;
|
|
152
|
+
const { modulePath } = parseModuleRef(ref);
|
|
153
|
+
const url = `${this.registryUrl.replace(/\/+$/, "")}/${modulePath}`;
|
|
154
|
+
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
155
|
+
if (res.status === 404) return null;
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
throw new Error(`Registry returned ${res.status} ${res.statusText} for ${modulePath}`);
|
|
158
|
+
}
|
|
159
|
+
const body = (await res.json()) as VersionsResponse;
|
|
160
|
+
return Array.isArray(body.versions) ? body.versions : [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async fetchArtifact(ref: string): Promise<FetchedArtifact> {
|
|
164
|
+
// `read` verifies the manifest bytes against the inline `#sha256-...` hash.
|
|
165
|
+
const { text: manifest, source } = await this.source.read(ref);
|
|
166
|
+
const meta = readOwnerManifest(manifest);
|
|
167
|
+
if (!meta.declaresFiles) return { manifest, files: [] };
|
|
168
|
+
|
|
169
|
+
// The payload rides beside the manifest as `module.tar.gz`.
|
|
170
|
+
const tarUrl = source.replace(/\/telo\.yaml$/, "/module.tar.gz");
|
|
171
|
+
const res = await fetch(tarUrl);
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
throw new Error(`could not fetch bundle ${tarUrl}: ${res.status} ${res.statusText}`);
|
|
174
|
+
}
|
|
175
|
+
const files = toPayloadFiles(await readTarGz(Buffer.from(await res.arrayBuffer())));
|
|
176
|
+
|
|
177
|
+
// Verify the payload against the manifest's `filesIntegrity` before handing
|
|
178
|
+
// it back — a mismatch is terminal (a tampered bundle must never be used).
|
|
179
|
+
// The manifest that carries the hash is itself pinned by the inline hash.
|
|
180
|
+
if (meta.filesIntegrity) {
|
|
181
|
+
const actual = await computeFilesIntegrity(files);
|
|
182
|
+
if (actual !== meta.filesIntegrity) {
|
|
183
|
+
throw new IntegrityError(
|
|
184
|
+
`Integrity check failed for bundle ${tarUrl}: filesIntegrity expected ` +
|
|
185
|
+
`${meta.filesIntegrity}, got ${actual}. The payload does not match the recorded ` +
|
|
186
|
+
`hash — the module may have been tampered with or republished.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { manifest, files };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async publish(
|
|
195
|
+
destination: string,
|
|
196
|
+
bundle: PublishBundle,
|
|
197
|
+
opts: PublishOptions = {},
|
|
198
|
+
): Promise<PublishResult> {
|
|
199
|
+
// Pin the payload in the manifest before it enters the tarball, then choose
|
|
200
|
+
// the artifact body: a `module.tar.gz` when there are files, else raw YAML.
|
|
201
|
+
let manifest = bundle.manifest;
|
|
202
|
+
let body: string | Uint8Array = manifest;
|
|
203
|
+
let contentType = "text/yaml";
|
|
204
|
+
let urlSuffix = "";
|
|
205
|
+
if (bundle.files.length > 0) {
|
|
206
|
+
manifest = injectFilesIntegrity(manifest, await computeFilesIntegrity(bundle.files));
|
|
207
|
+
const entries = [
|
|
208
|
+
{ name: DEFAULT_MANIFEST_FILENAME, content: manifest },
|
|
209
|
+
...bundle.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
|
|
210
|
+
];
|
|
211
|
+
body = await makeTarGz(entries);
|
|
212
|
+
contentType = "application/gzip";
|
|
213
|
+
urlSuffix = "/module.tar.gz";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { namespace, name, version } = readOwnerManifest(manifest);
|
|
217
|
+
if (!namespace || !name || !version) {
|
|
218
|
+
throw new Error("metadata must include namespace, name, and version.");
|
|
219
|
+
}
|
|
220
|
+
const identity = { namespace, name, version };
|
|
221
|
+
const base = `${destination.replace(/\/+$/, "")}/${identity.namespace}/${identity.name}/${identity.version}`;
|
|
222
|
+
const url = `${base}${urlSuffix}`;
|
|
223
|
+
const label = `${identity.namespace}/${identity.name}@${identity.version}`;
|
|
224
|
+
|
|
225
|
+
const headers: Record<string, string> = { "content-type": contentType };
|
|
226
|
+
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
|
227
|
+
|
|
228
|
+
let res: Response | null = null;
|
|
229
|
+
let networkErr: unknown = null;
|
|
230
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
231
|
+
networkErr = null;
|
|
232
|
+
try {
|
|
233
|
+
res = await fetch(url, { method: "PUT", headers, body });
|
|
234
|
+
} catch (err) {
|
|
235
|
+
networkErr = err;
|
|
236
|
+
res = null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const transient = networkErr != null || (res != null && isRetryableStatus(res.status));
|
|
240
|
+
if (!transient) break;
|
|
241
|
+
if (attempt === MAX_PUSH_ATTEMPTS) break;
|
|
242
|
+
|
|
243
|
+
const reason = networkErr
|
|
244
|
+
? `network error: ${networkErr instanceof Error ? networkErr.message : String(networkErr)}`
|
|
245
|
+
: `HTTP ${res!.status}`;
|
|
246
|
+
// Drain the body so the underlying connection can be reused for the retry.
|
|
247
|
+
if (res) await res.text().catch(() => {});
|
|
248
|
+
const delayMs = PUSH_BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * 250);
|
|
249
|
+
opts.onRetry?.({ reason, attempt, maxAttempts: MAX_PUSH_ATTEMPTS, delayMs });
|
|
250
|
+
await sleep(delayMs);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (networkErr) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Network error: ${networkErr instanceof Error ? networkErr.message : String(networkErr)} ` +
|
|
256
|
+
`(after ${MAX_PUSH_ATTEMPTS} attempts)`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (!res!.ok) {
|
|
260
|
+
const ct = res!.headers.get("content-type") ?? "";
|
|
261
|
+
const errBody = ct.includes("application/json") ? await res!.json() : await res!.text();
|
|
262
|
+
throw new Error(`Push failed (${res!.status}): ${JSON.stringify(errBody)}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { label, url };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
canonicalizeSiblingRef(
|
|
269
|
+
_destination: string,
|
|
270
|
+
_relativeSource: string,
|
|
271
|
+
sibling: SiblingIdentity,
|
|
272
|
+
): string {
|
|
273
|
+
// An HTTP registry path defaults to the sibling's own `<namespace>/<name>`.
|
|
274
|
+
if (!sibling.namespace || !sibling.name) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
"a relative import canonicalized to an HTTP registry needs the sibling's metadata.namespace and metadata.name.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return `${sibling.namespace}/${sibling.name}@${sibling.version}`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ManifestSource } from "@telorun/analyzer";
|
|
2
|
+
|
|
3
|
+
import { OciTransport } from "./oci/oci-transport.js";
|
|
4
|
+
import { RegistryTransport } from "./registry-transport.js";
|
|
5
|
+
import type {
|
|
6
|
+
FetchedArtifact,
|
|
7
|
+
PublishBundle,
|
|
8
|
+
PublishOptions,
|
|
9
|
+
PublishResult,
|
|
10
|
+
Transport,
|
|
11
|
+
} from "./transport.js";
|
|
12
|
+
|
|
13
|
+
/** Dispatches ref-scheme-specific operations to the transport that owns a ref.
|
|
14
|
+
* The loader, cache source, `upgrade`, and `publish` consult this instead of
|
|
15
|
+
* branching on ref shape. `RegistryTransport` is always last so it is the
|
|
16
|
+
* fallback for bare / `https` refs, and a scheme-owning transport (OCI, later
|
|
17
|
+
* S3) claims its refs via `supports()` before the fallback is reached. */
|
|
18
|
+
export class TransportRegistry {
|
|
19
|
+
constructor(private readonly transports: Transport[]) {}
|
|
20
|
+
|
|
21
|
+
/** The transport that owns `ref`, or `undefined` if none does. */
|
|
22
|
+
forRef(ref: string): Transport | undefined {
|
|
23
|
+
return this.transports.find((t) => t.supports(ref));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The resolution `ManifestSource`s of every registered transport, in order —
|
|
27
|
+
* the browser-safe subset the loader appends to its source chain. */
|
|
28
|
+
sources(): ManifestSource[] {
|
|
29
|
+
return this.transports.map((t) => t.source);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Cache-path segments for `ref`, from its owning transport; `null` when no
|
|
33
|
+
* transport owns it or the ref is not cacheable. */
|
|
34
|
+
cacheLocation(ref: string): string[] | null {
|
|
35
|
+
return this.forRef(ref)?.cacheLocation(ref) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Published versions for `ref` via its owning transport; `null` when the
|
|
39
|
+
* module is unpublished. Throws when no transport owns the ref. */
|
|
40
|
+
listVersions(ref: string): Promise<string[] | null> {
|
|
41
|
+
return this.require(ref).listVersions(ref);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Full artifact for `ref` via its owning transport. Throws when no transport
|
|
45
|
+
* owns the ref. */
|
|
46
|
+
fetchArtifact(ref: string): Promise<FetchedArtifact> {
|
|
47
|
+
return this.require(ref).fetchArtifact(ref);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Publish `bundle` to `destination` via the transport its scheme selects.
|
|
51
|
+
* Throws when no transport owns the destination. */
|
|
52
|
+
publish(
|
|
53
|
+
destination: string,
|
|
54
|
+
bundle: PublishBundle,
|
|
55
|
+
opts?: PublishOptions,
|
|
56
|
+
): Promise<PublishResult> {
|
|
57
|
+
return this.require(destination).publish(destination, bundle, opts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private require(ref: string): Transport {
|
|
61
|
+
const transport = this.forRef(ref);
|
|
62
|
+
if (!transport) {
|
|
63
|
+
throw new Error(`no transport owns ref '${ref}'`);
|
|
64
|
+
}
|
|
65
|
+
return transport;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The default transport set. Scheme-owning transports come first; the
|
|
70
|
+
* `RegistryTransport` is last, the fallback for bare / `https` refs. OCI (and
|
|
71
|
+
* later S3) claim their `oci://` / `s3://` refs before the fallback is reached. */
|
|
72
|
+
export function defaultTransports(registryUrl?: string): Transport[] {
|
|
73
|
+
return [new OciTransport(), new RegistryTransport(registryUrl)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const defaultRegistryCache = new Map<string, TransportRegistry>();
|
|
77
|
+
|
|
78
|
+
/** A `TransportRegistry` seeded with {@link defaultTransports}, memoized per
|
|
79
|
+
* `registryUrl`. The default transports are stateless config (a fresh
|
|
80
|
+
* `OciClient` with its own token cache is created per OCI operation), so one
|
|
81
|
+
* shared instance per registry URL is safe — and avoids re-instantiating the
|
|
82
|
+
* whole set on hot paths like `cachePathForCanonical`. */
|
|
83
|
+
export function defaultTransportRegistry(registryUrl?: string): TransportRegistry {
|
|
84
|
+
const key = registryUrl ?? "";
|
|
85
|
+
let cached = defaultRegistryCache.get(key);
|
|
86
|
+
if (!cached) {
|
|
87
|
+
cached = new TransportRegistry(defaultTransports(registryUrl));
|
|
88
|
+
defaultRegistryCache.set(key, cached);
|
|
89
|
+
}
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ManifestSource } from "@telorun/analyzer";
|
|
2
|
+
|
|
3
|
+
import type { PayloadFile } from "../bundle/files-integrity.js";
|
|
4
|
+
|
|
5
|
+
/** The full module artifact a transport delivers: the `telo.yaml` bytes plus
|
|
6
|
+
* the decompressed `files:` payload (empty for a manifest-only module). The
|
|
7
|
+
* manifest is already verified against the import's inline hash; the payload
|
|
8
|
+
* against the manifest-embedded `filesIntegrity`. */
|
|
9
|
+
export interface FetchedArtifact {
|
|
10
|
+
manifest: string;
|
|
11
|
+
files: PayloadFile[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The module bundle handed to a transport for publishing: the final,
|
|
15
|
+
* already-analyzed / pinned / canonicalized `telo.yaml` bytes plus the `files:`
|
|
16
|
+
* payload (empty for a manifest-only module). The transport is responsible for
|
|
17
|
+
* pinning the payload (`filesIntegrity`) and for the artifact shape it writes
|
|
18
|
+
* (HTTP: `telo.yaml` + `module.tar.gz`; OCI: a single blob). */
|
|
19
|
+
export interface PublishBundle {
|
|
20
|
+
manifest: string;
|
|
21
|
+
files: PayloadFile[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PublishResult {
|
|
25
|
+
/** Human-readable `<ns>/<name>@<version>` label of what was pushed. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** The location the artifact was written to. */
|
|
28
|
+
url: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Identity of a sibling library, read from its own manifest, that a relative
|
|
32
|
+
* import canonicalizes to. `version` is always required; `namespace`/`name` are
|
|
33
|
+
* used only by transports whose location is metadata-derived (HTTP registry). */
|
|
34
|
+
export interface SiblingIdentity {
|
|
35
|
+
namespace?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
version: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PublishOptions {
|
|
41
|
+
/** Bearer token for registries that require auth. */
|
|
42
|
+
token?: string;
|
|
43
|
+
/** Notified before each backoff sleep on a transient push failure, so the
|
|
44
|
+
* caller can surface retry progress. */
|
|
45
|
+
onRetry?: (info: {
|
|
46
|
+
reason: string;
|
|
47
|
+
attempt: number;
|
|
48
|
+
maxAttempts: number;
|
|
49
|
+
delayMs: number;
|
|
50
|
+
}) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A Transport owns everything ref-scheme-specific about a module's lifecycle:
|
|
54
|
+
* resolution (through its `ManifestSource`), cache placement, version
|
|
55
|
+
* enumeration, full-artifact fetch, and publish. Registering a transport is
|
|
56
|
+
* the only thing needed to add a backend — the loader, cache, `upgrade`, and
|
|
57
|
+
* `publish` never branch on scheme again; they ask the {@link TransportRegistry}
|
|
58
|
+
* which transport owns a ref and delegate.
|
|
59
|
+
*
|
|
60
|
+
* A Transport *composes* a resolution `ManifestSource`, it does not extend it:
|
|
61
|
+
* `ManifestSource` is the browser-safe resolution primitive (also implemented
|
|
62
|
+
* by the cache / local / memory sources, which have no versions and nothing to
|
|
63
|
+
* publish), so it stays in `analyzer`, while the Node-only management methods
|
|
64
|
+
* (`cacheLocation` and, in later phases, `listVersions` / `fetchArtifact` /
|
|
65
|
+
* `publish`) live on the Transport here in `kernel`. */
|
|
66
|
+
export interface Transport {
|
|
67
|
+
/** True when this transport owns the given ref (or publish destination). */
|
|
68
|
+
supports(ref: string): boolean;
|
|
69
|
+
|
|
70
|
+
/** The resolution primitive: fetch + verify `telo.yaml`, resolve relatives.
|
|
71
|
+
* Browser-safe for a browser-reachable transport (HTTP/registry), so it can
|
|
72
|
+
* live in `analyzer`; a Node-only transport has no browser-safe source. */
|
|
73
|
+
readonly source: ManifestSource;
|
|
74
|
+
|
|
75
|
+
/** Deterministic cache-path segments for a ref, joined under the cache root by
|
|
76
|
+
* the cache source. Returns `null` when the ref is not cacheable here
|
|
77
|
+
* (unsupported scheme, malformed ref, or path-traversal in the ref). */
|
|
78
|
+
cacheLocation(ref: string): string[] | null;
|
|
79
|
+
|
|
80
|
+
/** The versions published for the module `ref` names, newest-first order not
|
|
81
|
+
* guaranteed (the caller sorts). Returns `null` when the module is not
|
|
82
|
+
* published (e.g. a 404), distinct from `[]` (published, no versions). Used
|
|
83
|
+
* by `telo upgrade`. */
|
|
84
|
+
listVersions(ref: string): Promise<string[] | null>;
|
|
85
|
+
|
|
86
|
+
/** Retrieve the full artifact for `ref` — the `telo.yaml` and its `files:`
|
|
87
|
+
* payload — verifying the manifest against the inline hash and the payload
|
|
88
|
+
* against the manifest's `filesIntegrity`. Used by `telo install`; subsumes
|
|
89
|
+
* the out-of-band bundle fetch that used to sit outside the source chain. */
|
|
90
|
+
fetchArtifact(ref: string): Promise<FetchedArtifact>;
|
|
91
|
+
|
|
92
|
+
/** Push `bundle` to `destination` (a base ref / repo whose scheme this
|
|
93
|
+
* transport owns), pinning the payload and writing the transport-native
|
|
94
|
+
* artifact shape. Throws on failure. Used by `telo publish`. */
|
|
95
|
+
publish(
|
|
96
|
+
destination: string,
|
|
97
|
+
bundle: PublishBundle,
|
|
98
|
+
opts?: PublishOptions,
|
|
99
|
+
): Promise<PublishResult>;
|
|
100
|
+
|
|
101
|
+
/** Canonicalize a relative sibling import (`../lib`) declared in a module
|
|
102
|
+
* being published to `destination` into the absolute ref it will resolve to
|
|
103
|
+
* once published. Owns the scheme-specific "where does a sibling land" rule —
|
|
104
|
+
* OCI derives the repo from the destination, HTTP from the sibling's
|
|
105
|
+
* `<namespace>/<name>` — so `telo publish` delegates instead of branching on
|
|
106
|
+
* transport shape. */
|
|
107
|
+
canonicalizeSiblingRef(
|
|
108
|
+
destination: string,
|
|
109
|
+
relativeSource: string,
|
|
110
|
+
sibling: SiblingIdentity,
|
|
111
|
+
): string;
|
|
112
|
+
}
|