@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,335 @@
1
+ import { hostRequirement, hostRequirements } from "../host/requirements.mjs";
2
+ import { unretainedPath, validName } from "./image-names.mjs";
3
+
4
+ export const MAX_DOLLYFILE_BYTES = 128 * 1024;
5
+
6
+ const objectTypes = new Set([
7
+ "TOOL", "LIB", "ENV", "FILE", "FOLDER", "HEADER",
8
+ ]);
9
+ const sha256Pattern = /^[0-9a-f]{64}$/;
10
+ const whitespace = /[ \t\r\n\v\f]/;
11
+ const trim = value => value.replace(/^[ \t\r\n\v\f]+|[ \t\r\n\v\f]+$/g, "");
12
+ const byteLength = value => new TextEncoder().encode(value).byteLength;
13
+
14
+ function fail(label, line, message) {
15
+ throw new Error(`${label}:${line}: ${message}`);
16
+ }
17
+
18
+ function physicalLines(source, label) {
19
+ if (typeof source !== "string") throw new TypeError(`${label}: Dollyfile must be text`);
20
+ if (byteLength(source) > MAX_DOLLYFILE_BYTES || source.includes("\0")) {
21
+ throw new Error(`${label}: invalid Dollyfile text`);
22
+ }
23
+ return source.split(/\r\n|\r|\n/);
24
+ }
25
+
26
+ function stripComment(value) {
27
+ let quote = "";
28
+ let escaped = false;
29
+ for (let index = 0; index < value.length; index += 1) {
30
+ const character = value[index];
31
+ if (escaped) escaped = false;
32
+ else if (character === "\\" && quote !== "'") escaped = true;
33
+ else if (quote) {
34
+ if (character === quote) quote = "";
35
+ } else if (character === "'" || character === '"') quote = character;
36
+ else if (character === "#" && (index === 0 || whitespace.test(value[index - 1]))) {
37
+ return value.slice(0, index);
38
+ }
39
+ }
40
+ return value;
41
+ }
42
+
43
+ // The words of a row; `ends` receives the offset after each.
44
+ function words(value, label, line, ends = []) {
45
+ const result = [];
46
+ let word = "";
47
+ let quote = "";
48
+ let escaped = false;
49
+ let started = false;
50
+ for (let offset = 0; offset < value.length; offset += 1) {
51
+ const character = value[offset];
52
+ if (escaped) {
53
+ if (quote === '"' && !['$', '`', '"', "\\"].includes(character)) word += "\\";
54
+ word += character;
55
+ escaped = false;
56
+ started = true;
57
+ } else if (character === "\\" && quote !== "'") {
58
+ escaped = true;
59
+ started = true;
60
+ } else if (quote) {
61
+ if (character === quote) quote = "";
62
+ else word += character;
63
+ started = true;
64
+ } else if (character === "'" || character === '"') {
65
+ quote = character;
66
+ started = true;
67
+ } else if (whitespace.test(character)) {
68
+ if (started) {
69
+ result.push(word);
70
+ ends.push(offset);
71
+ word = "";
72
+ started = false;
73
+ }
74
+ } else {
75
+ word += character;
76
+ started = true;
77
+ }
78
+ }
79
+ if (escaped || quote) fail(label, line, "unterminated quoted word");
80
+ if (started) {
81
+ result.push(word);
82
+ ends.push(value.length);
83
+ }
84
+ return result;
85
+ }
86
+
87
+ function directives(physical, label) {
88
+ const result = [];
89
+ for (let index = 0; index < physical.length; index += 1) {
90
+ const raw = physical[index];
91
+ const line = index + 1;
92
+ let logical = stripComment(raw).replace(/[ \t]+$/, "");
93
+ while (logical.endsWith("\\")) {
94
+ logical = logical.slice(0, -1);
95
+ index += 1;
96
+ if (index >= physical.length || index === physical.length - 1 && physical[index] === "") {
97
+ fail(label, line, "unterminated continuation");
98
+ }
99
+ logical += `${logical.length ? " " : ""}${stripComment(physical[index]).replace(/[ \t]+$/, "")}`;
100
+ }
101
+ if (byteLength(logical) > 64 * 1024) fail(label, line, "logical line is too long");
102
+ logical = trim(logical);
103
+ if (logical === "") continue;
104
+ const match = /^([^ \t\r\n\v\f]+)(?:[ \t\r\n\v\f]+(.*))?$/s.exec(logical);
105
+ const directive = match[1];
106
+ const args = match[2] ?? "";
107
+ let body = null;
108
+ let endLine = index + 1;
109
+ if (directive === "FILE") {
110
+ const bodyLines = [];
111
+ while (index + 1 < physical.length && physical[index + 1].startsWith(" ")) {
112
+ index += 1;
113
+ bodyLines.push(physical[index].slice(4));
114
+ endLine = index + 1;
115
+ }
116
+ if (bodyLines.length) body = bodyLines.join("\n") + "\n";
117
+ }
118
+ result.push({ line, endLine, directive, args, body });
119
+ }
120
+ return result;
121
+ }
122
+
123
+ function validAbsolutePath(value) {
124
+ return value.startsWith("/") && value.length > 1 && byteLength(value) < 4096 &&
125
+ !/[\\\r\n]/.test(value) && !value.endsWith("/") && !value.includes("//") &&
126
+ !value.split("/").some((part) => part === "." || part === "..");
127
+ }
128
+
129
+ // A site path names a file one release of the site publishes: /vX.Y.Z/PATH,
130
+ // without a query. FROM, INSTALL and COPY take one whose file is
131
+ // Dollyfile[-NAME]; SOURCE takes one or an absolute http(s) URL without a
132
+ // fragment.
133
+ const sitePathSyntax = /^\/v\d+\.\d+\.\d+\/[^?#\\ \t\r\n\v\f]+$/;
134
+ const sourceURL = /^https?:\/\/[^/?#\\ \t\r\n\v\f]+(?:[/?][^#\\ \t\r\n\v\f]*)?$/;
135
+ // A URL parser resolves empty, "." and ".." path segments (also spelled with
136
+ // %2e) to another path, so a recipe names only normalized paths.
137
+ function normalizedPath(reference) {
138
+ const path = reference.split("?")[0].replace(/^https?:\/\/[^/]*/, "");
139
+ return !path.includes("//") && !path.split("/").some(segment => /^(?:\.|%2e){1,2}$/i.test(segment));
140
+ }
141
+ // The file a recipe's site path names, or "" when the value is not one.
142
+ export const recipeFileName = reference =>
143
+ sitePathSyntax.test(reference) && normalizedPath(reference) ? reference.slice(reference.lastIndexOf("/") + 1) : "";
144
+ // The image name a file "Dollyfile" or "Dollyfile-NAME" declares, or "".
145
+ export const imageFileName = file => file === "Dollyfile" ? "default" : file.startsWith("Dollyfile-") ? file.slice(10) : "";
146
+ const roles = { APPLICATION: "application", TOOLCHAIN: "toolchain", PACKAGE: "package" };
147
+
148
+ function assertObject(tokens, label, item, directive) {
149
+ if (tokens.length < 2 || !objectTypes.has(tokens[0])) {
150
+ fail(label, item.line, `invalid ${directive}; expected ${directive} <${[...objectTypes].join("|")}> NAME`);
151
+ }
152
+ if (tokens[0] === "ENV" && !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(tokens[1])) {
153
+ fail(label, item.line, `invalid ${directive} environment name`);
154
+ }
155
+ if (tokens[0] !== "ENV" && (tokens[1].length > 128 || !/^(?:[a-zA-Z][a-zA-Z0-9._+-]*|\[)$/.test(tokens[1]))) {
156
+ fail(label, item.line, `invalid ${directive}`);
157
+ }
158
+ }
159
+
160
+ // RUN and ENTRY share one argument vector grammar and its record limits.
161
+ function programWords(tokens, label, item) {
162
+ if (tokens.length === 0 || !validAbsolutePath(tokens[0])) fail(label, item.line, `invalid ${item.directive}`);
163
+ if (tokens.length > 256 || tokens.some(word => byteLength(word) > 4096) ||
164
+ tokens.reduce((size, word) => size + 4 + byteLength(word), 16) > 64 * 1024) {
165
+ fail(label, item.line, `${item.directive} exceeds its record limits`);
166
+ }
167
+ return tokens;
168
+ }
169
+
170
+ function inspectRecipe(source, label, rows) {
171
+ let role = null;
172
+ let name = null;
173
+ let entry = null;
174
+ const requirements = [];
175
+ const exports = [];
176
+ const sources = [];
177
+ const slops = [];
178
+ const files = [];
179
+ const folders = [];
180
+ const artifacts = [];
181
+ let from = null;
182
+ // REQUIRES HOST lines are the image's manifest: they follow the role line.
183
+ let operations = 0;
184
+
185
+ for (const item of rows.slice(1)) {
186
+ const ends = [];
187
+ const tokens = words(item.args, label, item.line, ends);
188
+ // What /bin/dollyfile executes for the row (recipeRows).
189
+ item.fields = [item.directive, ...tokens];
190
+ if (role === null && !(item.directive in roles)) {
191
+ fail(label, item.line, "expected APPLICATION, TOOLCHAIN or PACKAGE");
192
+ }
193
+ if (entry) fail(label, item.line, "ENTRY must be the final declaration");
194
+ switch (item.directive) {
195
+ case "APPLICATION":
196
+ case "TOOLCHAIN":
197
+ case "PACKAGE":
198
+ if (role || tokens.length !== 1 || !validName(tokens[0])) fail(label, item.line, `invalid ${item.directive}`);
199
+ role = roles[item.directive];
200
+ name = tokens[0];
201
+ break;
202
+ case "FROM":
203
+ case "INSTALL":
204
+ case "USE":
205
+ case "COPY": {
206
+ const operation = item.directive.toLowerCase();
207
+ if (tokens.length !== (operation === "copy" ? 4 : 2) ||
208
+ !validName(imageFileName(recipeFileName(tokens[0]))) || !sha256Pattern.test(tokens[1]) ||
209
+ tokens.slice(2).some(path => path !== "/" && !validAbsolutePath(path))) {
210
+ fail(label, item.line, `invalid ${item.directive}`);
211
+ }
212
+ if (operation === "from" && operations !== 0) {
213
+ fail(label, item.line, "FROM must be the first operation");
214
+ }
215
+ if (operation === "use" && role !== "package") {
216
+ fail(label, item.line, "USE is for a PACKAGE recipe, which keeps nothing it installs");
217
+ }
218
+ operations += 1;
219
+ const artifact = { location: tokens[0], sha256: tokens[1], line: item.line,
220
+ source: tokens[2] ?? "/", destination: tokens[3] ?? "/", operation };
221
+ artifacts.push(artifact);
222
+ if (operation === "from") from = artifact;
223
+ break;
224
+ }
225
+ case "SOURCE":
226
+ operations += 1;
227
+ if (tokens.length !== 3 || !(sitePathSyntax.test(tokens[0]) || sourceURL.test(tokens[0])) || !normalizedPath(tokens[0]) ||
228
+ !sha256Pattern.test(tokens[1]) || !validAbsolutePath(tokens[2])) fail(label, item.line, "invalid SOURCE");
229
+ sources.push({ location: tokens[0], sha256: tokens[1], destination: tokens[2], line: item.line });
230
+ break;
231
+ case "REQUIRES":
232
+ if (tokens[0] === "HOST") {
233
+ if (tokens.length !== 2) fail(label, item.line, "invalid REQUIRES HOST; expected REQUIRES HOST NAME@ABI");
234
+ if (operations !== 0) fail(label, item.line, "REQUIRES HOST lines follow the role line, before every other declaration");
235
+ try { hostRequirement(tokens[1]); } catch (error) { fail(label, item.line, error.message); }
236
+ } else {
237
+ assertObject(tokens, label, item, "REQUIRES");
238
+ if (!["TOOL", "LIB", "HEADER"].includes(tokens[0])) fail(label, item.line, "REQUIRES takes HOST, TOOL, LIB or HEADER");
239
+ operations += 1;
240
+ }
241
+ if (tokens.length !== 2) fail(label, item.line, "invalid REQUIRES");
242
+ if (tokens[0] === "TOOL" && tokens[1] === "slop") fail(label, item.line, "SLOP depends on /bin/slop by definition");
243
+ requirements.push({ type: tokens[0], name: tokens[1], line: item.line });
244
+ break;
245
+ case "EXPORTS": {
246
+ operations += 1;
247
+ assertObject(tokens, label, item, "EXPORTS");
248
+ const [type, object, ...details] = tokens;
249
+ if (type === "TOOL") {
250
+ if (details.length !== 0) fail(label, item.line, "invalid TOOL export");
251
+ } else if (type === "ENV") {
252
+ if (details.length !== 1) {
253
+ fail(label, item.line, "invalid ENV export");
254
+ }
255
+ } else if (details.length !== 1 || !validAbsolutePath(details[0]) || unretainedPath(details[0])) {
256
+ fail(label, item.line, `invalid ${type} export`);
257
+ }
258
+ exports.push({ type, name: object, details, line: item.line });
259
+ break;
260
+ }
261
+ case "FILE":
262
+ operations += 1;
263
+ if (tokens.length !== 1 || !validAbsolutePath(tokens[0])) fail(label, item.line, "invalid FILE");
264
+ if (unretainedPath(tokens[0]) && !tokens[0].startsWith("/tmp/")) {
265
+ fail(label, item.line, "FILE cannot retain mutable session state");
266
+ }
267
+ files.push({ path: tokens[0], body: item.body, line: item.line, endLine: item.endLine });
268
+ if (item.body !== null) item.fields.push(item.body);
269
+ break;
270
+ case "FOLDER":
271
+ operations += 1;
272
+ if (tokens.length !== 1 || !validAbsolutePath(tokens[0]) ||
273
+ unretainedPath(tokens[0])) fail(label, item.line, "invalid FOLDER");
274
+ folders.push({ path: tokens[0], line: item.line });
275
+ break;
276
+ case "RUN":
277
+ case "SLOP": {
278
+ operations += 1;
279
+ let cwd = "/";
280
+ let command = tokens;
281
+ if (tokens[0] === "CWD") {
282
+ if (tokens.length < 3 || (tokens[1] !== "/" && !validAbsolutePath(tokens[1]))) {
283
+ fail(label, item.line, `invalid ${item.directive} CWD`);
284
+ }
285
+ cwd = tokens[1];
286
+ command = tokens.slice(2);
287
+ }
288
+ if (item.directive === "RUN") {
289
+ item.fields = ["RUN", cwd, ...programWords(command, label, item)];
290
+ } else {
291
+ if (!command[0]) fail(label, item.line, "empty SLOP");
292
+ slops.push({ cwd, command, line: item.line });
293
+ // Slop reads the command as written, quoting and all.
294
+ item.fields = ["SLOP", cwd, trim(tokens[0] === "CWD" ? item.args.slice(ends[1]) : item.args)];
295
+ }
296
+ break;
297
+ }
298
+ case "ENTRY":
299
+ if (!["application", "toolchain"].includes(role)) fail(label, item.line, "invalid ENTRY");
300
+ entry = programWords(tokens, label, item);
301
+ break;
302
+ case "DOLLY":
303
+ fail(label, item.line, "DOLLY may only appear on the first line");
304
+ break;
305
+ default:
306
+ fail(label, item.line, `unknown directive ${item.directive}`);
307
+ }
308
+ }
309
+ if (role === null) throw new Error(`${label}: missing APPLICATION, TOOLCHAIN or PACKAGE`);
310
+ if (role === "application" && !entry) throw new Error(`${label}: APPLICATION is missing ENTRY`);
311
+ let required;
312
+ try { required = hostRequirements(requirements.filter(item => item.type === "HOST").map(item => item.name)); }
313
+ catch (error) { throw new Error(`${label}: ${error.message}`); }
314
+ return {
315
+ hostRequirements: required, role, name, image: name, entry,
316
+ requirements, exports, artifacts, from, sources, slops, files, folders, rows, source,
317
+ };
318
+ }
319
+
320
+ // The rows /bin/dollyfile executes, which the page writes to
321
+ // /etc/dolly/recipe.rows: the engine parses no recipe text, so this file is the
322
+ // language's one parser. Every field ends with NUL, which no recipe holds:
323
+ // "DOLLY 7 SHA256" of the recipe's text, then for each row after DOLLY
324
+ // "LINE COUNT" and that many fields, the directive first.
325
+ export const recipeRows = (recipe, sha256) => [`DOLLY 7 ${sha256}`,
326
+ ...recipe.rows.slice(1).flatMap(row => [`${row.line} ${row.fields.length}`, ...row.fields])]
327
+ .map(field => `${field}\0`).join("");
328
+
329
+ export function inspectDollyfile(source, label = "Dollyfile") {
330
+ const rows = directives(physicalLines(source, label), label);
331
+ if (rows[0]?.directive !== "DOLLY" || rows[0].args !== "7") {
332
+ throw new Error(`${label}:${rows[0]?.line ?? 1}: first declaration must be DOLLY 7`);
333
+ }
334
+ return inspectRecipe(source, label, rows);
335
+ }
@@ -0,0 +1,248 @@
1
+ // Built images in this browser: an artifact's identity read from its own
2
+ // snapshot, and the bounded cache of the images the site builds: each one's
3
+ // snapshot a file of origin-private storage, indexed in IndexedDB.
4
+ //
5
+ // A snapshot is written to its file as it streams out of Wasm memory or out
6
+ // of its packs, and the artifact's bytes is that file. A Blob built from
7
+ // parts in memory is unreadable in Chrome once the parts exceed its
8
+ // in-memory blob space (500 MiB before the browser has been idle after
9
+ // starting, whatever the machine), a file is not. A file is deleted when its
10
+ // entry is replaced or evicted; a page that still holds the file then reads
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";
14
+ import { loadRecipeGraph } from "./dollyfile-graph.mjs";
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";
19
+ export { sha256 };
20
+
21
+ const STORE_DIRECTORY = "dolly-images";
22
+ // The store's files this page reads: a build may still read one whose entry
23
+ // was evicted, so it is left for a later sweep.
24
+ const held = new Set();
25
+
26
+ async function storeDirectory() {
27
+ try {
28
+ return await (await navigator.storage.getDirectory()).getDirectoryHandle(STORE_DIRECTORY, { create: true });
29
+ } catch { return null; }
30
+ }
31
+
32
+ // A new file of the store, written piece by piece: write(piece), then
33
+ // close() for its File or abort() to delete it. Null without a store.
34
+ async function openStoreFile() {
35
+ const directory = await storeDirectory();
36
+ if (!directory) return null;
37
+ const name = crypto.randomUUID();
38
+ const handle = await directory.getFileHandle(name, { create: true }), writable = await handle.createWritable();
39
+ return {
40
+ write: piece => writable.write(piece),
41
+ async close() {
42
+ await writable.close();
43
+ return handle.getFile();
44
+ },
45
+ async abort() {
46
+ await writable.abort().catch(() => {});
47
+ await directory.removeEntry(name).catch(() => {});
48
+ },
49
+ };
50
+ }
51
+
52
+ // The File of a store file written through OPENED, which this page then holds.
53
+ async function storeFile(opened) {
54
+ const file = await opened.close();
55
+ held.add(file.name);
56
+ return file;
57
+ }
58
+
59
+ // An artifact's identity read from its snapshot, SOURCE: a Blob, or the
60
+ // snapshot's bytes in pieces (an iterable of Uint8Array), which become a file
61
+ // of the store (a Blob in memory where the browser has no origin-private
62
+ // storage). The snapshot is digested and checked as it passes; only the
63
+ // identity's files under /etc/dolly are held.
64
+ export async function describeImageArtifact(source, recipeSha256, inputs = []) {
65
+ const blob = source instanceof Blob ? source : null, hash = new Sha256(), parts = [];
66
+ const file = blob ? null : await openStoreFile();
67
+ const pieces = (async function* () {
68
+ for await (const piece of blob ? blob.stream() : source) {
69
+ hash.update(piece);
70
+ if (file) await file.write(piece);
71
+ else if (!blob) parts.push(new Blob([piece]));
72
+ yield piece;
73
+ }
74
+ })();
75
+ try {
76
+ const { records, byteLength } = await scanSnapshotRecords(pieces, path => path.startsWith("/etc/dolly/"));
77
+ 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");
80
+ // The artifact retains every recipe it was built from, by file name.
81
+ const graph = await loadRecipeGraph(reference => {
82
+ if (reference === "Dollyfile") return recipe.data;
83
+ const path = `/etc/dolly/recipes/${recipeFileName(reference)}`;
84
+ const record = records.get(path);
85
+ if (record?.kind !== 2) throw new Error(`artifact does not retain recipe ${reference}`);
86
+ return record.data;
87
+ }, "Dollyfile");
88
+ return { buildId: DOLLY_IMAGE_BUILD_ID, recipeSha256, sha256: hash.hex(),
89
+ inputs: imageInputs(inputs), hostRequirements: graph.root.hostRequirements,
90
+ byteLength, manifest: [...records.keys()], bytes: blob ?? (file ? await storeFile(file) : new Blob(parts)) };
91
+ } catch (error) {
92
+ await file?.abort();
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ async function databaseOperation(mode, operation) {
98
+ const database = await new Promise((resolve, reject) => {
99
+ const request = indexedDB.open("dolly-image-artifacts-v3", 3);
100
+ request.onupgradeneeded = () => {
101
+ // This database contains rebuildable images, never named user sessions.
102
+ if (request.result.objectStoreNames.contains("images")) request.result.deleteObjectStore("images");
103
+ request.result.createObjectStore("images", { keyPath: "id" });
104
+ // Other runtime versions' pages keep their images' bytes here; this
105
+ // version's are files of the store, named by their entries.
106
+ request.result.createObjectStore("payloads");
107
+ };
108
+ request.onsuccess = () => resolve(request.result);
109
+ request.onerror = () => reject(request.error);
110
+ });
111
+ try {
112
+ return await new Promise((resolve, reject) => {
113
+ const transaction = database.transaction(["images", "payloads"], mode);
114
+ let request;
115
+ transaction.oncomplete = () => resolve(request.result);
116
+ transaction.onerror = transaction.onabort = () => reject(transaction.error ?? new Error("image cache transaction aborted"));
117
+ try { request = operation(transaction.objectStore("images"), transaction.objectStore("payloads")); }
118
+ catch (error) { transaction.abort(); reject(error); }
119
+ });
120
+ } finally { database.close(); }
121
+ }
122
+
123
+ export async function loadImageArtifactDescriptor(recipeSha256, inputs = []) {
124
+ try {
125
+ const id = `${DOLLY_IMAGE_BUILD_ID}:${recipeSha256}`;
126
+ const record = await databaseOperation("readonly", store => store.get(id));
127
+ if (record?.id !== id || record.buildId !== DOLLY_IMAGE_BUILD_ID || record.recipeSha256 !== recipeSha256 ||
128
+ !/^[0-9a-f]{64}$/.test(record.sha256) || !Number.isSafeInteger(record.byteLength) ||
129
+ record.byteLength <= 0 || record.byteLength > snapshotSizeLimit || typeof record.file !== "string" ||
130
+ !imageInputsMatch(record.inputs, inputs)) return null;
131
+ return record;
132
+ } catch { return null; }
133
+ }
134
+
135
+ // The cached artifact DESCRIPTOR (its recipe, digest, size and inputs) names,
136
+ // its bytes the entry's file, or null when the entry holds another image or
137
+ // its file is missing or holds other bytes. Loading marks the entry used: the
138
+ // entries a build reads are evicted last.
139
+ export async function loadImageArtifact(descriptor) {
140
+ try {
141
+ const id = `${DOLLY_IMAGE_BUILD_ID}:${descriptor.recipeSha256}`;
142
+ const entry = await databaseOperation("readwrite", store => {
143
+ const request = store.get(id);
144
+ request.onsuccess = () => {
145
+ if (request.result?.sha256 === descriptor.sha256 && request.result.file) store.put({ ...request.result, usedAt: now() });
146
+ };
147
+ return request;
148
+ });
149
+ if (entry?.sha256 !== descriptor.sha256 || typeof entry.file !== "string") return null;
150
+ const payload = await (await (await storeDirectory()).getFileHandle(entry.file)).getFile();
151
+ if (payload.size !== descriptor.byteLength || payload.size <= 0 || payload.size > snapshotSizeLimit) return null;
152
+ held.add(entry.file);
153
+ const artifact = await describeImageArtifact(payload, descriptor.recipeSha256, descriptor.inputs);
154
+ return artifact.sha256 === descriptor.sha256 ? artifact : null;
155
+ } catch { return null; }
156
+ }
157
+
158
+ const now = () => performance.timeOrigin + performance.now();
159
+
160
+ // Rebuildable images only; a bounded cache so builds cannot fill browser storage.
161
+ export const IMAGE_CACHE_MAX_ENTRIES = 32;
162
+ const IMAGE_CACHE_MAX_BYTES = 8 * 1024 ** 3;
163
+
164
+ // The name of the store's file holding BLOB: its own name when it is one of
165
+ // the store's files, else a new file written from it.
166
+ async function storeFileOf(directory, blob) {
167
+ if (blob instanceof File && held.has(blob.name)) return blob.name;
168
+ const file = await openStoreFile();
169
+ try {
170
+ for await (const piece of blob.stream()) await file.write(piece);
171
+ return (await file.close()).name;
172
+ } catch (error) {
173
+ await file.abort();
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ export async function saveImageArtifact(artifact, slot = artifact.recipeSha256) {
179
+ try {
180
+ if (artifact.buildId !== DOLLY_IMAGE_BUILD_ID || !(artifact.bytes instanceof Blob) ||
181
+ artifact.bytes.size !== artifact.byteLength || artifact.byteLength <= 0 ||
182
+ artifact.byteLength > snapshotSizeLimit) return false;
183
+ const directory = await storeDirectory();
184
+ if (!directory) return false;
185
+ const file = await storeFileOf(directory, artifact.bytes);
186
+ const id = `${DOLLY_IMAGE_BUILD_ID}:${artifact.recipeSha256}`;
187
+ const { buildId, recipeSha256, sha256, inputs, hostRequirements, byteLength } = artifact;
188
+ const removed = [];
189
+ try {
190
+ await databaseOperation("readwrite", (store, payloads) => {
191
+ const remove = entry => { store.delete(entry.id); payloads.delete(entry.id); if (entry.file) removed.push(entry.file); };
192
+ const previous = store.get(id);
193
+ previous.onsuccess = () => {
194
+ if (previous.result && previous.result.file !== file) remove(previous.result);
195
+ store.put({ buildId, recipeSha256, sha256, inputs, hostRequirements, byteLength, slot, id, file, usedAt: now() });
196
+ // Publish and prune atomically: failed writes preserve the previous
197
+ // entry, and concurrent writers cannot prune each other's newly
198
+ // published data. This runtime's previous image in the slot goes
199
+ // first, then the least recently used images beyond the entry and
200
+ // byte bounds. Other versions' pages keep their images here too, so
201
+ // another runtime's are only bounded.
202
+ const kept = [], cursor = store.openCursor();
203
+ cursor.onsuccess = () => {
204
+ const entry = cursor.result?.value;
205
+ if (entry) {
206
+ if (entry.id !== id) {
207
+ if (entry.buildId === DOLLY_IMAGE_BUILD_ID && entry.slot === slot) remove(entry);
208
+ else kept.push(entry);
209
+ }
210
+ cursor.result.continue();
211
+ return;
212
+ }
213
+ kept.sort((left, right) => (right.usedAt ?? right.savedAt ?? 0) - (left.usedAt ?? left.savedAt ?? 0));
214
+ let total = byteLength;
215
+ kept.forEach((entry, index) => {
216
+ total += entry.byteLength;
217
+ if (index + 1 >= IMAGE_CACHE_MAX_ENTRIES || total > IMAGE_CACHE_MAX_BYTES) remove(entry);
218
+ });
219
+ };
220
+ };
221
+ return previous;
222
+ });
223
+ } catch (error) {
224
+ if (file !== artifact.bytes.name) await directory.removeEntry(file).catch(() => {});
225
+ return false;
226
+ }
227
+ await deleteStoreFiles(directory, removed.filter(name => !held.has(name)));
228
+ await sweepStore(directory).catch(() => {});
229
+ return true;
230
+ } catch { return false; }
231
+ }
232
+
233
+ async function deleteStoreFiles(directory, names) {
234
+ for (const name of names) await directory.removeEntry(name).catch(() => {});
235
+ }
236
+
237
+ // Files no entry names and this page does not hold, an hour after they were
238
+ // written: what a failed save or a closed page left, and the evicted files
239
+ // pages still read. Chrome lists a file being written under another name.
240
+ const SWEEP_AGE = 60 * 60 * 1000;
241
+ async function sweepStore(directory) {
242
+ const named = new Set(await databaseOperation("readonly", store => store.getAll()).then(entries => entries.map(entry => entry.file)));
243
+ for await (const [name, handle] of directory) {
244
+ if (handle.kind !== "file" || named.has(name) || held.has(name) ||
245
+ !/^[0-9a-f-]{36}$/.test(name) || (await handle.getFile()).lastModified > now() - SWEEP_AGE) continue;
246
+ await directory.removeEntry(name).catch(() => {});
247
+ }
248
+ }
@@ -0,0 +1,89 @@
1
+ import { DOLLY_IMAGES, imageBase, imageDefinition } from "./images.mjs";
2
+ import { loadRecipeGraph } from "./dollyfile-graph.mjs";
3
+ import { imageInputs, imageInputsMatch } from "./image-inputs.mjs";
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";
7
+
8
+
9
+ // A custom recipe builds from this release's published recipes and sources.
10
+ async function customRecipeGraph(customSource, signal) {
11
+ const graph = await loadRecipeGraph(async reference => {
12
+ if (reference === "Dollyfile") return new TextEncoder().encode(customSource);
13
+ const response = await fetch(new URL(sitePath(reference).slice(1), imageBase),
14
+ { credentials: "same-origin", redirect: "error", signal });
15
+ if (!response.ok) throw new Error(`${reference}: HTTP ${response.status}; this release does not publish that recipe`);
16
+ return response.arrayBuffer();
17
+ }, "Dollyfile");
18
+ // The broker would refuse another version's path without saying why.
19
+ for (const { location } of graph.root.sources) sitePath(location);
20
+ return graph;
21
+ }
22
+
23
+ // Only explicit image references schedule builds. Modules still execute in
24
+ // order inside their caller's userspace; this traversal makes no input guesses.
25
+ export async function prepareImageArtifacts(image, customSource, build, report, signal) {
26
+ const artifacts = new Map(), active = new Set();
27
+ async function materialize(node) {
28
+ signal?.throwIfAborted();
29
+ if (node.artifact) return node.artifact;
30
+ const { definition, descriptor } = node;
31
+ let artifact = await loadImageArtifact(descriptor);
32
+ if (artifact) report(`reusing local ${definition.image} artifact`);
33
+ else {
34
+ const metadata = node.metadata ?? await loadPackagedSnapshotMetadata(definition.image);
35
+ // A cache replacement or corruption must not substitute different bytes
36
+ // after a consumer has already been selected using this descriptor.
37
+ if (metadata.sha256 !== descriptor.sha256 || !imageInputsMatch(metadata.inputs, descriptor.inputs)) {
38
+ throw new Error(`${definition.image}: selected image artifact is no longer available; retry the rebuild`);
39
+ }
40
+ artifact = await describeImageArtifact(await packagedSystemSnapshot(metadata, signal),
41
+ definition.sha256, descriptor.inputs);
42
+ report(`reusing published ${definition.image} artifact`);
43
+ await saveImageArtifact(artifact, `/${definition.dollyfile}`);
44
+ }
45
+ node.artifact = artifact;
46
+ signal?.throwIfAborted();
47
+ return artifact;
48
+ }
49
+ async function resolve(reference) {
50
+ signal?.throwIfAborted();
51
+ if (artifacts.has(reference.sha256)) return artifacts.get(reference.sha256);
52
+ const definition = DOLLY_IMAGES.find(candidate =>
53
+ siteReference(candidate.dollyfile) === reference.location && candidate.sha256 === reference.sha256);
54
+ if (!definition) throw new Error(`${reference.location}: image pin is not present in this release`);
55
+ if (active.has(definition.image)) throw new Error(`image cycle at ${definition.image}`);
56
+ active.add(definition.image);
57
+ const dependencies = new Map();
58
+ for (const dependency of definition.artifacts) dependencies.set(dependency.sha256, await resolve(dependency));
59
+ const inputs = imageInputs([...dependencies.values()].map(node => node.descriptor));
60
+ let descriptor = await loadImageArtifactDescriptor(definition.sha256, inputs);
61
+ let metadata, artifact;
62
+ if (!descriptor) {
63
+ try {
64
+ metadata = await loadPackagedSnapshotMetadata(definition.image);
65
+ if (!imageInputsMatch(metadata.inputs, inputs)) throw new Error("published image inputs changed");
66
+ descriptor = { recipeSha256: definition.sha256, sha256: metadata.sha256,
67
+ byteLength: metadata.byteLength, inputs };
68
+ } catch {
69
+ report(`building missing ${definition.image} artifact`);
70
+ const loaded = [];
71
+ for (const dependency of dependencies.values()) loaded.push(await materialize(dependency));
72
+ artifact = await build(definition.image, loaded);
73
+ if (!imageInputsMatch(artifact.inputs, inputs)) throw new Error("built image inputs changed");
74
+ descriptor = artifact;
75
+ }
76
+ }
77
+ signal?.throwIfAborted();
78
+ active.delete(definition.image);
79
+ const node = { definition, descriptor, metadata, artifact };
80
+ artifacts.set(definition.sha256, node);
81
+ return node;
82
+ }
83
+ const references = image === "custom" ? (await customRecipeGraph(customSource, signal)).artifacts : imageDefinition(image).artifacts;
84
+ const selected = new Map();
85
+ for (const reference of references) selected.set(reference.sha256, await resolve(reference));
86
+ const loaded = [];
87
+ for (const node of selected.values()) loaded.push(await materialize(node));
88
+ return loaded;
89
+ }