@mosetta/build 0.0.0-stage → 0.0.9

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.
@@ -0,0 +1,516 @@
1
+ // THE RELEASE of a built distribution into a code store — the rules every publisher runs: the
2
+ // direct `mosetta deploy` against a provider registry, and the platform's API on behalf of a
3
+ // person. Portable (no node): a store is anything with `get(key) → Response` and
4
+ // `put(key, body, meta) → Response` that honours the write conditions (`ifMatch`, `ifNoneMatch`) —
5
+ // a provider handle, or a capability stub an isolate was granted.
6
+ //
7
+ // What a release keeps true:
8
+ // - a deployed version never changes: a module whose id is there with other bytes is refused, and
9
+ // its version has to go up;
10
+ // - the catalogs grow — every version deployed stays named, so pages naming an older one keep
11
+ // rendering;
12
+ // - the artifacts land before the catalogs that name them, the private catalog last (its new ETag
13
+ // tells the runner the new generation is complete);
14
+ // - two releases into one store at once both land or one is refused: every catalog is written
15
+ // over exactly the copy the plan was made from (`If-Match`), and a module file only where none is
16
+ // (`If-None-Match: *`).
17
+
18
+ export const CATALOG_KEY = 'modules.json';
19
+ export const PREFABS_PREFIX = 'prefabs/';
20
+ // Artifact names are version-qualified and a deployed version never takes new bytes — yet the
21
+ // stores still hold artifacts from the time 0.0.1 was re-deployed in place, and the shell pins
22
+ // every artifact by SRI. Public objects keep a SHORT cache life until those are gone; the edge
23
+ // revalidates by ETag after it.
24
+ export const PUBLIC_CACHE_CONTROL = 'public, max-age=300';
25
+ // The catalogs name the integrity of every artifact: a stale copy makes pages pin bytes that are
26
+ // no longer there, so they are always revalidated.
27
+ export const CATALOG_CACHE_CONTROL = 'no-cache';
28
+ /** how many times a release starts over when another one wrote a catalog under it */
29
+ const ATTEMPTS = 3;
30
+ const CONCURRENCY = 8;
31
+
32
+ const CONTENT_TYPES = {
33
+ '.js': 'text/javascript; charset=utf-8',
34
+ '.mjs': 'text/javascript; charset=utf-8',
35
+ '.css': 'text/css; charset=utf-8',
36
+ '.json': 'application/json; charset=utf-8',
37
+ '.map': 'application/json; charset=utf-8',
38
+ '.html': 'text/html; charset=utf-8',
39
+ '.wasm': 'application/wasm',
40
+ '.svg': 'image/svg+xml',
41
+ '.png': 'image/png',
42
+ '.jpg': 'image/jpeg',
43
+ '.jpeg': 'image/jpeg',
44
+ '.gif': 'image/gif',
45
+ '.webp': 'image/webp',
46
+ '.avif': 'image/avif',
47
+ '.ico': 'image/x-icon',
48
+ '.woff': 'font/woff',
49
+ '.woff2': 'font/woff2',
50
+ '.ttf': 'font/ttf',
51
+ '.txt': 'text/plain; charset=utf-8',
52
+ '.md': 'text/markdown; charset=utf-8',
53
+ };
54
+
55
+ /** the content type a file is stored with, by its extension */
56
+ export function contentTypeOf(key) {
57
+ const dot = key.lastIndexOf('.');
58
+ const ext = dot > key.lastIndexOf('/') ? key.slice(dot).toLowerCase() : '';
59
+ return CONTENT_TYPES[ext] ?? 'application/octet-stream';
60
+ }
61
+
62
+ /**
63
+ * THE RELEASE PLAN: what the store's catalog already holds against what this build brings. A
64
+ * deployed version is immutable — a module (or a package built from the distribution's own
65
+ * sources) whose id is there with other bytes is a CONFLICT: the release is refused before anything
66
+ * is uploaded, and the version has to go up. A published package (react) may come out of another
67
+ * build tool with other bytes under the same version: its first deployed copy STAYS. The same bytes
68
+ * are nothing to do. New ids are uploaded, and the catalog grows by them — the versions already
69
+ * deployed stay in it, so pages that name them keep rendering.
70
+ *
71
+ * @param existing the store's catalog entities ([] for a store never deployed to)
72
+ * @param built this build's catalog entities
73
+ * @returns { catalog, fresh: Set<id>, same: id[], kept: id[], conflicts: { id, release }[] }
74
+ */
75
+ export function releasePlan(existing, built) {
76
+ const held = new Map(existing.map((entity) => [entity.id, entity]));
77
+ const fingerprint = (entity) => entity.integrity ?? stableJson(entity);
78
+ const fresh = new Set();
79
+ const same = [];
80
+ const kept = [];
81
+ const conflicts = [];
82
+ for (const entity of built) {
83
+ const there = held.get(entity.id);
84
+ if (!there) {
85
+ fresh.add(entity.id);
86
+ held.set(entity.id, entity);
87
+ } else if (fingerprint(there) === fingerprint(entity)) {
88
+ same.push(entity.id);
89
+ } else if (entity.reference?.type === 'package' && !entity.local) {
90
+ kept.push(entity.id);
91
+ } else {
92
+ conflicts.push({ id: entity.id, release: releaseOf(entity) });
93
+ }
94
+ }
95
+ return { catalog: [...held.values()], fresh, same, kept, conflicts };
96
+ }
97
+
98
+ /** a framework package's id in code — its version is the page's business (aligned at run time) */
99
+ const FRAMEWORK_DEPENDENCY_RE = /(_ns_package_t_package_n_a_mosetta_[A-Za-z0-9_.-]*?)_v_[0-9_]+/g;
100
+
101
+ /**
102
+ * A module's code with the versions of the FRAMEWORK packages it names left out: a page runs one
103
+ * copy of each framework package, the newest its blocks need (the server aligns them), so a block
104
+ * rebuilt only because a new framework came out is the same block — not a new version of it.
105
+ */
106
+ export function frameworkFreeShape(code) {
107
+ return String(code).replace(FRAMEWORK_DEPENDENCY_RE, '$1_v_*');
108
+ }
109
+
110
+ /**
111
+ * The conflicts that are only a rebuild for a new framework: the code the store holds and the code
112
+ * built differ in nothing but the framework's versions. Those modules stay as deployed — moved from
113
+ * `conflicts` to `rebuilt`; the catalog keeps naming the deployed bytes.
114
+ * @param readStored (id) → the deployed module's code, or null
115
+ * @param readBuilt (id) → this build's code, or null
116
+ */
117
+ export async function settleRebuilds(plan, readStored, readBuilt) {
118
+ const conflicts = [];
119
+ const rebuilt = [];
120
+ for (const conflict of plan.conflicts) {
121
+ const [stored, built] = await Promise.all([readStored(conflict.id), readBuilt(conflict.id)]);
122
+ if (stored !== null && built !== null && frameworkFreeShape(stored) === frameworkFreeShape(built)) rebuilt.push(conflict.id);
123
+ else conflicts.push(conflict);
124
+ }
125
+ return { ...plan, conflicts, rebuilt };
126
+ }
127
+
128
+ /** what a version bump is asked of: a distribution (`mosetta-ui@0.0.1`) or a package of its own */
129
+ function releaseOf(entity) {
130
+ const ref = entity.reference ?? {};
131
+ return ref.type === 'package' ? `package ${ref.specifier}@${ref.version}` : `${ref.namespace}@${ref.version}`;
132
+ }
133
+
134
+ /** JSON with sorted keys — two descriptors with the same fields compare equal */
135
+ function stableJson(value) {
136
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
137
+ if (value && typeof value === 'object') {
138
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
139
+ }
140
+ return JSON.stringify(value);
141
+ }
142
+
143
+ /** the refusal, said once per release that has to go up — with the command that raises it */
144
+ export function conflictMessage(conflicts, storeLabel) {
145
+ const releases = [...new Set(conflicts.map((c) => c.release))];
146
+ const commands = releases.map((release) => {
147
+ const [, kind, name] = /^(package )?(.+)@[^@]+$/.exec(release) ?? [];
148
+ return kind ? ` ${release}: release a new version of the package` : ` ${release}: mosetta version patch --package ${name}`;
149
+ });
150
+ return `deploy refused: ${storeLabel} already holds these with other bytes, and a deployed version never changes:\n`
151
+ + `${commands.join('\n')}\nRaise each version, build again and deploy.`;
152
+ }
153
+
154
+ /** the module folder a dist key belongs to (`modules/<id>/…`), or null for anything else */
155
+ export const moduleIdOf = (key) => /^modules\/([^/]+)\//.exec(key)?.[1] ?? null;
156
+
157
+ /** the file of a module's code, by its id */
158
+ export const moduleCodeKey = (id) => `modules/${id}/${id}.js`;
159
+
160
+ /**
161
+ * The code upload, in phases: every artifact first, the catalogs last. A catalog names the
162
+ * integrities of the artifacts — published before them, a page rendered in between pins bytes
163
+ * that are not there yet (and a CDN caches the old bytes under the new content address). The
164
+ * distribution's PREFABS travel with the public catalog, uncached: a page embeds a prefab live, so
165
+ * a stale copy — or one naming modules not uploaded yet — would show in every such page. The
166
+ * private aggregate goes very last: its new ETag tells the runner the new generation is complete.
167
+ */
168
+ export function uploadPhases(publicFiles, privateFiles) {
169
+ const isCatalog = (f) => f.key === CATALOG_KEY;
170
+ const isPublicCatalog = (f) => isCatalog(f) || f.key.startsWith(PREFABS_PREFIX);
171
+ return [
172
+ { label: 'public-code', store: 'public-code', files: publicFiles.filter((f) => !isPublicCatalog(f)), cacheControl: PUBLIC_CACHE_CONTROL },
173
+ { label: 'private-code', store: 'private-code', files: privateFiles.filter((f) => !isCatalog(f)) },
174
+ { label: 'public-code catalog', store: 'public-code', files: publicFiles.filter(isPublicCatalog), cacheControl: CATALOG_CACHE_CONTROL },
175
+ { label: 'private-code catalog', store: 'private-code', files: privateFiles.filter(isCatalog) },
176
+ ];
177
+ }
178
+
179
+ /** a release the rules refuse — the message says what to raise; `conflicts` name it */
180
+ export class ReleaseRefused extends Error {
181
+ constructor(message, conflicts = []) {
182
+ super(message);
183
+ this.name = 'ReleaseRefused';
184
+ this.conflicts = conflicts;
185
+ }
186
+ }
187
+
188
+ const SIDES = ['public', 'private'];
189
+
190
+ /** a store's catalog and the ETag it was read with (null: the store has none yet) */
191
+ async function readCatalog(store, label) {
192
+ const res = await store.get(CATALOG_KEY);
193
+ if (res.status === 404) {
194
+ await res.arrayBuffer().catch(() => undefined);
195
+ return { entities: [], etag: null };
196
+ }
197
+ if (!res.ok) throw new Error(`release: reading the ${label} ${CATALOG_KEY} failed (${res.status})`);
198
+ // R2 behind Cloudflare compresses the answer and WEAKENS its ETag for it (`W/"…"`) — a write
199
+ // condition is compared with the stored object's strong ETag, which is the same value without
200
+ // the mark (checked live: a weak one never matches, the bare one does)
201
+ const etag = res.headers.get('etag')?.replace(/^W\//, '') ?? null;
202
+ const value = await res.json();
203
+ // without an ETag the catalog cannot be written over exactly this copy — and a release that
204
+ // cannot say so would silently drop what another one wrote
205
+ if (!etag) throw new Error(`release: the ${label} store gives no ETag for ${CATALOG_KEY} — it cannot be released into safely`);
206
+ return { entities: Array.isArray(value) ? value : [], etag };
207
+ }
208
+
209
+ const bytesOf = (body) => (typeof body === 'string' ? new TextEncoder().encode(body) : body);
210
+
211
+ function sameBytes(a, b) {
212
+ if (a.byteLength !== b.byteLength) return false;
213
+ for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
214
+ return true;
215
+ }
216
+
217
+ /** a store's answer as text, for a failure message */
218
+ async function failure(res) {
219
+ return `${res.status} ${(await res.text().catch(() => '')).slice(0, 200)}`;
220
+ }
221
+
222
+ /**
223
+ * The plan of a release against the stores as they are now: the release plan of each side, its
224
+ * rebuilds settled, and the files to write. Conflicts are left in — `release` refuses them, a dry
225
+ * run reports them.
226
+ */
227
+ async function planAgainst(stores, build) {
228
+ const catalogs = {};
229
+ const plans = {};
230
+ for (const side of SIDES) {
231
+ catalogs[side] = await readCatalog(stores[side], `${side}-code`);
232
+ const plan = releasePlan(catalogs[side].entities, build.catalogs[side]);
233
+ const readStored = async (id) => {
234
+ const res = await stores[side].get(moduleCodeKey(id));
235
+ return res.ok ? res.text() : (await res.arrayBuffer().catch(() => undefined), null);
236
+ };
237
+ const readBuilt = async (id) => {
238
+ const bytes = await build.read(side, moduleCodeKey(id));
239
+ return bytes === null ? null : new TextDecoder().decode(bytes);
240
+ };
241
+ plans[side] = await settleRebuilds(plan, readStored, readBuilt);
242
+ }
243
+ // a module folder goes up only when its id is new to the store; a map or a server facet of a
244
+ // public module rides with it. A folder no catalog names (a server-render facet) belongs to its
245
+ // component.
246
+ const fresh = new Set([...plans.public.fresh, ...plans.private.fresh]);
247
+ const known = new Set([...plans.public.catalog, ...plans.private.catalog].map((e) => e.id));
248
+ const ownerOf = (id) => (known.has(id) ? id : id.replace('_t_ssr_', '_t_component_'));
249
+ const wanted = (key) => {
250
+ const id = moduleIdOf(key);
251
+ if (id === null) return true;
252
+ const owner = ownerOf(id);
253
+ return fresh.has(owner) || !known.has(owner);
254
+ };
255
+ const files = Object.fromEntries(SIDES.map((side) => [side, build.files[side].filter((key) => key !== CATALOG_KEY && wanted(key))]));
256
+ // the bytes a fresh module's catalog entry vouches for — its code, or a stylesheet entity's sheet
257
+ // — must be them (the integrity the build writes; another kind is not this build's to check)
258
+ const vouched = new Map();
259
+ for (const side of SIDES) {
260
+ for (const entity of plans[side].catalog) {
261
+ if (!plans[side].fresh.has(entity.id) || !String(entity.integrity ?? '').startsWith('sha256-')) continue;
262
+ const ext = entity.reference?.type === 'css' ? 'css' : 'js';
263
+ vouched.set(`${side}/modules/${entity.id}/${entity.id}.${ext}`, entity.integrity);
264
+ }
265
+ }
266
+ return { catalogs, plans, files, vouched };
267
+ }
268
+
269
+ /** what a plan asks of the build: the files to write, and the code of each conflict to settle it */
270
+ function neededOf(plans, files) {
271
+ return Object.fromEntries(SIDES.map((side) => [side, [...new Set([...files[side], ...plans[side].conflicts.map((c) => moduleCodeKey(c.id))])]]));
272
+ }
273
+
274
+ /**
275
+ * The plan of a release against the stores as they are, said without refusing: what is new, what
276
+ * conflicts (a conflict the build's code was not given for may still settle as a rebuild for a
277
+ * new framework), and which files a release will need — a platform answers a push's plan with it.
278
+ */
279
+ export async function planRelease(stores, build) {
280
+ const { plans, files } = await planAgainst(stores, build);
281
+ return {
282
+ ...summaryOf(plans),
283
+ conflicts: [...plans.public.conflicts, ...plans.private.conflicts],
284
+ need: neededOf(plans, files),
285
+ };
286
+ }
287
+
288
+ /** a distribution by its namespace, a package (js or css) by its specifier — once each */
289
+ function releasesOf(plan) {
290
+ return [...new Set([...plan.fresh].map((id) => plan.catalog.find((e) => e.id === id)?.reference)
291
+ .filter(Boolean)
292
+ .map((ref) => (ref.namespace ? `${ref.namespace}@${ref.version}` : `${ref.specifier}@${ref.version}`)))];
293
+ }
294
+
295
+ /** what a plan says, for a person and for the trace of the release — both sides of the code */
296
+ function summaryOf(plans) {
297
+ const count = (field) => plans.public[field].length + plans.private[field].length;
298
+ return {
299
+ // a distribution of server modules only (a platform's API) is new in the private catalog alone
300
+ fresh: [...new Set([...releasesOf(plans.public), ...releasesOf(plans.private)])],
301
+ same: count('same'),
302
+ rebuilt: count('rebuilt'),
303
+ kept: count('kept'),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Write a module's file where none is: an object already there with the same bytes is the same
309
+ * release finishing (a retry, a rerun); with other bytes it is another release of the same version
310
+ * — in flight, or left over by one that failed — and both are cured the same way: a new version.
311
+ */
312
+ async function putModuleFile(store, key, body, meta) {
313
+ const res = await store.put(key, body, { ...meta, ifNoneMatch: '*' });
314
+ if (res.ok) return void (await res.arrayBuffer().catch(() => undefined));
315
+ if (res.status !== 412) throw new Error(`put ${key} failed (${await failure(res)})`);
316
+ await res.arrayBuffer().catch(() => undefined);
317
+ const there = await store.get(key);
318
+ if (!there.ok) throw new Error(`put ${key}: the store refused it as present but does not give it back (${there.status})`);
319
+ if (!sameBytes(new Uint8Array(await there.arrayBuffer()), bytesOf(body))) {
320
+ throw new ReleaseRefused(`deploy refused: ${key} is already in the store with other bytes — another release of this version is in flight or left behind; raise the version, build again and deploy`);
321
+ }
322
+ }
323
+
324
+ async function putAll(store, keys, write) {
325
+ let done = 0;
326
+ const queue = [...keys];
327
+ const worker = async () => {
328
+ for (let key = queue.shift(); key !== undefined; key = queue.shift()) {
329
+ await write(key);
330
+ done++;
331
+ }
332
+ };
333
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, keys.length) }, worker));
334
+ return done;
335
+ }
336
+
337
+ /**
338
+ * Release a build into a pair of code stores.
339
+ *
340
+ * @param stores `{ public, private }` — the public-code and private-code stores
341
+ * @param build `{ catalogs: { public, private }` (this build's entities)`, files: { public, private }`
342
+ * (every key of the build, catalogs included)`, read(side, key) → Uint8Array | null }`
343
+ * @param options `label` names the stores in a refusal; `dryRun` plans and writes nothing; `log`
344
+ * @returns the plan's summary, and — when written — what went up and the catalogs' ETags before
345
+ * and after. A refusal throws `ReleaseRefused`.
346
+ */
347
+ export async function release(stores, build, { label = 'the store', dryRun = false, log = () => undefined } = {}) {
348
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
349
+ const { catalogs, plans, files, vouched } = await planAgainst(stores, build);
350
+ const summary = summaryOf(plans);
351
+ log(`release: new ${summary.fresh.join(', ') || 'nothing'}; ${summary.same} tile(s) as deployed, `
352
+ + `${summary.rebuilt} rebuilt only for a new framework (kept as deployed), ${summary.kept} published package(s) kept as first deployed`);
353
+ const conflicts = [...plans.public.conflicts, ...plans.private.conflicts];
354
+ if (conflicts.length) throw new ReleaseRefused(conflictMessage(conflicts, label), conflicts);
355
+ if (dryRun) return { dryRun: true, ...summary, conflicts: [] };
356
+
357
+ const read = async (side, key) => {
358
+ const bytes = await build.read(side, key);
359
+ if (bytes === null) throw new Error(`release: ${side} ${key} is part of the release but was not given`);
360
+ return bytes;
361
+ };
362
+ const uploaded = {};
363
+ // the artifacts: a module's files where none are, anything else (pages.json) as built
364
+ for (const side of SIDES) {
365
+ const cacheControl = side === 'public' ? PUBLIC_CACHE_CONTROL : undefined;
366
+ const artifacts = files[side].filter((key) => side === 'private' || !key.startsWith(PREFABS_PREFIX));
367
+ uploaded[side] = await putAll(stores[side], artifacts, async (key) => {
368
+ const meta = { contentType: contentTypeOf(key), ...(cacheControl ? { cacheControl } : {}) };
369
+ const body = await read(side, key);
370
+ const integrity = vouched.get(`${side}/${key}`);
371
+ if (integrity && integrity !== await sriOf(body)) {
372
+ throw new ReleaseRefused(`deploy refused: ${key} is not the bytes its tile descriptor vouches for — build again`);
373
+ }
374
+ if (moduleIdOf(key) !== null) return putModuleFile(stores[side], key, body, meta);
375
+ const res = await stores[side].put(key, body, meta);
376
+ if (!res.ok) throw new Error(`${side}-code: put ${key} failed (${await failure(res)})`);
377
+ await res.arrayBuffer().catch(() => undefined);
378
+ });
379
+ log(`${side}-code: ${uploaded[side]} object(s) uploaded`);
380
+ }
381
+ // the distribution's prefabs, live, right before the public catalog
382
+ const prefabs = files.public.filter((key) => key.startsWith(PREFABS_PREFIX));
383
+ await putAll(stores.public, prefabs, async (key) => {
384
+ const res = await stores.public.put(key, await read('public', key), { contentType: contentTypeOf(key), cacheControl: CATALOG_CACHE_CONTROL });
385
+ if (!res.ok) throw new Error(`public-code: put ${key} failed (${await failure(res)})`);
386
+ await res.arrayBuffer().catch(() => undefined);
387
+ });
388
+ // the catalogs, each over exactly the copy the plan was made from: another release that wrote
389
+ // one in between makes this one start over — and its additions are in the next plan
390
+ const after = {};
391
+ let overtaken = false;
392
+ for (const side of SIDES) {
393
+ const condition = catalogs[side].etag ? { ifMatch: catalogs[side].etag } : { ifNoneMatch: '*' };
394
+ const body = JSON.stringify(plans[side].catalog, null, 2);
395
+ const res = await stores[side].put(CATALOG_KEY, body, {
396
+ contentType: contentTypeOf(CATALOG_KEY),
397
+ ...(side === 'public' ? { cacheControl: CATALOG_CACHE_CONTROL } : {}),
398
+ ...condition,
399
+ });
400
+ if (res.status === 412) {
401
+ await res.arrayBuffer().catch(() => undefined);
402
+ overtaken = true;
403
+ break;
404
+ }
405
+ if (!res.ok) throw new Error(`${side}-code: put ${CATALOG_KEY} failed (${await failure(res)})`);
406
+ after[side] = res.headers.get('etag');
407
+ await res.arrayBuffer().catch(() => undefined);
408
+ }
409
+ if (overtaken) {
410
+ if (attempt < ATTEMPTS) log(`release: another release wrote a tile host descriptor meanwhile — planning again (attempt ${attempt + 1} of ${ATTEMPTS})`);
411
+ continue;
412
+ }
413
+ log(`release: tile host descriptors written (${prefabs.length} prefab(s))`);
414
+ return {
415
+ ...summary,
416
+ uploaded: { ...uploaded, prefabs: prefabs.length },
417
+ catalogs: Object.fromEntries(SIDES.map((side) => [side, { before: catalogs[side].etag, after: after[side] ?? null }])),
418
+ attempts: attempt,
419
+ };
420
+ }
421
+ throw new Error(`release: other releases kept writing the tile host descriptors — ${ATTEMPTS} attempts were not enough; deploy again`);
422
+ }
423
+
424
+ // THE PUSH — a release sent to a platform rather than written by the publisher: a manifest of the
425
+ // build (both catalogs, every file with its size and SHA-256, where it comes from), then the files
426
+ // the platform's plan asked for. The platform checks every byte against the manifest and runs the
427
+ // same release rules on its side.
428
+
429
+ /** the manifest's entry in a push */
430
+ export const RELEASE_MANIFEST = 'release.json';
431
+
432
+ const hexOf = (digest) => [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
433
+
434
+ export async function sha256Hex(bytes) {
435
+ return hexOf(await crypto.subtle.digest('SHA-256', bytes));
436
+ }
437
+
438
+ /** the subresource integrity of bytes, as a catalog names it (`sha256-<base64>`) */
439
+ export async function sriOf(bytes) {
440
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytesOf(bytes)));
441
+ return `sha256-${btoa(String.fromCharCode(...digest))}`;
442
+ }
443
+
444
+ /**
445
+ * Why a build is not a TILE PALETTE of `brand` as a platform keeps one, said as a refusal: a
446
+ * palette holds tiles of its own brand and package tiles only, each vouching for its bytes with its
447
+ * integrity (a palette descriptor names no artifact and needs none) — the factory takes a tile of a
448
+ * host other than its primary only with it — and no fragments: a project's fragments are its
449
+ * content.
450
+ */
451
+ export function checkPaletteBuild(catalogs, brand) {
452
+ const issues = [];
453
+ for (const side of SIDES) {
454
+ for (const entity of catalogs?.[side] ?? []) {
455
+ const id = String(entity?.id ?? '');
456
+ const of = /^_ns_([a-z0-9-]+)_t_/.exec(id)?.[1] ?? null;
457
+ const type = entity?.reference?.type;
458
+ if (of !== brand && of !== 'package') issues.push(`${id} is not of the brand ${brand}`);
459
+ else if (type === 'prefab') issues.push(`${id} is a fragment — a project's fragments are its content`);
460
+ else if (type !== 'distribution' && !String(entity.integrity ?? '').startsWith('sha256-')) issues.push(`${id} names no integrity`);
461
+ }
462
+ }
463
+ if (issues.length) {
464
+ const more = issues.length > 5 ? `; and ${issues.length - 5} more` : '';
465
+ throw new ReleaseRefused(`push refused: not a tile palette of ${brand} — ${issues.slice(0, 5).join('; ')}${more}`);
466
+ }
467
+ }
468
+
469
+ /** a build as a push describes it — `provenance` is what the publisher says of where it comes from */
470
+ export async function manifestOf(build, provenance = {}) {
471
+ const files = {};
472
+ for (const side of SIDES) {
473
+ files[side] = [];
474
+ for (const key of build.files[side]) {
475
+ const bytes = await build.read(side, key);
476
+ if (bytes === null) throw new Error(`push: ${side} ${key} is listed but cannot be read`);
477
+ files[side].push({ key, size: bytes.byteLength, sha256: await sha256Hex(bytes) });
478
+ }
479
+ }
480
+ return { catalogs: build.catalogs, files, provenance };
481
+ }
482
+
483
+ const isFileList = (list) => Array.isArray(list) && list.every((f) => f && typeof f.key === 'string' && Number.isInteger(f.size) && typeof f.sha256 === 'string');
484
+
485
+ /** a manifest as a platform accepts it, or a refusal saying what is wrong with it */
486
+ export function readManifest(value) {
487
+ const ok = value && typeof value === 'object'
488
+ && SIDES.every((side) => Array.isArray(value.catalogs?.[side]) && isFileList(value.files?.[side]));
489
+ if (!ok) throw new ReleaseRefused('push refused: the manifest is not a build\'s (tile host descriptors and files of both sides)');
490
+ return { catalogs: value.catalogs, files: value.files, provenance: value.provenance && typeof value.provenance === 'object' ? value.provenance : {} };
491
+ }
492
+
493
+ /** the build a manifest names, without its bytes — what a plan is made from */
494
+ export function describedBuild(manifest) {
495
+ return {
496
+ catalogs: manifest.catalogs,
497
+ files: Object.fromEntries(SIDES.map((side) => [side, manifest.files[side].map((f) => f.key)])),
498
+ read: async () => null,
499
+ };
500
+ }
501
+
502
+ /**
503
+ * The build a manifest names, with the bytes a push brought (`<side>/<key>` → bytes): each one the
504
+ * file the manifest names — its size and its SHA-256 — or the push is refused.
505
+ */
506
+ export async function pushedBuild(manifest, provided) {
507
+ const named = new Map(SIDES.flatMap((side) => manifest.files[side].map((f) => [`${side}/${f.key}`, f])));
508
+ for (const [path, bytes] of provided) {
509
+ const file = named.get(path);
510
+ if (!file) throw new ReleaseRefused(`push refused: ${path} is not in the manifest`);
511
+ if (bytes.byteLength !== file.size || await sha256Hex(bytes) !== file.sha256) {
512
+ throw new ReleaseRefused(`push refused: ${path} is not the file the manifest names`);
513
+ }
514
+ }
515
+ return { ...describedBuild(manifest), read: async (side, key) => provided.get(`${side}/${key}`) ?? null };
516
+ }