@daugasauron/dolly 0.1.5-rc.0 → 0.1.6

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 (76) hide show
  1. package/README.md +51 -78
  2. package/coi-serviceworker.js +23 -8
  3. package/dist/dolly-build-id.mjs +1 -1
  4. package/dist/dolly-image-build-id.mjs +1 -1
  5. package/dist/dolly-process-0.wasm +0 -0
  6. package/dist/dolly-process-abi.mjs +1 -1
  7. package/dist/dolly-process-dso.mjs +19 -11
  8. package/dist/dolly-process-worker.mjs +88 -33
  9. package/dist/dolly-runtime-worker.mjs +1544 -0
  10. package/dist/dolly.wasm +0 -0
  11. package/host/dso/dso.mjs +2 -2
  12. package/host/dso/module.json +4 -1
  13. package/host/http/abi.mjs +3 -1
  14. package/host/http/broker.mjs +17 -7
  15. package/host/http/http.mjs +19 -5
  16. package/host/http/module.json +3 -1
  17. package/host/http/policy.mjs +13 -6
  18. package/host/http/refusals.mjs +77 -0
  19. package/host/input/abi.mjs +1 -1
  20. package/host/modules.mjs +64 -23
  21. package/host/nena/module.json +2 -1
  22. package/host/nena/nena.mjs +2 -0
  23. package/host/packages/abi.mjs +27 -0
  24. package/host/packages/module.json +6 -4
  25. package/host/packages/packages.mjs +23 -8
  26. package/host/packages/service.mjs +46 -20
  27. package/host/packages/transport.mjs +123 -0
  28. package/host/packages/worker.mjs +13 -9
  29. package/host/requirements.mjs +1 -1
  30. package/host/runtime/abi.mjs +32 -0
  31. package/host/runtime/lease-bridge.mjs +13 -8
  32. package/host/runtime/module.json +3 -3
  33. package/host/runtime/runtime.mjs +3 -3
  34. package/host/snapshot/abi.mjs +2 -1
  35. package/host/snapshot/module.json +1 -1
  36. package/host/snapshot/snapshot.mjs +80 -57
  37. package/host/snapshot/transport.mjs +10 -3
  38. package/host/snapshot/ui.mjs +64 -39
  39. package/host/stack/module.json +1 -0
  40. package/host/stack/stack.mjs +1 -1
  41. package/host/threads/module.json +6 -1
  42. package/host/threads/threads.mjs +32 -13
  43. package/package.json +2 -2
  44. package/{src → page}/browser.mjs +37 -23
  45. package/page/generated.mjs +8 -0
  46. package/{src → page}/image-entry.mjs +10 -6
  47. package/{src → page}/image-inputs.mjs +11 -0
  48. package/{src → page}/image-names.mjs +2 -2
  49. package/{src → page}/kernel-plugin.mjs +1 -1
  50. package/{src → page}/pack-store.mjs +1 -1
  51. package/{src → page}/packaged-snapshot.mjs +56 -46
  52. package/{src → page}/process-constants.mjs +72 -15
  53. package/{src → page}/process-supervisor.mjs +155 -227
  54. package/{src → page}/snapshot-records.mjs +63 -44
  55. package/{src → page}/static-asset.mjs +36 -20
  56. package/{src → page}/version.mjs +1 -1
  57. package/{src → page}/worker-image.mjs +20 -9
  58. package/{src → site}/build-worker.mjs +36 -25
  59. package/{src → site}/custom-image.mjs +2 -10
  60. package/{src → site}/dollyfile-graph.mjs +1 -1
  61. package/{src → site}/dollyfile-view.mjs +1 -1
  62. package/{src → site}/image-artifact.mjs +9 -8
  63. package/{src → site}/image-build.mjs +4 -4
  64. package/{src → site}/image-builder.mjs +5 -4
  65. package/site/offline.mjs +53 -0
  66. package/{src → site}/session-store.mjs +47 -38
  67. package/src/runtime-worker.mjs +0 -57
  68. /package/{src → page}/build-log.mjs +0 -0
  69. /package/{src → page}/images.mjs +0 -0
  70. /package/{src → page}/page-chords.mjs +0 -0
  71. /package/{src → page}/page-indicators.mjs +0 -0
  72. /package/{src → page}/process-abi.mjs +0 -0
  73. /package/{src → page}/sha256.mjs +0 -0
  74. /package/{src → page}/terminal-text.mjs +0 -0
  75. /package/{src → page}/wasm-interface.mjs +0 -0
  76. /package/{src → site}/alphabetical.mjs +0 -0
@@ -1,6 +1,11 @@
1
1
  import { unretainedPath } from "./image-names.mjs";
2
- const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
3
- export const MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024 * 1024;
2
+ import { DOLLY_SNAPSHOT_VERSION, DOLLY_SNAPSHOT_HEADER_BYTES as HEADER_BYTES, DOLLY_SNAPSHOT_RECORD_HEADER_BYTES as RECORD_HEADER_BYTES,
3
+ DOLLY_SNAPSHOT_RECORD_DIRECTORY as DIRECTORY, DOLLY_SNAPSHOT_RECORD_FILE as FILE, DOLLY_SNAPSHOT_RECORD_SYMLINK as SYMLINK,
4
+ DOLLY_SNAPSHOT_MAX_PATHS, DOLLY_SNAPSHOT_MAX_MEBIBYTES, DOLLY_SNAPSHOT_PATH_MIN_BYTES, DOLLY_SNAPSHOT_PATH_MAX_BYTES,
5
+ DOLLY_SNAPSHOT_SYMLINK_MIN_BYTES, DOLLY_SNAPSHOT_SYMLINK_MAX_BYTES } from "../host/runtime/abi.mjs";
6
+ // A snapshot part's layout and limits are abi/dolly-image-0.wat's.
7
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }), encoder = new TextEncoder();
8
+ export const MAX_SNAPSHOT_BYTES = DOLLY_SNAPSHOT_MAX_MEBIBYTES * 1024 * 1024;
4
9
  // Packs average 2 MiB or more (scripts/share-pages-snapshots.mjs): an image of
5
10
  // MAX_SNAPSHOT_BYTES needs about 1,024, which this bound leaves room for.
6
11
  const MAX_SNAPSHOT_PACKS = MAX_SNAPSHOT_BYTES / (1024 * 1024);
@@ -12,27 +17,46 @@ function compareBytes(left, right) {
12
17
  return left.length - right.length;
13
18
  }
14
19
 
15
- // The 16-byte snapshot header: its record count.
16
- function decodeHeader(bytes) {
20
+ // A snapshot part's header: its record count.
21
+ export function decodeSnapshotHeader(bytes) {
17
22
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
18
- if (bytes.length < 16 || decoder.decode(bytes.subarray(0, 8)) !== "DOLLYSNP" || view.getUint32(8, true) !== 2) {
19
- throw new Error("invalid Dolly snapshot header");
20
- }
23
+ if (bytes.length < HEADER_BYTES || decoder.decode(bytes.subarray(0, 8)) !== "DOLLYSNP" ||
24
+ view.getUint32(8, true) !== DOLLY_SNAPSHOT_VERSION) throw new Error("invalid Dolly snapshot header");
21
25
  const count = view.getUint32(12, true);
22
- if (count === 0 || count > 100_000) throw new Error("invalid Dolly snapshot record count");
26
+ if (count === 0 || count > DOLLY_SNAPSHOT_MAX_PATHS) throw new Error("invalid Dolly snapshot record count");
23
27
  return count;
24
28
  }
25
29
 
26
- // A record's 16-byte header: its kind, path length and data size.
27
- function decodeRecordHeader(bytes) {
30
+ // A record's header: its kind, path length and data size.
31
+ export function decodeRecordHeader(bytes) {
28
32
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
29
33
  const kind = view.getUint32(0, true), pathLength = view.getUint32(4, true), size = view.getBigUint64(8, true);
30
- if (kind < 1 || kind > 3 || pathLength < 2 || pathLength >= 4096 ||
31
- size > BigInt(MAX_SNAPSHOT_BYTES) || (kind === 1 && size !== 0n) ||
32
- (kind === 3 && (size === 0n || size >= 4096n))) throw new Error("invalid snapshot record size");
34
+ if (kind < DIRECTORY || kind > SYMLINK || pathLength < DOLLY_SNAPSHOT_PATH_MIN_BYTES || pathLength > DOLLY_SNAPSHOT_PATH_MAX_BYTES ||
35
+ size > BigInt(MAX_SNAPSHOT_BYTES) || (kind === DIRECTORY && size !== 0n) ||
36
+ (kind === SYMLINK && (size < BigInt(DOLLY_SNAPSHOT_SYMLINK_MIN_BYTES) || size > BigInt(DOLLY_SNAPSHOT_SYMLINK_MAX_BYTES)))) {
37
+ throw new Error("invalid snapshot record size");
38
+ }
33
39
  return { kind, pathLength, size: Number(size) };
34
40
  }
35
41
 
42
+ export function snapshotHeader(count) {
43
+ const bytes = new Uint8Array(HEADER_BYTES), view = new DataView(bytes.buffer);
44
+ bytes.set(encoder.encode("DOLLYSNP"));
45
+ view.setUint32(8, DOLLY_SNAPSHOT_VERSION, true);
46
+ view.setUint32(12, count, true);
47
+ return bytes;
48
+ }
49
+
50
+ // A record's header and its encoded path PATH.
51
+ export function recordHeader(kind, path, size) {
52
+ const bytes = new Uint8Array(RECORD_HEADER_BYTES + path.length), view = new DataView(bytes.buffer);
53
+ view.setUint32(0, kind, true);
54
+ view.setUint32(4, path.length, true);
55
+ view.setBigUint64(8, BigInt(size), true);
56
+ bytes.set(path, RECORD_HEADER_BYTES);
57
+ return bytes;
58
+ }
59
+
36
60
  // The path of PATHBYTES, valid and after PREVIOUS in byte order.
37
61
  function decodePath(pathBytes, previous) {
38
62
  const path = decoder.decode(pathBytes);
@@ -47,7 +71,7 @@ function checkParents(records) {
47
71
  for (const path of records.keys()) {
48
72
  for (let slash = path.indexOf("/", 1); slash !== -1; slash = path.indexOf("/", slash + 1)) {
49
73
  const parent = records.get(path.slice(0, slash));
50
- if (parent && parent.kind !== 1) throw new Error(`snapshot parent is not a directory: ${path}`);
74
+ if (parent && parent.kind !== DIRECTORY) throw new Error(`snapshot parent is not a directory: ${path}`);
51
75
  }
52
76
  }
53
77
  }
@@ -55,20 +79,20 @@ function checkParents(records) {
55
79
  export function decodeSnapshotRecords(input) {
56
80
  const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
57
81
  if (bytes.length > MAX_SNAPSHOT_BYTES) throw new Error("invalid Dolly snapshot header");
58
- const count = decodeHeader(bytes.subarray(0, 16));
82
+ const count = decodeSnapshotHeader(bytes.subarray(0, HEADER_BYTES));
59
83
  const records = new Map();
60
- let offset = 16, previous;
84
+ let offset = HEADER_BYTES, previous;
61
85
  for (let index = 0; index < count; index += 1) {
62
- if (offset > bytes.length - 16) throw new Error("truncated snapshot record");
63
- const { kind, pathLength, size } = decodeRecordHeader(bytes.subarray(offset, offset + 16));
64
- offset += 16;
86
+ if (offset > bytes.length - RECORD_HEADER_BYTES) throw new Error("truncated snapshot record");
87
+ const { kind, pathLength, size } = decodeRecordHeader(bytes.subarray(offset, offset + RECORD_HEADER_BYTES));
88
+ offset += RECORD_HEADER_BYTES;
65
89
  if (offset > bytes.length - pathLength - size) throw new Error("truncated snapshot record");
66
90
  const pathBytes = bytes.subarray(offset, offset + pathLength);
67
91
  const path = decodePath(pathBytes, previous);
68
92
  previous = pathBytes;
69
93
  offset += pathLength;
70
94
  const data = bytes.subarray(offset, offset + size);
71
- if (kind === 3 && data.includes(0)) throw new Error("invalid snapshot symlink");
95
+ if (kind === SYMLINK && data.includes(0)) throw new Error("invalid snapshot symlink");
72
96
  records.set(path, { kind, data });
73
97
  offset += size;
74
98
  }
@@ -143,19 +167,19 @@ class PieceReader {
143
167
  // snapshot's size.
144
168
  export async function scanSnapshotRecords(pieces, keep) {
145
169
  const reader = new PieceReader(pieces);
146
- const count = decodeHeader(await reader.take(16));
170
+ const count = decodeSnapshotHeader(await reader.take(HEADER_BYTES));
147
171
  const records = new Map();
148
172
  let previous;
149
173
  for (let index = 0; index < count; index += 1) {
150
- const { kind, pathLength, size } = decodeRecordHeader(await reader.take(16));
174
+ const { kind, pathLength, size } = decodeRecordHeader(await reader.take(RECORD_HEADER_BYTES));
151
175
  const pathBytes = await reader.take(pathLength);
152
176
  const path = decodePath(pathBytes, previous);
153
177
  previous = pathBytes;
154
178
  const offset = reader.position;
155
179
  let data;
156
- if (kind === 3 || (kind === 2 && keep(path))) {
180
+ if (kind === SYMLINK || (kind === FILE && keep(path))) {
157
181
  data = await reader.take(size);
158
- if (kind === 3 && data.includes(0)) throw new Error("invalid snapshot symlink");
182
+ if (kind === SYMLINK && data.includes(0)) throw new Error("invalid snapshot symlink");
159
183
  } else await reader.skip(size);
160
184
  records.set(path, { kind, data, offset, size });
161
185
  }
@@ -165,35 +189,30 @@ export async function scanSnapshotRecords(pieces, keep) {
165
189
  }
166
190
 
167
191
  export function encodeSnapshotRecords(records) {
168
- const encoder = new TextEncoder();
169
192
  const entries = [...records].map(([path, record]) => ({ path: encoder.encode(path), ...record }))
170
193
  .sort((left, right) => compareBytes(left.path, right.path));
171
- const size = entries.reduce((sum, record) => sum + 16 + record.path.length + record.data.length, 16);
172
- if (size > MAX_SNAPSHOT_BYTES || entries.length === 0 || entries.length > 100_000) throw new Error("snapshot exceeds record limits");
173
- const bytes = new Uint8Array(size), view = new DataView(bytes.buffer);
174
- bytes.set(encoder.encode("DOLLYSNP"));
175
- view.setUint32(8, 2, true);
176
- view.setUint32(12, entries.length, true);
177
- let offset = 16;
194
+ const size = entries.reduce((sum, record) => sum + RECORD_HEADER_BYTES + record.path.length + record.data.length, HEADER_BYTES);
195
+ if (size > MAX_SNAPSHOT_BYTES || entries.length === 0 || entries.length > DOLLY_SNAPSHOT_MAX_PATHS) throw new Error("snapshot exceeds record limits");
196
+ const bytes = new Uint8Array(size);
197
+ bytes.set(snapshotHeader(entries.length));
198
+ let offset = HEADER_BYTES;
178
199
  for (const record of entries) {
179
- view.setUint32(offset, record.kind, true);
180
- view.setUint32(offset + 4, record.path.length, true);
181
- view.setBigUint64(offset + 8, BigInt(record.data.length), true);
182
- bytes.set(record.path, offset + 16);
183
- bytes.set(record.data, offset + 16 + record.path.length);
184
- offset += 16 + record.path.length + record.data.length;
200
+ const header = recordHeader(record.kind, record.path, record.data.length);
201
+ bytes.set(header, offset);
202
+ bytes.set(record.data, offset + header.length);
203
+ offset += header.length + record.data.length;
185
204
  }
186
205
  return bytes;
187
206
  }
188
207
 
189
208
  export function mergeSnapshotRecords(parts) {
190
209
  const records = new Map();
191
- let size = 16;
210
+ let size = HEADER_BYTES;
192
211
  for (const part of parts) {
193
212
  for (const [path, record] of decodeSnapshotRecords(part)) {
194
213
  if (records.has(path)) throw new Error(`duplicate packed snapshot path: ${path}`);
195
- size += 16 + new TextEncoder().encode(path).length + record.data.length;
196
- if (size > MAX_SNAPSHOT_BYTES || records.size >= 100_000) throw new Error("packed snapshot exceeds limits");
214
+ size += RECORD_HEADER_BYTES + encoder.encode(path).length + record.data.length;
215
+ if (size > MAX_SNAPSHOT_BYTES || records.size >= DOLLY_SNAPSHOT_MAX_PATHS) throw new Error("packed snapshot exceeds limits");
197
216
  records.set(path, record);
198
217
  }
199
218
  }
@@ -205,15 +224,15 @@ export function mergeSnapshotRecords(parts) {
205
224
  export function validateSnapshotPacks(metadata) {
206
225
  if (!Array.isArray(metadata.packs) || metadata.packs.length === 0 || metadata.packs.length > MAX_SNAPSHOT_PACKS) throw new Error("invalid snapshot packs");
207
226
  const hashes = new Set();
208
- let size = 16;
227
+ let size = HEADER_BYTES;
209
228
  for (const pack of metadata.packs) {
210
229
  if (!/^[0-9a-f]{64}$/.test(pack.sha256) || hashes.has(pack.sha256) ||
211
- !Number.isSafeInteger(pack.byteLength) || pack.byteLength < 16 || pack.byteLength > MAX_SNAPSHOT_BYTES ||
230
+ !Number.isSafeInteger(pack.byteLength) || pack.byteLength < HEADER_BYTES || pack.byteLength > MAX_SNAPSHOT_BYTES ||
212
231
  !Number.isSafeInteger(pack.encodedByteLength) || pack.encodedByteLength <= 0 || pack.encodedByteLength > MAX_SNAPSHOT_BYTES) {
213
232
  throw new Error("invalid snapshot pack descriptor");
214
233
  }
215
234
  hashes.add(pack.sha256);
216
- size += pack.byteLength - 16;
235
+ size += pack.byteLength - HEADER_BYTES;
217
236
  if (size > MAX_SNAPSHOT_BYTES) throw new Error("packed snapshot exceeds size limit");
218
237
  }
219
238
  if (size !== metadata.byteLength) throw new Error("snapshot pack sizes do not match image");
@@ -61,31 +61,47 @@ export async function boundedAssetBody(response, limit, signal) {
61
61
  // Pages before v0.1.5 read the X-Dolly-Parts header instead.
62
62
  export const PARTS_MANIFEST_START = '{"byteLength":';
63
63
 
64
- // Whether a whole response's body begins as a manifest, read from a copy of it.
65
- async function isPartsManifest(response) {
66
- if (response.status !== 200 || !response.body) return false;
67
- const start = new TextEncoder().encode(PARTS_MANIFEST_START), reader = response.clone().body.getReader();
68
- const head = new Uint8Array(start.length);
69
- let size = 0;
70
- try {
71
- while (size < start.length) {
72
- const { done, value } = await reader.read();
73
- if (done) break;
74
- const taken = value.subarray(0, start.length - size);
75
- head.set(taken, size);
76
- size += taken.length;
77
- }
78
- } finally {
79
- // Not awaited: cancelling a copy settles only once the response is read too.
80
- reader.cancel().catch(() => {});
64
+ // The first `length` bytes of a response's body, and a response whose body is
65
+ // all of it again, those bytes first. It reads the body itself: reading a
66
+ // copy of it and cancelling the copy cost the original its first bytes, in
67
+ // Firefox up to 151 and in Node, when other bodies were read meanwhile.
68
+ async function peeked(response, length) {
69
+ const reader = response.body.getReader(), chunks = [];
70
+ let size = 0, ended = false;
71
+ while (size < length) {
72
+ const next = await reader.read();
73
+ if (next.done) { ended = true; break; }
74
+ chunks.push(next.value);
75
+ size += next.value.length;
81
76
  }
82
- return size === start.length && head.every((byte, index) => byte === start[index]);
77
+ const head = new Uint8Array(Math.min(size, length));
78
+ for (let offset = 0, index = 0; offset < head.length; ++index) {
79
+ const taken = chunks[index].subarray(0, head.length - offset);
80
+ head.set(taken, offset);
81
+ offset += taken.length;
82
+ }
83
+ const body = new ReadableStream({
84
+ start(controller) {
85
+ for (const chunk of chunks) controller.enqueue(chunk);
86
+ if (ended) controller.close();
87
+ },
88
+ async pull(controller) {
89
+ const next = await reader.read();
90
+ if (next.done) controller.close(); else controller.enqueue(next.value);
91
+ },
92
+ cancel: reason => reader.cancel(reason),
93
+ }, { highWaterMark: 0 });
94
+ const whole = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
95
+ Object.defineProperty(whole, "url", { value: response.url });
96
+ return { head, whole };
83
97
  }
84
98
 
85
99
  // Streams verified parts, holding at most one 20 MiB part per request. Each
86
100
  // part matches the manifest hash before any of its bytes are delivered.
87
- export async function decodeStaticAsset(response, target, init, maximumBytes, fetchRequest = globalThis.fetch.bind(globalThis)) {
88
- if (!await isPartsManifest(response)) return response;
101
+ export async function decodeStaticAsset(given, target, init, maximumBytes, fetchRequest = globalThis.fetch.bind(globalThis)) {
102
+ if (given.status !== 200 || !given.body) return given;
103
+ const start = new TextEncoder().encode(PARTS_MANIFEST_START), { head, whole: response } = await peeked(given, start.length);
104
+ if (head.length !== start.length || head.some((byte, index) => byte !== start[index])) return response;
89
105
  const url = new URL(target);
90
106
  if ((init.method ?? "GET") !== "GET" || url.search || url.hash ||
91
107
  url.username || url.password || !/^https?:$/.test(url.protocol)) throw new Error("invalid multipart asset request");
@@ -1,3 +1,3 @@
1
1
  // The version being developed: the release tag vX.Y.Z, the site path /vX.Y.Z/
2
2
  // and the prefix of every recipe reference. package.json holds the same value.
3
- export const DOLLY_VERSION = "0.1.5";
3
+ export const DOLLY_VERSION = "0.1.6";
@@ -12,19 +12,29 @@ import { bootFiles } from "../host/runtime/boot-files.mjs";
12
12
  const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
13
13
 
14
14
  // Listen before the host registry loads its manifests: the page's configure
15
- // message may arrive while that import is still pending.
16
- const configured = new Promise((resolve, reject) => {
17
- const timeout = setTimeout(
18
- () => reject(new Error("Dolly boot configuration was not provided")), 10_000,
19
- );
15
+ // message may arrive while that import is still pending. The page starts this
16
+ // Worker before its own host modules and configures it once they are up, which
17
+ // a route that builds first takes minutes to be.
18
+ const configured = new Promise(resolve => {
20
19
  self.addEventListener("message", function configure(event) {
21
20
  if (event.data?.type !== "configure") return;
22
21
  self.removeEventListener("message", configure);
23
- clearTimeout(timeout);
24
22
  resolve(event.data);
25
23
  });
26
24
  });
27
- const { hostRegistry } = await import("../host/modules.mjs");
25
+ // The page names its image directory and modules as it starts this Worker: their catalog,
26
+ // manifests and providers load while the page starts its own, and configure finds them loaded.
27
+ const registry = import("../host/modules.mjs");
28
+ self.addEventListener("message", function prepare(event) {
29
+ if (event.data?.type !== "prepare") return;
30
+ self.removeEventListener("message", prepare);
31
+ const { images, modules, hostModules } = event.data;
32
+ void (async () => {
33
+ await loadImages(images);
34
+ (await (await registry).hostRegistry(modules)).load(hostModules);
35
+ })().catch(() => {});
36
+ });
37
+ const { hostRegistry } = await registry;
28
38
 
29
39
  // The runtime's own files: the kernel and the contracts beside this module.
30
40
  const runtimeBase = new URL("../", import.meta.url);
@@ -78,7 +88,8 @@ export async function runImage(image) {
78
88
  host = await (await hostRegistry(config.modules)).createHost("worker", config.hostModules, {
79
89
  send: (message, transfers = []) => self.postMessage(message, transfers),
80
90
  configuration: config.hostConfiguration,
81
- resources: { runtimeBase },
91
+ // The image directory reaches the modules here: one the bundle holds is not theirs.
92
+ resources: { runtimeBase, images: new URL(config.images) },
82
93
  });
83
94
  const steps = await image(config, host);
84
95
  stage("creating wasm64 userspace kernel...");
@@ -101,7 +112,7 @@ export async function runImage(image) {
101
112
  const supervisor = restored.supervisor ?? await host.kernel.supervisor(kernel);
102
113
  await steps.finish?.({ kernel, stage });
103
114
 
104
- await host.imageRestored({ kernel, supervisor, stage, files, image: steps.name });
115
+ await host.imageRestored({ kernel, memory, supervisor, stage, files, image: steps.name });
105
116
  if (kernel.dolly_bootstrap_environment() !== 0) throw new Error("Dolly image environment is invalid");
106
117
  await host.start("image", { kernel, memory });
107
118
  const runtimeImage = decoder.decode(files.read("/etc/dolly/image", 256));
@@ -1,18 +1,18 @@
1
1
  // The site's runtime Worker (worker-image.mjs): it builds an image from its
2
- // recipe, from the compiler seed or the base image the page hands it, caches
3
- // it and runs it, telling the page its identity, or, for the image builder,
4
- // hands the page the cached artifact (buildOnly); or it restores a custom
5
- // image the site built before. Images cross to and from Wasm memory a piece
6
- // at a time: the page holds them as Blobs, never in a JS heap.
7
- import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
8
- import { imageBase, imageDefinition } from "./images.mjs";
9
- import { scanSnapshotRecords, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "./snapshot-records.mjs";
2
+ // recipe, from the base image or the engine package the page hands it or from
3
+ // the compiler seed, caches it and runs it, telling the page its identity, or,
4
+ // for the image builder, hands the page the cached artifact (buildOnly); or it
5
+ // restores a custom image the site built before. Images cross to and from Wasm
6
+ // memory a piece at a time: the page holds them as Blobs, never in a JS heap.
7
+ import { DOLLY_IMAGE_BUILD_ID } from "../page/generated.mjs";
8
+ import { imageBase, imageDefinition } from "../page/images.mjs";
9
+ import { scanSnapshotRecords, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "../page/snapshot-records.mjs";
10
10
  import { describeImageArtifact, saveImageArtifact, sha256 } from "./image-artifact.mjs";
11
- import { imageInputs } from "./image-inputs.mjs";
12
- import { inspectDollyfile, MAX_DOLLYFILE_BYTES, recipeRows } from "./dollyfile-view.mjs";
11
+ import { imageInputs } from "../page/image-inputs.mjs";
12
+ import { imageFileName, inspectDollyfile, MAX_DOLLYFILE_BYTES, recipeFileName, recipeRows } from "./dollyfile-view.mjs";
13
13
  import { checkedCustomArtifact } from "./custom-image.mjs";
14
- import { siteReference, decodeStaticAsset } from "./static-asset.mjs";
15
- import { checkedMemoryRange, kernelArtifact, runImage } from "./worker-image.mjs";
14
+ import { siteReference, decodeStaticAsset } from "../page/static-asset.mjs";
15
+ import { checkedMemoryRange, kernelArtifact, runImage } from "../page/worker-image.mjs";
16
16
 
17
17
  const encoder = new TextEncoder();
18
18
  const PIECE = 8 * 1024 * 1024;
@@ -69,17 +69,21 @@ await runImage(async (config, host) => {
69
69
  return range.size;
70
70
  };
71
71
  if (mode === "snapshot") {
72
- const artifact = await checkedCustomArtifact(customSource, config.customArtifact);
72
+ // A saved session is the whole filesystem: it needs the recipe, not the image.
73
+ const artifact = config.customArtifact === undefined ? null : await checkedCustomArtifact(customSource, config.customArtifact);
73
74
  config.customArtifact = undefined;
74
- host.require(artifact.hostRequirements);
75
- host.admit(artifact.hostRequirements);
75
+ const hosts = artifact?.hostRequirements ?? recipe.hostRequirements;
76
+ host.require(hosts);
77
+ await host.admit(hosts);
76
78
  return {
77
79
  name: image,
78
80
  prepare({ files }) {
79
81
  prepareRecipe(files);
80
- files.write("/etc/dolly/image.manifest", `${artifact.manifest.join("\n")}\n`);
82
+ if (artifact) files.write("/etc/dolly/image.manifest", `${artifact.manifest.join("\n")}\n`);
81
83
  },
82
84
  async restore({ kernel, memory, stage }) {
85
+ if (await host.restoreFilesystem({ kernel, memory, stage })) return { status: 0 };
86
+ if (!artifact) throw new Error("invalid Dolly image selection");
83
87
  stage("loading precompiled userspace snapshot...");
84
88
  const size = await stageSnapshot(kernel, memory, artifact.bytes);
85
89
  artifact.bytes = undefined;
@@ -93,6 +97,10 @@ await runImage(async (config, host) => {
93
97
  // /bin/dollyfile executes the rows this page's parser compiles.
94
98
  const rows = recipeRows(custom ? recipe : await catalogRecipe(definition), recipeSha256);
95
99
  const baseReference = custom ? recipe.from : definition.artifacts.find(reference => reference.operation === "from");
100
+ // A root build that installs the recipe engine's package starts from that package's
101
+ // /bin/dollyfile, as a FROM build starts from its base's; any other from the compiler seed.
102
+ const engineReference = baseReference ? null : (custom ? recipe : definition).artifacts.find(reference =>
103
+ reference.operation === "install" && imageFileName(recipeFileName(reference.location)) === "dollyfile");
96
104
  const artifacts = new Map();
97
105
  if (config.artifacts !== undefined && (!Array.isArray(config.artifacts) || config.artifacts.length > 256)) {
98
106
  throw new Error("invalid build artifacts");
@@ -108,16 +116,19 @@ await runImage(async (config, host) => {
108
116
  // A build runs the engine and its tools with the build host's modules, and
109
117
  // the programs of the images it reads with their modules: one the build host
110
118
  // does not enable answers ENOSYS.
111
- host.admit([...declared, ...host.enabled, ...[...artifacts.values()].flatMap(artifact => artifact.hostRequirements)]);
119
+ await host.admit([...declared, ...host.enabled, ...[...artifacts.values()].flatMap(artifact => artifact.hostRequirements)]);
112
120
  const inputs = imageInputs([...artifacts.values()]);
113
121
  const base = baseReference ? artifacts.get(baseReference.sha256) : null;
114
122
  if (baseReference && !base) throw new Error("base image artifact was not provided");
123
+ const engine = engineReference ? artifacts.get(engineReference.sha256) : null;
124
+ if (engineReference && !engine) throw new Error("dollyfile package artifact was not provided");
125
+ const builder = base ?? engine, builderReference = baseReference ?? engineReference;
115
126
  let seed = null, built = false;
116
127
  return {
117
128
  name: image,
118
129
  async prepare({ files, stage }) {
119
- // A root build starts from the compiler seed, a snapshot like any image.
120
- if (!base) {
130
+ // A root build without the engine starts from the compiler seed, a snapshot like any image.
131
+ if (!builder) {
121
132
  stage("loading root compiler seed...");
122
133
  // Static hosts may serve the seed as verified parts.
123
134
  const url = kernelArtifact("dolly.data");
@@ -129,15 +140,15 @@ await runImage(async (config, host) => {
129
140
  [...artifacts.values()].forEach((artifact, input) =>
130
141
  files.input(`/etc/dolly/artifacts/${artifact.recipeSha256}.snapshot`, input, artifact.bytes.size));
131
142
  // The kernel restores a snapshot against the list of its paths.
132
- const manifest = base?.manifest ?? [...(await scanSnapshotRecords(seed.stream(), () => false)).records.keys()];
143
+ const manifest = builder?.manifest ?? [...(await scanSnapshotRecords(seed.stream(), () => false)).records.keys()];
133
144
  files.write("/etc/dolly/image.manifest", `${manifest.join("\n")}\n`);
134
145
  },
135
146
  async restore({ kernel, memory, files, stage }) {
136
147
  stage("building userspace from the Dollyfile...");
137
148
  let status;
138
- if (base) {
139
- stage(`loading builder from ${baseReference.location}...`);
140
- status = kernel.dolly_process_bootstrap_resume_prepare(BigInt(await stageSnapshot(kernel, memory, base.bytes)), 1);
149
+ if (builder) {
150
+ stage(`loading builder from ${builderReference.location}...`);
151
+ status = kernel.dolly_process_bootstrap_resume_prepare(BigInt(await stageSnapshot(kernel, memory, builder.bytes)), 1);
141
152
  } else {
142
153
  status = kernel.dolly_process_bootstrap_prepare(BigInt(await stageSnapshot(kernel, memory, seed)));
143
154
  seed = null;
@@ -145,7 +156,7 @@ await runImage(async (config, host) => {
145
156
  let supervisor;
146
157
  if (status === 0) {
147
158
  supervisor = await host.kernel.supervisor(kernel);
148
- status = (await supervisor.spawn(base ? ["/bin/dollyfile", locator] : ["/usr/libexec/dolly/process-bin/bootstrap"])).status;
159
+ status = (await supervisor.spawn(builder ? ["/bin/dollyfile", locator] : ["/usr/libexec/dolly/process-bin/bootstrap"])).status;
149
160
  }
150
161
  for (const artifact of artifacts.values()) files.remove(`/etc/dolly/artifacts/${artifact.recipeSha256}.snapshot`);
151
162
  artifacts.clear();
@@ -166,7 +177,7 @@ await runImage(async (config, host) => {
166
177
  // The image runs here with its own modules. The page keeps its identity,
167
178
  // which a session saved on it names, not its bytes.
168
179
  host.require(artifact.hostRequirements);
169
- host.admit(artifact.hostRequirements);
180
+ await host.admit(artifact.hostRequirements);
170
181
  const { buildId, sha256: digest, byteLength } = artifact;
171
182
  self.postMessage({ type: "image-built", artifact: { buildId, recipeSha256, sha256: digest, byteLength, inputs: artifact.inputs } });
172
183
  built = true;
@@ -1,5 +1,5 @@
1
- import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
2
- import { describeImageArtifact, loadImageArtifact, loadImageArtifactDescriptor, sha256 } from "./image-artifact.mjs";
1
+ import { DOLLY_IMAGE_BUILD_ID } from "../page/generated.mjs";
2
+ import { describeImageArtifact, loadImageArtifact, sha256 } from "./image-artifact.mjs";
3
3
  import { inspectDollyfile } from "./dollyfile-view.mjs";
4
4
 
5
5
  export async function loadCustomImage(source, descriptor) {
@@ -13,14 +13,6 @@ export async function loadCustomImage(source, descriptor) {
13
13
  return artifact;
14
14
  }
15
15
 
16
- // Whether this browser's image cache still records the completed custom image
17
- // ARTIFACT (its descriptor): a session saved on it can then be restored, which
18
- // checks the image's bytes.
19
- export async function customImageCached(artifact) {
20
- const cached = await loadImageArtifactDescriptor(artifact.recipeSha256, artifact.inputs);
21
- return cached?.sha256 === artifact.sha256 && cached.byteLength === artifact.byteLength;
22
- }
23
-
24
16
  export async function checkedCustomArtifact(source, candidate) {
25
17
  inspectDollyfile(source);
26
18
  if (candidate?.buildId !== DOLLY_IMAGE_BUILD_ID || !(candidate.bytes instanceof Blob) ||
@@ -1,5 +1,5 @@
1
1
  import { inspectDollyfile, recipeFileName, imageFileName } from "./dollyfile-view.mjs";
2
- import { sha256 } from "./static-asset.mjs";
2
+ import { sha256 } from "../page/static-asset.mjs";
3
3
 
4
4
  const key = object => `${object.type}:${object.name}`;
5
5
  const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
@@ -1,5 +1,5 @@
1
1
  import { hostRequirement, hostRequirements } from "../host/requirements.mjs";
2
- import { unretainedPath, validName } from "./image-names.mjs";
2
+ import { unretainedPath, validName } from "../page/image-names.mjs";
3
3
 
4
4
  export const MAX_DOLLYFILE_BYTES = 128 * 1024;
5
5
 
@@ -9,13 +9,14 @@
9
9
  // starting, whatever the machine), a file is not. A file is deleted when its
10
10
  // entry is replaced or evicted; a page that still holds the file then reads
11
11
  // nothing, so an entry's bytes are read only for a build the entry outlives.
12
- import { DOLLY_IMAGE_BUILD_ID } from "../dist/dolly-image-build-id.mjs";
13
- import { imageInputs, imageInputsMatch } from "./image-inputs.mjs";
12
+ import { DOLLY_IMAGE_BUILD_ID } from "../page/generated.mjs";
13
+ import { imageInputs, imageInputsMatch } from "../page/image-inputs.mjs";
14
14
  import { loadRecipeGraph } from "./dollyfile-graph.mjs";
15
15
  import { recipeFileName } from "./dollyfile-view.mjs";
16
- import { sha256 } from "./static-asset.mjs";
17
- import { Sha256 } from "./sha256.mjs";
18
- import { scanSnapshotRecords, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "./snapshot-records.mjs";
16
+ import { sha256 } from "../page/static-asset.mjs";
17
+ import { Sha256 } from "../page/sha256.mjs";
18
+ import { scanSnapshotRecords, MAX_SNAPSHOT_BYTES as snapshotSizeLimit } from "../page/snapshot-records.mjs";
19
+ import { DOLLY_SNAPSHOT_RECORD_FILE } from "../host/runtime/abi.mjs";
19
20
  export { sha256 };
20
21
 
21
22
  const STORE_DIRECTORY = "dolly-images";
@@ -75,14 +76,14 @@ export async function describeImageArtifact(source, recipeSha256, inputs = []) {
75
76
  try {
76
77
  const { records, byteLength } = await scanSnapshotRecords(pieces, path => path.startsWith("/etc/dolly/"));
77
78
  const recipe = records.get("/etc/dolly/Dollyfile");
78
- if (recipe?.kind !== 2 || await sha256(recipe.data) !== recipeSha256 ||
79
- records.get("/etc/dolly/artifact")?.kind !== 2) throw new Error("artifact recipe identity mismatch");
79
+ if (recipe?.kind !== DOLLY_SNAPSHOT_RECORD_FILE || await sha256(recipe.data) !== recipeSha256 ||
80
+ records.get("/etc/dolly/artifact")?.kind !== DOLLY_SNAPSHOT_RECORD_FILE) throw new Error("artifact recipe identity mismatch");
80
81
  // The artifact retains every recipe it was built from, by file name.
81
82
  const graph = await loadRecipeGraph(reference => {
82
83
  if (reference === "Dollyfile") return recipe.data;
83
84
  const path = `/etc/dolly/recipes/${recipeFileName(reference)}`;
84
85
  const record = records.get(path);
85
- if (record?.kind !== 2) throw new Error(`artifact does not retain recipe ${reference}`);
86
+ if (record?.kind !== DOLLY_SNAPSHOT_RECORD_FILE) throw new Error(`artifact does not retain recipe ${reference}`);
86
87
  return record.data;
87
88
  }, "Dollyfile");
88
89
  return { buildId: DOLLY_IMAGE_BUILD_ID, recipeSha256, sha256: hash.hex(),
@@ -1,9 +1,9 @@
1
- import { DOLLY_IMAGES, imageBase, imageDefinition } from "./images.mjs";
1
+ import { DOLLY_IMAGES, imageBase, imageDefinition } from "../page/images.mjs";
2
2
  import { loadRecipeGraph } from "./dollyfile-graph.mjs";
3
- import { imageInputs, imageInputsMatch } from "./image-inputs.mjs";
3
+ import { imageInputs, imageInputsMatch } from "../page/image-inputs.mjs";
4
4
  import { describeImageArtifact, loadImageArtifactDescriptor, loadImageArtifact, saveImageArtifact } from "./image-artifact.mjs";
5
- import { loadPackagedSnapshotMetadata, packagedSystemSnapshot } from "./packaged-snapshot.mjs";
6
- import { siteReference, sitePath } from "./static-asset.mjs";
5
+ import { loadPackagedSnapshotMetadata, packagedSystemSnapshot } from "../page/packaged-snapshot.mjs";
6
+ import { siteReference, sitePath } from "../page/static-asset.mjs";
7
7
 
8
8
 
9
9
  // A custom recipe builds from this release's published recipes and sources.
@@ -1,6 +1,6 @@
1
- import { buildHost, hostRegistry } from "../host/modules.mjs";
1
+ import { hostRegistry } from "../host/modules.mjs";
2
2
  import { hostRequirement } from "../host/requirements.mjs";
3
- import { imageBase, imageDefinition } from "./images.mjs";
3
+ import { imageBase, imageDefinition } from "../page/images.mjs";
4
4
  import { inspectDollyfile } from "./dollyfile-view.mjs";
5
5
 
6
6
  // Disposable Wasm userspace on the runtime the image declares, with the build
@@ -19,9 +19,10 @@ export async function buildImage(image, artifacts, configuration, report, { modu
19
19
  // inputs declare: it enables only the runtime and the build host, but a step
20
20
  // may run a program linked against a declared module's client (transfer's
21
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
22
  const manifests = await Promise.all(modules.map(async url => ({ url, ...(await import(url, { with: { type: "json" } })).default })));
23
+ const needed = new Set([...declared, ...artifacts.flatMap(artifact => artifact.hostRequirements)]
24
+ .map(value => hostRequirement(value).name));
25
+ for (const { name, build } of manifests) if (build) needed.add(name);
25
26
  const registry = await hostRegistry(manifests.filter(({ name }) => needed.has(name)).map(({ url }) => url));
26
27
  const host = await registry.createHost("browser", registry.buildHostFor(declared),
27
28
  { send: message => worker.postMessage(message), configuration });
@@ -0,0 +1,53 @@
1
+ // The site's runtime kept in this browser, so that a saved session opens when
2
+ // the network fails: the files dist/dolly-offline.json lists (the pages of a
3
+ // session, the page's and the Workers' code, the host modules and the kernel),
4
+ // in a cache named by the site and that list's digest. The service worker
5
+ // answers from it only when a request fails (coi-serviceworker.js). The page
6
+ // alone writes it, each file checked against the list's SHA-256 for it; nothing
7
+ // of the guest reaches it. A cache is complete once it holds the list itself.
8
+ import { sha256 } from "../page/static-asset.mjs";
9
+
10
+ const prefix = site => `dolly-offline ${new URL(site).href} `;
11
+ const listUrl = site => new URL("dist/dolly-offline.json", site).href;
12
+
13
+ async function keptCaches(site) {
14
+ return (await caches.keys()).filter(name => name.startsWith(prefix(site)));
15
+ }
16
+
17
+ // Whether SITE's runtime is kept whole.
18
+ export async function runtimeKept(site) {
19
+ for (const name of await keptCaches(site)) if (await (await caches.open(name)).match(listUrl(site))) return true;
20
+ return false;
21
+ }
22
+
23
+ // Keeps SITE's runtime as its server gives it now, and removes what was kept of
24
+ // another. Rejects where the browser refuses the page a service worker or a
25
+ // cache, or a file is not the one the list names.
26
+ export async function keepRuntime(site) {
27
+ await navigator.serviceWorker.register(new URL("coi-serviceworker.js", site), { scope: new URL(site).pathname });
28
+ const listed = await fetch(listUrl(site), { cache: "no-cache" });
29
+ if (!listed.ok) throw new Error("this site lists no runtime to keep");
30
+ const list = await listed.clone().text(), name = prefix(site) + await sha256(new TextEncoder().encode(list));
31
+ const cache = await caches.open(name);
32
+ if (!await cache.match(listUrl(site))) {
33
+ try {
34
+ await Promise.all(JSON.parse(list).map(async ([path, digest]) => {
35
+ const url = new URL(path, site).href;
36
+ const response = await fetch(url, { cache: "no-cache" }), copy = response.clone();
37
+ if (!response.ok || await sha256(await response.arrayBuffer()) !== digest) throw new Error(`${path} is not the file this site lists`);
38
+ await cache.put(url, copy);
39
+ }));
40
+ await cache.put(listUrl(site), listed);
41
+ } catch (error) {
42
+ // A part of a runtime opens nothing and takes room the saves may need.
43
+ await caches.delete(name);
44
+ throw error;
45
+ }
46
+ }
47
+ for (const other of await keptCaches(site)) if (other !== name) await caches.delete(other);
48
+ }
49
+
50
+ // Removes what is kept of SITE's runtime.
51
+ export async function clearRuntime(site) {
52
+ for (const name of await keptCaches(site)) await caches.delete(name);
53
+ }