@daugasauron/dolly 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +134 -14
  2. package/coi-serviceworker.js +28 -0
  3. package/dist/dolly-build-id.mjs +1 -1
  4. package/dist/dolly-dso-0.wasm +0 -0
  5. package/dist/dolly-image-build-id.mjs +1 -1
  6. package/dist/dolly-process-0.wasm +0 -0
  7. package/dist/dolly-process-abi.mjs +1 -1
  8. package/dist/dolly-process-dso.mjs +382 -53
  9. package/dist/dolly-process-gate-0.wasm +0 -0
  10. package/dist/dolly-process-worker.mjs +164 -35
  11. package/dist/dolly.wasm +0 -0
  12. package/host/dso/abi.mjs +3 -1
  13. package/host/dso/dso.mjs +1 -2
  14. package/host/http/abi.mjs +2 -2
  15. package/host/http/broker.mjs +18 -8
  16. package/host/http/http.mjs +0 -1
  17. package/host/http/policy.mjs +8 -4
  18. package/host/input/abi.mjs +47 -0
  19. package/host/modules.mjs +4 -3
  20. package/host/nena/abi.mjs +1 -1
  21. package/host/nena/nena.mjs +16 -18
  22. package/host/packages/service.mjs +35 -15
  23. package/host/packages/worker.mjs +21 -0
  24. package/host/runtime/abi.mjs +1 -0
  25. package/host/runtime/boot-files.mjs +8 -2
  26. package/host/runtime/grower.mjs +11 -8
  27. package/host/runtime/lease-bridge.mjs +8 -5
  28. package/host/runtime/module.json +1 -1
  29. package/host/runtime/runtime.mjs +37 -18
  30. package/host/snapshot/abi.mjs +7 -9
  31. package/host/snapshot/snapshot.mjs +14 -12
  32. package/host/snapshot/transport.mjs +4 -12
  33. package/host/snapshot/ui.mjs +17 -7
  34. package/host/stack/stack.mjs +1 -2
  35. package/host/threads/abi.mjs +1 -1
  36. package/host/threads/threads.mjs +2 -1
  37. package/package.json +1 -1
  38. package/src/browser.mjs +34 -37
  39. package/src/build-log.mjs +13 -2
  40. package/src/build-worker.mjs +182 -0
  41. package/src/custom-image.mjs +59 -0
  42. package/src/dollyfile-graph.mjs +127 -0
  43. package/src/dollyfile-view.mjs +335 -0
  44. package/src/image-artifact.mjs +248 -0
  45. package/src/image-build.mjs +89 -0
  46. package/src/image-builder.mjs +63 -0
  47. package/src/images.mjs +13 -2
  48. package/src/pack-store.mjs +141 -0
  49. package/src/packaged-snapshot.mjs +263 -91
  50. package/src/page-chords.mjs +4 -2
  51. package/src/process-constants.mjs +4 -2
  52. package/src/process-supervisor.mjs +209 -122
  53. package/src/runtime-worker.mjs +9 -10
  54. package/src/session-store.mjs +5 -7
  55. package/src/sha256.mjs +65 -0
  56. package/src/snapshot-records.mjs +135 -23
  57. package/src/static-asset.mjs +29 -2
  58. package/src/terminal-text.mjs +8 -5
  59. package/src/version.mjs +1 -1
  60. package/src/worker-image.mjs +3 -5
@@ -0,0 +1,63 @@
1
+ import { buildHost, hostRegistry } from "../host/modules.mjs";
2
+ import { hostRequirement } from "../host/requirements.mjs";
3
+ import { imageBase, imageDefinition } from "./images.mjs";
4
+ import { inspectDollyfile } from "./dollyfile-view.mjs";
5
+
6
+ // Disposable Wasm userspace on the runtime the image declares, with the build
7
+ // host's modules, configured as the caller's host says (host.builder): the
8
+ // caller's browser policy and no display, file picker, local service or ENTRY.
9
+ // MODULES are the URLs of the module.json of each host module the page has.
10
+ // Used for dependencies and Studio builds. ARTIFACTS are the build's inputs,
11
+ // each one's bytes a Blob the Worker reads; it resolves to the built artifact,
12
+ // verified and in the image cache.
13
+ export async function buildImage(image, artifacts, configuration, report, { modules, customSource, signal } = {}) {
14
+ signal?.throwIfAborted();
15
+ const declared = image === "custom" ? inspectDollyfile(customSource).hostRequirements
16
+ : imageDefinition(image).hostRequirements;
17
+ let worker;
18
+ // The build registers the build host and every module the image and its
19
+ // inputs declare: it enables only the runtime and the build host, but a step
20
+ // may run a program linked against a declared module's client (transfer's
21
+ // download --help), which the Worker admits by that module's ABI.
22
+ const needed = new Set([...buildHost, ...declared, ...artifacts.flatMap(artifact => artifact.hostRequirements)]
23
+ .map(value => hostRequirement(value).name));
24
+ const manifests = await Promise.all(modules.map(async url => ({ url, ...(await import(url, { with: { type: "json" } })).default })));
25
+ const registry = await hostRegistry(manifests.filter(({ name }) => needed.has(name)).map(({ url }) => url));
26
+ const host = await registry.createHost("browser", registry.buildHostFor(declared),
27
+ { send: message => worker.postMessage(message), configuration });
28
+ let abort, result;
29
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
30
+ try {
31
+ signal?.throwIfAborted();
32
+ worker = new Worker(new URL("./build-worker.mjs", import.meta.url), {
33
+ type: "module", name: `dolly-build-${image}`,
34
+ });
35
+ result = await new Promise((resolve, reject) => {
36
+ abort = () => reject(signal.reason);
37
+ signal?.addEventListener("abort", abort, { once: true });
38
+ worker.addEventListener("error", event => reject(new Error(event.message || "Image build worker failed")), { once: true });
39
+ worker.addEventListener("message", ({ data: message }) => {
40
+ try {
41
+ void host.handle(message).catch(reject);
42
+ if (message.type === "bootstrap") report(message.text);
43
+ else if (message.type === "bootstrap-bytes") report(decoder.decode(message.bytes, { stream: true }));
44
+ else if (message.type === "image-built") resolve(message.artifact);
45
+ else if (message.type === "error") reject(new Error(message.message));
46
+ } catch (error) { reject(error); }
47
+ });
48
+ // An input's Blob is cloned by reference: every Worker reads the one copy.
49
+ worker.postMessage({ type: "configure", mode: "rebuild", image, buildOnly: true,
50
+ artifacts: artifacts.map(({ buildId, recipeSha256, sha256, bytes }) => ({ buildId, recipeSha256, sha256, bytes })),
51
+ images: imageBase.href, modules: registry.urls, hostModules: host.enabled, hostConfiguration: host.configuration,
52
+ ...(customSource === undefined ? {} : { customSource }) });
53
+ });
54
+ } finally {
55
+ signal?.removeEventListener("abort", abort);
56
+ host.dispose();
57
+ worker?.terminate();
58
+ const tail = decoder.decode();
59
+ if (tail && !signal?.aborted) report(tail);
60
+ }
61
+ signal?.throwIfAborted();
62
+ return result;
63
+ }
package/src/images.mjs CHANGED
@@ -1,10 +1,21 @@
1
1
  // The images a page runs and where their files are. An image directory holds
2
- // dist/dolly-images.mjs (its catalog), each image's metadata and packs under
2
+ // dist/dolly-images.json (its catalog), each image's metadata and packs under
3
3
  // dist/, and the recipes and sources at their site paths. The site is one such
4
4
  // directory; an embedding names its own (boot's `images`). The page and the
5
5
  // Worker each load it once, before anything reads it.
6
6
  export let DOLLY_IMAGES = [], DOLLY_STATIC_SOURCES = [], imageBase;
7
7
  export async function loadImages(base) {
8
8
  imageBase = new URL(base);
9
- ({ DOLLY_IMAGES, DOLLY_STATIC_SOURCES } = await import(new URL("dist/dolly-images.mjs", imageBase).href));
9
+ ({ images: DOLLY_IMAGES, staticSources: DOLLY_STATIC_SOURCES } =
10
+ (await import(new URL("dist/dolly-images.json", imageBase).href, { with: { type: "json" } })).default);
10
11
  }
12
+
13
+ // The catalog's record of an image, by name; undefined for one it lacks.
14
+ export const imageDefinition = name => DOLLY_IMAGES.find(definition => definition.image === name);
15
+
16
+ // The files of the directory a build reads by site path, with their sizes:
17
+ // every recipe and every source (http@0's trusted sources).
18
+ export const bootstrapSources = () => [
19
+ ...DOLLY_IMAGES.map(definition => ({ path: `/${definition.dollyfile}`, byteLength: definition.byteLength })),
20
+ ...DOLLY_STATIC_SOURCES,
21
+ ];
@@ -0,0 +1,141 @@
1
+ // The packs this browser has read, kept in origin-private storage so that an
2
+ // image starts again, and a session saved on it opens, without downloading
3
+ // them. A pack is a file named by its SHA-256, written as its bytes stream to
4
+ // the reader and kept only once the reader has checked them against that
5
+ // digest; a kept pack is checked the same way at every use, and removed when
6
+ // it fails. Every image read leaves a record of its packs; once no image is
7
+ // being read on the origin, what the records of this runtime's images do not
8
+ // name is removed. Only the page and its Workers write here, from an image's
9
+ // trusted metadata; nothing of the guest reaches it.
10
+ import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
11
+ import { imageBase, imageDefinition } from "./images.mjs";
12
+ import { sha256 } from "./static-asset.mjs";
13
+
14
+ const STORE = "dolly-packs", LOCK = "dolly-packs";
15
+ const encoder = new TextEncoder();
16
+
17
+ async function storeDirectories() {
18
+ const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(STORE, { create: true });
19
+ const [packs, records] = await Promise.all(["packs", "images"].map(name => root.getDirectoryHandle(name, { create: true })));
20
+ return { packs, records };
21
+ }
22
+
23
+ // Holds the store's lock in MODE until the function it resolves to is
24
+ // called, which resolves once the lock is released.
25
+ const hold = mode => new Promise((held, refused) => {
26
+ const released = navigator.locks.request(LOCK, { mode }, () => new Promise(release => held(() => {
27
+ release();
28
+ return released;
29
+ })));
30
+ released.catch(refused);
31
+ });
32
+
33
+ async function writeFile(directory, name, data) {
34
+ const writable = await (await directory.getFileHandle(name, { create: true })).createWritable();
35
+ await writable.write(data);
36
+ await writable.close();
37
+ }
38
+
39
+ // Where the browser refuses origin-private storage or Web Locks (and in
40
+ // Node), every pack is downloaded.
41
+ const noStore = {
42
+ kept: async () => null,
43
+ keeping: async (pack, body) => ({ body, keep() {}, discard() {} }),
44
+ close() {},
45
+ };
46
+
47
+ // The store as the reader of IMAGE's PACKS uses it: kept(pack) is a pack's
48
+ // part read from here, or null; keeping(pack, body) the part of a download,
49
+ // written here as it is read. A part's keep() is called once its bytes match
50
+ // the pack's digest, discard() otherwise. close(complete) ends the reading;
51
+ // while it lasts, nothing is removed.
52
+ export async function openPackStore(image, packs) {
53
+ let store, release;
54
+ try {
55
+ store = await storeDirectories();
56
+ release = await hold("shared");
57
+ const record = { directory: imageBase.href, image, buildId: DOLLY_IMAGE_BUILD_ID, packs: packs.map(pack => pack.sha256) };
58
+ await writeFile(store.records, await sha256(encoder.encode(`${record.directory}\0${image}`)), JSON.stringify(record));
59
+ } catch {
60
+ release?.();
61
+ return noStore;
62
+ }
63
+ return {
64
+ async kept(pack) {
65
+ try {
66
+ const file = await (await store.packs.getFileHandle(pack.sha256)).getFile();
67
+ if (file.size !== pack.encodedByteLength) return null;
68
+ return { body: file.stream(), keep() {}, discard: () => store.packs.removeEntry(pack.sha256).catch(() => {}) };
69
+ } catch { return null; }
70
+ },
71
+ async keeping(pack, body) {
72
+ // The file holds what was written only once it is closed. A write that
73
+ // fails, such as one over the quota, leaves the pack unkept.
74
+ let writable = await store.packs.getFileHandle(pack.sha256, { create: true })
75
+ .then(handle => handle.createWritable()).catch(() => null);
76
+ const discard = async () => {
77
+ const unkept = writable;
78
+ writable = null;
79
+ await unkept?.abort().catch(() => {});
80
+ };
81
+ return {
82
+ body: body.pipeThrough(new TransformStream({
83
+ async transform(chunk, controller) {
84
+ await writable?.write(chunk).catch(discard);
85
+ controller.enqueue(chunk);
86
+ },
87
+ })),
88
+ keep: () => writable?.close().catch(discard),
89
+ discard,
90
+ };
91
+ },
92
+ close(complete) {
93
+ void release().then(() => complete &&
94
+ navigator.locks.request(LOCK, { ifAvailable: true }, lock => lock && prune(store))).catch(() => {});
95
+ },
96
+ };
97
+ }
98
+
99
+ // Removes the records of images no page of this runtime starts (another
100
+ // runtime's, and those this directory's catalog no longer has), then every
101
+ // pack the remaining records do not name. An image rebuilt since it was last
102
+ // read keeps its former packs until it is read again.
103
+ async function prune({ packs, records }) {
104
+ const used = new Set(), stale = [], unused = [];
105
+ for await (const [name, handle] of records) {
106
+ try {
107
+ const record = JSON.parse(await (await handle.getFile()).text());
108
+ if (record.buildId === DOLLY_IMAGE_BUILD_ID && (record.directory !== imageBase.href || imageDefinition(record.image))) {
109
+ for (const pack of record.packs) used.add(pack);
110
+ continue;
111
+ }
112
+ } catch {}
113
+ stale.push(name);
114
+ }
115
+ for (const name of stale) await records.removeEntry(name).catch(() => {});
116
+ for await (const name of packs.keys()) if (!used.has(name)) unused.push(name);
117
+ for (const name of unused) await packs.removeEntry(name).catch(() => {});
118
+ }
119
+
120
+ // What the store holds: its packs and their bytes.
121
+ export async function packStoreSize() {
122
+ let count = 0, bytes = 0;
123
+ try {
124
+ const store = await (await navigator.storage.getDirectory()).getDirectoryHandle(STORE);
125
+ for await (const handle of (await store.getDirectoryHandle("packs")).values()) {
126
+ count += 1;
127
+ bytes += (await handle.getFile()).size;
128
+ }
129
+ } catch (error) {
130
+ if (error.name !== "NotFoundError") throw error;
131
+ }
132
+ return { count, bytes };
133
+ }
134
+
135
+ // Removes the store once no image is being read on the origin.
136
+ export async function clearPackStore() {
137
+ await navigator.locks.request(LOCK, async () =>
138
+ (await navigator.storage.getDirectory()).removeEntry(STORE, { recursive: true }).catch(error => {
139
+ if (error.name !== "NotFoundError") throw error;
140
+ }));
141
+ }
@@ -1,13 +1,16 @@
1
1
  // The images of an image directory as the page and the Worker load them: each
2
2
  // one's metadata, checked against the catalog and the recipes the directory
3
- // serves, and its packs, streamed or whole.
3
+ // serves, and its packs: streamed into a booting kernel, or merged in path
4
+ // order into the image's canonical snapshot.
4
5
  import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
5
6
  import { DOLLY_IMAGES, imageBase } from "./images.mjs";
6
7
  import { imageInputs, imageInputsMatch } from "./image-inputs.mjs";
7
8
  import { hostRequirements } from "../host/requirements.mjs";
8
9
  import { siteReference, sitePath, decodeStaticAsset, sha256 } from "./static-asset.mjs";
9
- import { mergeSnapshotRecords, validateSnapshotPacks, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "./snapshot-records.mjs";
10
+ import { scanSnapshotRecords, validateSnapshotPacks, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "./snapshot-records.mjs";
10
11
  import { unretainedPath } from "./image-names.mjs";
12
+ import { openPackStore } from "./pack-store.mjs";
13
+ import { Sha256 } from "./sha256.mjs";
11
14
  const definitionOf = image => DOLLY_IMAGES.find(definition => definition.image === image);
12
15
  const encoder = new TextEncoder();
13
16
  const expectedRecipes = image => definitionOf(image).recipes;
@@ -20,50 +23,74 @@ function validSnapshotPath(path) {
20
23
  !unretainedPath(path);
21
24
  }
22
25
 
23
- async function verifyVisibleRecipes(recipes) {
24
- for (const recipe of recipes) {
26
+ // The recipes the directory serves for IMAGE (its page shows them) are the ones
27
+ // that built the image a boot runs. They are fetched together.
28
+ export async function verifyVisibleRecipes(image) {
29
+ await Promise.all(expectedRecipes(image).map(async recipe => {
25
30
  const response = await fetch(new URL(sitePath(recipe.sourcePath).slice(1), imageBase), {
26
- cache: "no-store", credentials: "same-origin", redirect: "error",
31
+ credentials: "same-origin", redirect: "error",
27
32
  });
28
33
  if (!response.ok) throw new Error(`${recipe.sourcePath} returned HTTP ${response.status}`);
29
34
  const bytes = await response.arrayBuffer();
30
35
  if (bytes.byteLength !== recipe.byteLength || await sha256(bytes) !== recipe.sha256) {
31
36
  throw new Error(`${recipe.sourcePath} does not match the packaged snapshot recipe`);
32
37
  }
38
+ }));
39
+ }
40
+
41
+ // The image a FROM, COPY or INSTALL row names: its recipe at that path and pin.
42
+ const referenced = ({ location, sha256 }) => DOLLY_IMAGES.find(candidate =>
43
+ siteReference(candidate.dollyfile) === location && candidate.sha256 === sha256);
44
+
45
+ // The images IMAGE is built from, each once and before those built from it,
46
+ // IMAGE last; a cycle is refused.
47
+ function inputClosure(image, order = [], path = []) {
48
+ if (path.includes(image)) throw new Error("packaged image cycle");
49
+ if (order.includes(image)) return order;
50
+ for (const reference of definitionOf(image).artifacts) {
51
+ const dependency = referenced(reference);
52
+ if (!dependency) throw new Error("packaged image input is missing");
53
+ inputClosure(dependency.image, order, [...path, image]);
33
54
  }
55
+ order.push(image);
56
+ return order;
34
57
  }
35
58
 
36
- export async function loadPackagedSnapshotMetadata(image, checked = new Map(), active = new Set()) {
37
- if (checked.has(image)) return checked.get(image);
38
- if (active.has(image)) throw new Error("packaged image cycle");
39
- active.add(image);
40
- const metadataUrl = new URL(
41
- `dist/dolly-${image}-system-snapshot.mjs`, imageBase,
42
- );
43
- let metadata;
59
+ async function importMetadata(image) {
44
60
  try {
45
- ({ DOLLY_SYSTEM_SNAPSHOT: metadata } = await import(metadataUrl.href));
61
+ return (await import(new URL(`dist/dolly-${image}-system-snapshot.json`, imageBase).href, { with: { type: "json" } })).default;
46
62
  } catch {
47
63
  throw new Error(`The packaged ${image} snapshot is missing. Run npm run image -- ${image} first.`);
48
64
  }
65
+ }
66
+
67
+ // An image's metadata, checked against the catalog and the metadata of every
68
+ // image it is built from, which are fetched together.
69
+ export async function loadPackagedSnapshotMetadata(image) {
70
+ const closure = inputClosure(image);
71
+ const loaded = await Promise.all(closure.map(importMetadata));
72
+ const checked = new Map();
73
+ closure.forEach((name, index) => checked.set(name, checkedMetadata(name, loaded[index], checked)));
74
+ return checked.get(image);
75
+ }
76
+
77
+ // METADATA, checked as IMAGE's against the catalog and CHECKED, the metadata
78
+ // of the images it is built from.
79
+ function checkedMetadata(image, metadata, checked) {
49
80
  const recipes = expectedRecipes(image);
50
81
  if (metadata === null || typeof metadata !== "object" ||
51
82
  metadata.image !== image || metadata.buildId !== DOLLY_IMAGE_BUILD_ID ||
52
83
  metadata.formatVersion !== 2 || metadata.identityVersion !== 2 ||
53
84
  JSON.stringify(metadata.recipes) !== JSON.stringify(recipes) ||
54
- JSON.stringify(hostRequirements(metadata.hostRequirements)) !== JSON.stringify(definitionOf(image).hostRequirements ?? []) ||
85
+ JSON.stringify(hostRequirements(metadata.hostRequirements)) !== JSON.stringify(definitionOf(image).hostRequirements) ||
55
86
  !Number.isSafeInteger(metadata.byteLength) || metadata.byteLength <= 0 ||
56
- metadata.byteLength > snapshotSizeLimit ||
57
- (![undefined, "gzip", "packs"].includes(metadata.encoding)) ||
58
- (metadata.encoding === "gzip" &&
59
- (!Number.isSafeInteger(metadata.encodedByteLength) ||
60
- metadata.encodedByteLength <= 0 || metadata.encodedByteLength > snapshotSizeLimit)) ||
87
+ metadata.byteLength > snapshotSizeLimit || metadata.encoding !== "packs" ||
61
88
  !/^[0-9a-f]{64}$/.test(metadata.sha256) ||
62
89
  !Array.isArray(metadata.manifest) || metadata.manifest.length === 0 ||
63
90
  metadata.manifest.length > 100_000) {
64
91
  throw new Error("The packaged system snapshot metadata does not match this Dolly build");
65
92
  }
66
- if (metadata.encoding === "packs") validateSnapshotPacks(metadata);
93
+ validateSnapshotPacks(metadata);
67
94
  const inputs = imageInputs(metadata.inputs);
68
95
  const references = [...new Set(definitionOf(image).artifacts.map(reference => reference.sha256))].sort();
69
96
  if (JSON.stringify(inputs.map(input => input.recipeSha256)) !== JSON.stringify(references)) {
@@ -90,86 +117,231 @@ export async function loadPackagedSnapshotMetadata(image, checked = new Map(), a
90
117
  throw new Error(`The packaged system snapshot is missing ${required}`);
91
118
  }
92
119
  }
93
- const expectedInputs = [];
94
- for (const reference of definitionOf(image).artifacts) {
95
- const dependency = DOLLY_IMAGES.find(candidate =>
96
- siteReference(candidate.dollyfile) === reference.location && candidate.sha256 === reference.sha256);
97
- if (!dependency) throw new Error("packaged image input is missing");
98
- const parent = await loadPackagedSnapshotMetadata(dependency.image, checked, active);
99
- expectedInputs.push({ recipeSha256: reference.sha256, sha256: parent.sha256 });
100
- }
120
+ const expectedInputs = definitionOf(image).artifacts.map(reference =>
121
+ ({ recipeSha256: reference.sha256, sha256: checked.get(referenced(reference).image).sha256 }));
101
122
  if (!imageInputsMatch(inputs, expectedInputs)) throw new Error("The packaged system snapshot has stale image inputs");
102
- if (active.size === 1) await verifyVisibleRecipes(recipes);
103
- active.delete(image);
104
- checked.set(image, metadata);
105
123
  return metadata;
106
124
  }
107
125
 
108
- async function* packagedSnapshotParts(image, metadata, signal) {
109
- const packed = metadata.encoding === "packs";
110
- const parts = packed ? validateSnapshotPacks(metadata) : [metadata];
111
- for (const part of parts) {
112
- signal?.throwIfAborted();
113
- const compressed = packed || metadata.encoding === "gzip";
114
- const url = packed
115
- ? new URL(`dist/packs/${part.sha256}.snapshot.gz`, imageBase)
116
- : new URL(`dist/dolly-${image}-system.snapshot${compressed ? ".gz" : ""}`, imageBase);
117
- const init = { cache: packed ? "force-cache" : "no-store", credentials: "same-origin", redirect: "error", signal };
118
- const expected = compressed ? part.encodedByteLength : part.byteLength;
119
- const response = await decodeStaticAsset(await fetch(url, init), url, init, expected);
120
- if (!response.ok || !response.body) throw new Error(`snapshot returned HTTP ${response.status}`);
121
- const declared = response.headers.get("content-length");
122
- if (declared !== null && Number(declared) !== expected) throw new Error("snapshot HTTP content length mismatch");
123
- const body = compressed ? response.body.pipeThrough(new DecompressionStream("gzip")) : response.body;
124
- yield { ...part, body };
126
+ // An image is the records of its packs, each a gzip-compressed snapshot named
127
+ // by its digest, which images that hold the same files share. Each pack's
128
+ // compressed body, kept in this browser (pack-store.mjs) or downloaded and
129
+ // checked against its declared length. The consumer checks each part against
130
+ // its digest and then calls its verified(), which keeps a downloaded pack; a
131
+ // part it does not verify is not kept, or no longer.
132
+ async function* packagedSnapshotParts(metadata, signal) {
133
+ const packs = validateSnapshotPacks(metadata), store = await openPackStore(metadata.image, packs);
134
+ let complete = false;
135
+ try {
136
+ for (const pack of packs) {
137
+ signal?.throwIfAborted();
138
+ const part = await store.kept(pack) ?? await store.keeping(pack, await downloadedPack(pack, signal));
139
+ let verified = false;
140
+ try {
141
+ yield { ...pack, body: part.body, verified: () => { verified = true; return part.keep(); } };
142
+ } finally {
143
+ if (!verified) await part.discard();
144
+ }
145
+ }
146
+ complete = true;
147
+ } finally {
148
+ store.close(complete);
149
+ }
150
+ }
151
+
152
+ async function downloadedPack(pack, signal) {
153
+ const url = new URL(`dist/packs/${pack.sha256}.snapshot.gz`, imageBase);
154
+ const init = { cache: "force-cache", credentials: "same-origin", redirect: "error", signal };
155
+ const response = await decodeStaticAsset(await fetch(url, init), url, init, pack.encodedByteLength);
156
+ if (!response.ok || !response.body) throw new Error(`snapshot returned HTTP ${response.status}`);
157
+ const declared = response.headers.get("content-length");
158
+ if (declared !== null && Number(declared) !== pack.encodedByteLength) throw new Error("snapshot HTTP content length mismatch");
159
+ return response.body;
160
+ }
161
+
162
+ const gunzip = body => body.pipeThrough(new DecompressionStream("gzip"));
163
+
164
+ // The chunks of `body`; a consumer that stops early cancels it.
165
+ async function* chunksOf(body, signal) {
166
+ const reader = body.getReader();
167
+ let finished = false;
168
+ try {
169
+ for (;;) {
170
+ signal?.throwIfAborted();
171
+ const { done, value } = await reader.read();
172
+ if (done) break;
173
+ yield value;
174
+ }
175
+ finished = true;
176
+ } finally {
177
+ if (!finished) await reader.cancel().catch(() => {});
178
+ reader.releaseLock();
125
179
  }
126
180
  }
127
181
 
182
+ async function readBytes(body, length, signal) {
183
+ const bytes = new Uint8Array(length);
184
+ let offset = 0;
185
+ for await (const chunk of chunksOf(body, signal)) {
186
+ if (chunk.length > length - offset) throw new Error("snapshot exceeds declared size");
187
+ bytes.set(chunk, offset);
188
+ offset += chunk.length;
189
+ }
190
+ if (offset !== length) throw new Error("snapshot integrity mismatch");
191
+ return bytes;
192
+ }
193
+
194
+ // The stream of `bytes` in pieces of at most 16 KiB, so that decompressing it
195
+ // holds only a little of the output at a time.
196
+ function streamOf(bytes) {
197
+ let offset = 0;
198
+ return new ReadableStream({
199
+ pull(controller) {
200
+ const end = Math.min(offset + 16 * 1024, bytes.length);
201
+ controller.enqueue(bytes.subarray(offset, end));
202
+ offset = end;
203
+ if (offset === bytes.length) controller.close();
204
+ },
205
+ }, { highWaterMark: 0 });
206
+ }
207
+
128
208
  // The sink owns incremental hashing. Every part is checked before another is
129
209
  // requested; the caller verifies the complete canonical image before booting.
130
- export async function streamPackagedSystemSnapshot(image, metadata, write, endPart, signal) {
131
- for await (const part of packagedSnapshotParts(image, metadata, signal)) {
132
- const reader = part.body.getReader();
210
+ export async function streamPackagedSystemSnapshot(metadata, write, endPart, signal) {
211
+ for await (const part of packagedSnapshotParts(metadata, signal)) {
133
212
  let size = 0;
134
- try {
135
- for (;;) {
136
- signal?.throwIfAborted();
137
- const { done, value } = await reader.read();
138
- if (done) break;
139
- if (value.length > part.byteLength - size) throw new Error("snapshot exceeds declared size");
140
- await write(value);
141
- size += value.length;
213
+ for await (const chunk of chunksOf(gunzip(part.body), signal)) {
214
+ if (chunk.length > part.byteLength - size) throw new Error("snapshot exceeds declared size");
215
+ await write(chunk);
216
+ size += chunk.length;
217
+ }
218
+ if (size !== part.byteLength || await endPart() !== part.sha256) throw new Error("snapshot integrity mismatch");
219
+ await part.verified();
220
+ }
221
+ }
222
+
223
+ function snapshotHeader(count) {
224
+ const bytes = new Uint8Array(16), view = new DataView(bytes.buffer);
225
+ bytes.set(encoder.encode("DOLLYSNP"));
226
+ view.setUint32(8, 2, true);
227
+ view.setUint32(12, count, true);
228
+ return bytes;
229
+ }
230
+
231
+ function recordHeader(kind, path, size) {
232
+ const bytes = new Uint8Array(16 + path.length), view = new DataView(bytes.buffer);
233
+ view.setUint32(0, kind, true);
234
+ view.setUint32(4, path.length, true);
235
+ view.setBigUint64(8, BigInt(size), true);
236
+ bytes.set(path, 16);
237
+ return bytes;
238
+ }
239
+
240
+ function compareBytes(left, right) {
241
+ for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
242
+ if (left[index] !== right[index]) return left[index] - right[index];
243
+ }
244
+ return left.length - right.length;
245
+ }
246
+
247
+ // One pack's bytes, decompressed again as its records are reached in path order.
248
+ class PackReader {
249
+ constructor(compressed) {
250
+ this.chunks = chunksOf(gunzip(streamOf(compressed)));
251
+ this.chunk = new Uint8Array(0);
252
+ this.position = 0; // the pack offset of this.chunk
253
+ }
254
+
255
+ // The pack's bytes [offset, offset + length), skipping those before it.
256
+ async *read(offset, length) {
257
+ const end = offset + length;
258
+ while (this.position < end) {
259
+ if (this.chunk.length === 0) {
260
+ const { done, value } = await this.chunks.next();
261
+ if (done) throw new Error("truncated snapshot pack");
262
+ this.chunk = value;
142
263
  }
143
- if (size !== part.byteLength || await endPart() !== part.sha256) throw new Error("snapshot integrity mismatch");
144
- } catch (error) {
145
- await reader.cancel().catch(() => {});
146
- throw error;
147
- } finally { reader.releaseLock(); }
148
- }
149
- }
150
-
151
- export async function loadPackagedSystemSnapshot(image, metadata, signal) {
152
- const parts = [];
153
- for await (const part of packagedSnapshotParts(image, metadata, signal)) {
154
- const reader = part.body.getReader(), bytes = new Uint8Array(part.byteLength);
155
- let offset = 0;
156
- try {
157
- for (;;) {
158
- signal?.throwIfAborted();
159
- const { done, value } = await reader.read();
160
- if (done) break;
161
- if (value.length > bytes.length - offset) throw new Error("snapshot exceeds declared size");
162
- bytes.set(value, offset);
163
- offset += value.length;
264
+ const start = Math.max(offset - this.position, 0), taken = Math.min(this.chunk.length, end - this.position);
265
+ if (taken > start) yield this.chunk.subarray(start, taken);
266
+ this.chunk = this.chunk.subarray(taken);
267
+ this.position += taken;
268
+ }
269
+ }
270
+
271
+ close() { return this.chunks.return(); }
272
+ }
273
+
274
+ // Checks every pack against its hash as it decompresses, keeping only its
275
+ // compressed bytes and where its records lie; returns the image's records in
276
+ // path order, each pack's read from its bytes decompressed once more.
277
+ async function packedRecords(metadata, signal) {
278
+ const records = [];
279
+ for await (const part of packagedSnapshotParts(metadata, signal)) {
280
+ const pack = { compressed: await readBytes(part.body, part.encodedByteLength, signal), unread: 0 };
281
+ const hash = new Sha256();
282
+ const scanned = await scanSnapshotRecords((async function* () {
283
+ for await (const chunk of chunksOf(gunzip(streamOf(pack.compressed)), signal)) {
284
+ hash.update(chunk);
285
+ yield chunk;
286
+ }
287
+ })(), () => false);
288
+ if (scanned.byteLength !== part.byteLength || hash.hex() !== part.sha256) throw new Error("snapshot integrity mismatch");
289
+ await part.verified();
290
+ for (const [path, { kind, offset, size }] of scanned.records) {
291
+ records.push({ pack, kind, path: encoder.encode(path), offset, size });
292
+ pack.unread += 1;
293
+ }
294
+ }
295
+ records.sort((left, right) => compareBytes(left.path, right.path));
296
+ return (async function* () {
297
+ yield snapshotHeader(records.length);
298
+ for (const { pack, kind, path, offset, size } of records) {
299
+ yield recordHeader(kind, path, size);
300
+ pack.reader ??= new PackReader(pack.compressed);
301
+ yield* pack.reader.read(offset, size);
302
+ if (--pack.unread === 0) {
303
+ await pack.reader.close();
304
+ pack.reader = pack.compressed = null;
305
+ }
306
+ }
307
+ })();
308
+ }
309
+
310
+ // Joins pieces into chunks of 1 MiB, the last one shorter.
311
+ async function* joined(pieces) {
312
+ const size = 1024 * 1024;
313
+ let chunk = new Uint8Array(size), used = 0;
314
+ for await (let piece of pieces) {
315
+ while (piece.length !== 0) {
316
+ const taken = Math.min(piece.length, size - used);
317
+ chunk.set(piece.subarray(0, taken), used);
318
+ used += taken;
319
+ piece = piece.subarray(taken);
320
+ if (used === size) {
321
+ yield chunk;
322
+ chunk = new Uint8Array(size);
323
+ used = 0;
164
324
  }
165
- if (offset !== bytes.length || await sha256(bytes) !== part.sha256) throw new Error("snapshot integrity mismatch");
166
- parts.push(bytes);
167
- } catch (error) {
168
- await reader.cancel().catch(() => {});
169
- throw error;
170
- } finally { reader.releaseLock(); }
171
- }
172
- const bytes = parts.length === 1 ? parts[0] : mergeSnapshotRecords(parts);
173
- if (bytes.length !== metadata.byteLength || await sha256(bytes) !== metadata.sha256) throw new Error("packed snapshot integrity mismatch");
174
- return bytes.buffer;
325
+ }
326
+ }
327
+ if (used !== 0) yield chunk.subarray(0, used);
328
+ }
329
+
330
+ // The image's canonical snapshot in chunks, merged from its packs as they are
331
+ // read: the page holds the packs' compressed bytes, not the image. Every pack
332
+ // matches its hash before the first chunk; the chunks end in an error, not
333
+ // their end, unless together they match the image's hash.
334
+ export async function packagedSystemSnapshot(metadata, signal) {
335
+ const pieces = await packedRecords(metadata, signal);
336
+ return (async function* () {
337
+ const hash = new Sha256();
338
+ let size = 0;
339
+ for await (const chunk of joined(pieces)) {
340
+ if (chunk.length > metadata.byteLength - size) throw new Error("snapshot exceeds declared size");
341
+ size += chunk.length;
342
+ hash.update(chunk);
343
+ yield chunk;
344
+ }
345
+ if (size !== metadata.byteLength || hash.hex() !== metadata.sha256) throw new Error("packed snapshot integrity mismatch");
346
+ })();
175
347
  }