@daugasauron/dolly-build 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 (36) hide show
  1. package/README.md +259 -6
  2. package/host/requirements.mjs +1 -1
  3. package/host/runtime/abi.mjs +41 -0
  4. package/package.json +10 -4
  5. package/packages.json +101 -0
  6. package/{src → page}/image-entry.mjs +10 -6
  7. package/{src → page}/image-inputs.mjs +11 -0
  8. package/{src → page}/image-names.mjs +2 -2
  9. package/page/sha256.mjs +65 -0
  10. package/{src → page}/snapshot-records.mjs +63 -44
  11. package/{src → page}/static-asset.mjs +36 -20
  12. package/{src → page}/version.mjs +1 -1
  13. package/scripts/dolly-build-init/Dollyfile +3 -7
  14. package/scripts/dolly-build-init/public/icon.svg +1 -0
  15. package/scripts/dolly-build-init/public/index.html +6 -58
  16. package/scripts/dolly-build-init/public/main.mjs +52 -0
  17. package/scripts/dolly-build-init/public/page.css +3 -0
  18. package/scripts/dolly-build-init/public/skills/changelog/SKILL.md +5 -0
  19. package/scripts/dolly-build-init/workflow.yml +42 -0
  20. package/scripts/dolly-build.mjs +194 -62
  21. package/scripts/dollyfile-graph.mjs +3 -3
  22. package/scripts/image-directory.mjs +6 -7
  23. package/scripts/page-image-build.mjs +29 -10
  24. package/scripts/recipe-files.mjs +3 -3
  25. package/scripts/release-layout.mjs +1 -1
  26. package/scripts/serve.mjs +40 -10
  27. package/scripts/sha256.mjs +4 -0
  28. package/scripts/share-pages-snapshots.mjs +10 -30
  29. package/scripts/snapshot-identity.mjs +1 -2
  30. package/scripts/system-snapshot-format.mjs +11 -9
  31. package/scripts/wasm-interface.mjs +2 -2
  32. package/{src → site}/dollyfile-graph.mjs +1 -1
  33. package/{src → site}/dollyfile-view.mjs +1 -1
  34. /package/{src → page}/process-abi.mjs +0 -0
  35. /package/{src → page}/wasm-interface.mjs +0 -0
  36. /package/{src → site}/alphabetical.mjs +0 -0
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- // An npm user's image: builds a Dollyfile FROM an installed image package, with
3
- // the packages it INSTALLs from their packages, in a browser with that browser's
4
- // Dolly runtime, into a site a static host serves as it lies (docs/embedding.md,
5
- // "Your own image").
6
- // npx dolly-build init NAME [PACKAGE…] writes Dollyfile-NAME FROM pi-minimal and public/ (the page)
2
+ // An npm user's image: builds a Dollyfile that INSTALLs installed packages, or starts
3
+ // FROM an installed image, in a browser with that browser's Dolly runtime, into a
4
+ // site a static host serves as it lies (docs/npm.md).
5
+ // npx dolly-build init NAME [PACKAGE…] writes Dollyfile-NAME, Pi's project, public/ (the page) and a CI workflow
6
+ // npx dolly-build add NAME [PACKAGE…] adds INSTALL rows to Dollyfile-NAME, and writes every row's installed pin
7
7
  // npx dolly-build Dollyfile-NAME OUT [--browser chrome|chromium|firefox]
8
- // OUT is the files of public/, workspace.json listing public/workspace/, the runtime
8
+ // init and add install with the project's package manager what they name and lack.
9
+ // OUT is the files of public/, workspace.json and skills.json listing public/workspace/
10
+ // and public/skills/, the runtime
9
11
  // (dolly/), the host modules the image declares (dolly-host-NAME/),
10
12
  // coi-serviceworker.js for hosts without isolation headers, and image/, the image's
11
13
  // directory as an image package lays it out (catalog, metadata, packs, recipes,
@@ -14,9 +16,11 @@
14
16
  // (pnpm links there what it keeps elsewhere); the runtime runs only in the browser.
15
17
  import { cp, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
16
18
  import { createHash } from "node:crypto";
19
+ import { spawnSync } from "node:child_process";
17
20
  import { existsSync } from "node:fs";
18
21
  import { createServer } from "node:http";
19
22
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
23
+ import { Readable } from "node:stream";
20
24
  import { pathToFileURL } from "node:url";
21
25
  import { parseArgs } from "node:util";
22
26
  import { chromium, firefox } from "playwright-core";
@@ -27,37 +31,99 @@ import { ensureSnapshotPacks } from "./share-pages-snapshots.mjs";
27
31
  import { verifySnapshotIdentity } from "./snapshot-identity.mjs";
28
32
  import { decodeSystemSnapshot } from "./system-snapshot-format.mjs";
29
33
  import { readWasmInterface } from "./wasm-interface.mjs";
30
- import { loadRecipeGraph } from "../src/dollyfile-graph.mjs";
31
- import { imageFileName, inspectDollyfile } from "../src/dollyfile-view.mjs";
32
- import { imageInputs } from "../src/image-inputs.mjs";
33
- import { validName } from "../src/image-names.mjs";
34
- import { siteReference, sitePath } from "../src/static-asset.mjs";
34
+ import { loadRecipeGraph } from "../site/dollyfile-graph.mjs";
35
+ import { imageFileName, inspectDollyfile, recipeFileName } from "../site/dollyfile-view.mjs";
36
+ import { imageInputs } from "../page/image-inputs.mjs";
37
+ import { validName } from "../page/image-names.mjs";
38
+ import { siteReference, sitePath } from "../page/static-asset.mjs";
39
+
40
+ // A build reaches the network through Node (relay below), and through the proxy the
41
+ // environment names, as a CI runner's often does, which Node's fetch takes only with
42
+ // NODE_USE_ENV_PROXY set: dolly-build runs itself again with it.
43
+ if (["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"].some(name => process.env[name]) && !process.env.NODE_USE_ENV_PROXY) {
44
+ const { status } = spawnSync(process.execPath, process.argv.slice(1), { stdio: "inherit",
45
+ env: { ...process.env, NODE_USE_ENV_PROXY: "1" } });
46
+ process.exit(status ?? 1);
47
+ }
35
48
 
36
49
  function fail(message) {
37
50
  console.error(`dolly-build: ${message}`);
38
51
  process.exit(1);
39
52
  }
40
- const scope = resolve("node_modules/@daugasauron");
41
- if (!existsSync(join(scope, "dolly"))) fail("run it where node_modules/@daugasauron/dolly is installed");
42
53
  const load = path => import(pathToFileURL(path).href);
43
- const { positionals, values: { browser: browserName } } = parseArgs({ allowPositionals: true,
44
- options: { browser: { type: "string", default: "chrome" } } });
54
+ const usage = `usage: dolly-build init NAME [PACKAGE...] a project: Pi with the packages named
55
+ dolly-build add NAME [PACKAGE...] Dollyfile-NAME with these packages too, every row at its installed pin
56
+ dolly-build Dollyfile-NAME OUT [--browser chrome|chromium|firefox] the site, built in a browser`;
57
+ const { positionals, values: { browser: browserName } } = (() => {
58
+ try { return parseArgs({ allowPositionals: true, options: { browser: { type: "string" } } }); } catch { fail(usage); }
59
+ })();
45
60
  // Firefox's system add-on updater resolves its host even behind a proxy.
46
61
  const browsers = {
47
62
  chrome: options => chromium.launch({ channel: "chrome", headless: true, ...options }),
48
63
  chromium: options => chromium.launch({ headless: true, ...options }),
49
64
  firefox: options => firefox.launch({ headless: true, firefoxUserPrefs: { "extensions.systemAddon.update.enabled": false }, ...options }),
50
65
  };
51
- const usage = "usage: dolly-build init NAME [PACKAGE...] | Dollyfile-NAME OUT [--browser chrome|chromium|firefox]";
52
- const init = positionals[0] === "init";
53
- if (!browsers[browserName] || (init ? positionals.length < 2 : positionals.length !== 2)) fail(usage);
66
+ // Without --browser: the first of them this machine has.
67
+ async function launch(options) {
68
+ for (const name of browserName ? [browserName] : Object.keys(browsers)) {
69
+ try { return await browsers[name](options); } catch (error) {
70
+ if (!/is not found at|Executable doesn't exist/.test(error.message)) throw error;
71
+ }
72
+ }
73
+ fail(`${browserName ?? "a browser"} is not installed: install Chrome, or Playwright's Chromium with ` +
74
+ "npx playwright-core install chromium-headless-shell");
75
+ }
76
+ const project = ["init", "add"].includes(positionals[0]);
77
+ if ((browserName && !browsers[browserName]) || (project ? positionals.length < 2 : positionals.length !== 2)) fail(usage);
78
+ const scope = resolve("node_modules/@daugasauron"), beside = resolve(import.meta.dirname, "../..");
79
+ const hostPackage = name => `@daugasauron/dolly-host-${name === "download" ? "save" : name}`;
80
+ // Pi's packages, which init's recipe installs: those dolly-build depends on, pi-minimal's
81
+ // (scripts/package-npm.mjs), in its order.
82
+ const own = JSON.parse(await readFile(join(import.meta.dirname, "../package.json"), "utf8"));
83
+ const piPackages = Object.keys(own.dependencies ?? {})
84
+ .filter(dependency => dependency.startsWith("@daugasauron/dolly-package-")).map(dependency => dependency.replace("@daugasauron/dolly-package-", ""));
85
+
86
+ // init and add install what they name and the project lacks, at dolly-build's version, with
87
+ // the package manager that runs them: the runtime, each PACKAGE, and the device host modules
88
+ // they declare (init: Pi's too, and the keyboard's), which packages.json lists for every
89
+ // published package and image (scripts/package-npm.mjs). add also installs at that version
90
+ // each Dolly package the project depends on and has at another, as after dolly-build's
91
+ // update, and the host modules of the packages its recipe installs and of the image it
92
+ // starts FROM, which a new version may add to.
93
+ if (project) {
94
+ const { packages: published, images } = JSON.parse(await readFile(join(import.meta.dirname, "../packages.json"), "utf8"));
95
+ const named = positionals.slice(2), unknown = named.filter(name => !published[name]);
96
+ if (!validName(positionals[1])) fail(`${positionals[1]}: a name is lowercase letters, digits and dashes, at most 32 bytes`);
97
+ if (unknown.length) fail(`${unknown.join(", ")}: no such package. The packages: ${Object.keys(published).sort().join(", ")}`);
98
+ const recipePackages = positionals[0] === "init" ? [] : (await readFile(`Dollyfile-${positionals[1]}`, "utf8").catch(() => ""))
99
+ .split("\n").map(line => /^(?:FROM|INSTALL)\s+(\S+)/.exec(line)?.[1]).filter(Boolean).map(location => imageFileName(recipeFileName(location)));
100
+ const hosts = [...positionals[0] === "init" ? ["input", ...piPackages.flatMap(name => published[name])] : [],
101
+ ...[...named, ...recipePackages].flatMap(name => published[name] ?? images[name] ?? [])];
102
+ // The project's own Dolly packages that are installed at another version.
103
+ const manifest = async directory => JSON.parse(await readFile(join(directory, "package.json"), "utf8").catch(() => "{}"));
104
+ const stale = [];
105
+ for (const name of positionals[0] === "add" ? Object.keys((await manifest(".")).dependencies ?? {}).filter(name => name.startsWith("@daugasauron/dolly")) : []) {
106
+ if ((await manifest(join(scope, basename(name)))).version !== own.version) stale.push(name);
107
+ }
108
+ const missing = [...new Set([...stale,
109
+ ...["@daugasauron/dolly", ...hosts.map(hostPackage)].filter(name => !existsSync(join(scope, basename(name)))),
110
+ ...named.map(name => `@daugasauron/dolly-package-${name}`).filter(name => ![scope, beside].some(parent => existsSync(join(parent, basename(name)))))])];
111
+ if (missing.length) {
112
+ const manager = /^(pnpm|yarn)\//.exec(process.env.npm_config_user_agent ?? "")?.[1] ?? "npm";
113
+ const command = [manager, manager === "npm" ? "install" : "add", manager === "yarn" ? "--exact" : "--save-exact",
114
+ ...missing.map(name => `${name}@${own.version}`)];
115
+ console.log(`dolly-build: ${command.join(" ")}`);
116
+ if (spawnSync(command[0], command.slice(1), { stdio: "inherit" }).status !== 0) fail("that install failed: run it, then this command again");
117
+ }
118
+ }
119
+ if (!existsSync(join(scope, "dolly"))) fail("run it where node_modules/@daugasauron/dolly is installed");
54
120
 
55
121
  // The installed image and package directories (dolly-image-NAME, dolly-package-NAME):
56
122
  // each holds its own image's packs, and the catalog and metadata of that image's
57
123
  // closure. npm installs the packages a package installs beside it, pnpm beside its
58
- // real path.
124
+ // real path, and those dolly-build depends on beside dolly-build's.
59
125
  const directories = [];
60
- for (const pending = [scope]; pending.length;) {
126
+ for (const pending = [scope, beside]; pending.length;) {
61
127
  const parent = pending.pop();
62
128
  for (const name of (await readdir(parent)).filter(name => /^dolly-(image|package)-/.test(name))) {
63
129
  const directory = await realpath(join(parent, name));
@@ -83,52 +149,79 @@ for (const name of await readdir(join(scope, "dolly/host"))) {
83
149
  for (const name of (await readdir(scope)).filter(name => name.startsWith("dolly-host-"))) {
84
150
  modules.set(JSON.parse(await readFile(join(scope, name, "module.json"), "utf8")).name, `${name}/module.json`);
85
151
  }
86
- const hostPackage = name => `@daugasauron/dolly-host-${name === "download" ? "save" : name}`;
87
152
 
88
- try {
89
- if (init) await scaffold(positionals[1], positionals.slice(2));
90
- else await build(...positionals.map(path => resolve(path)));
91
- } catch (error) {
92
- fail(error.message);
93
- }
94
-
95
- // A project: the recipe (FROM pi-minimal at its pin, INSTALL of each PACKAGE,
96
- // the template's rows, the base's ENTRY) and public/, the page's files: the page,
97
- // its theme and the workspace.
153
+ // A project: the recipe (INSTALL of Pi's packages and of each PACKAGE at the installed
154
+ // pins, then the template's rows and ENTRY), public/, the page's files (the page, its
155
+ // theme, the workspace and a skill), and the workflow that builds the site in CI.
156
+ const installRow = definition => `INSTALL ${siteReference(definition.dollyfile)} ${definition.sha256}`;
98
157
  async function scaffold(name, packages) {
99
- if (!validName(name)) fail(`${name}: a name is lowercase letters, digits and dashes, at most 32 bytes`);
100
- const base = definitionOf(installed("pi-minimal", "image"));
101
- const installs = packages.map(image => definitionOf(installed(image, "package")));
102
- // snapshot@0 is the page's Save: a save is kept in the browser, and the page opens it again.
103
- const hosts = [...new Set([base, ...installs].flatMap(definition => definition.hostRequirements).concat("snapshot@0"))].sort();
104
- const row = (operation, definition) => `${operation} ${siteReference(definition.dollyfile)} ${definition.sha256}\n`;
158
+ const installs = [...new Set([...piPackages, ...packages])].map(image => definitionOf(installed(image, "package")));
159
+ // input@0 is the terminal's keyboard; snapshot@0 the page's Save: a save is kept in the
160
+ // browser, and the page opens it again.
161
+ const hosts = [...new Set(installs.flatMap(definition => definition.hostRequirements).concat("input@0", "snapshot@0"))].sort();
105
162
  const template = async file => (await readFile(join(import.meta.dirname, "dolly-build-init", file), "utf8")).replaceAll("__NAME__", name);
163
+ const pages = (await readdir(join(import.meta.dirname, "dolly-build-init/public"), { recursive: true, withFileTypes: true }))
164
+ .filter(entry => entry.isFile()).map(entry => relative(join(import.meta.dirname, "dolly-build-init"), join(entry.parentPath, entry.name)).split(sep).join("/")).sort();
106
165
  const files = {
107
166
  [`Dollyfile-${name}`]: `DOLLY 7\nAPPLICATION ${name}\n${hosts.map(host => `REQUIRES HOST ${host}\n`).join("")}\n` +
108
- row("FROM", base) + installs.map(definition => row("INSTALL", definition)).join("") + "\n" + await template("Dollyfile") +
109
- `\nENTRY ${base.entry.join(" ")}\n`,
110
- "public/index.html": await template("public/index.html"),
111
- "public/theme.json": await template("public/theme.json"),
112
- "public/workspace/README.md": await template("public/workspace/README.md"),
167
+ installs.map(definition => `${installRow(definition)}\n`).join("") + "\n" + await template("Dollyfile"),
168
+ ...Object.fromEntries(await Promise.all(pages.map(async file => [file, await template(file)]))),
169
+ [`.github/workflows/${name}.yml`]: await template("workflow.yml"),
113
170
  };
114
171
  const present = Object.keys(files).filter(file => existsSync(file));
115
172
  if (present.length) fail(`${present.join(", ")}: already here`);
116
- await mkdir("public/workspace", { recursive: true });
117
- for (const [file, text] of Object.entries(files)) await writeFile(file, text);
173
+ for (const [file, text] of Object.entries(files)) {
174
+ await mkdir(dirname(file), { recursive: true });
175
+ await writeFile(file, text);
176
+ }
118
177
  // The site and node_modules are built, not kept: a .gitignore, unless the project has one.
119
178
  if (!existsSync(".gitignore")) await writeFile(".gitignore", `node_modules/\n${name}/\n`);
120
- const missing = hosts.map(host => host.split("@")[0]).filter(name => !modules.has(name));
121
179
  console.log(`dolly-build: wrote ${Object.keys(files).join(", ")}\n` +
122
- (missing.length ? ` npm install ${missing.map(hostPackage).join(" ")} the recipe declares ${missing.join(", ")}\n` : "") +
123
180
  ` npx dolly-build Dollyfile-${name} ${name} builds the image and the site, ${name}/ (again after a change)\n` +
124
181
  ` python3 -m http.server -d ${name} -b 127.0.0.1 8080 serves it, as any static host does`);
125
182
  }
126
183
 
184
+ // Dollyfile-NAME with every FROM and INSTALL row at the pin of the installed package that
185
+ // holds its recipe, as after an update of the packages, and a row for each PACKAGE it
186
+ // lacks, with the host modules those, the packages it installs and the image it starts FROM
187
+ // declare at their new pins: FROM carries a base's files, not its host modules.
188
+ async function add(name, packages) {
189
+ const file = `Dollyfile-${name}`, lines = (await readFile(file, "utf8").catch(() =>
190
+ fail(`${file}: no such recipe here (dolly-build init ${name} writes one)`))).split("\n");
191
+ const held = new Set(), rows = [], repeated = [];
192
+ let repinned = 0;
193
+ for (const [index, line] of lines.entries()) {
194
+ const [, operation, location, pin] = /^(FROM|INSTALL)\s+(\S+)\s+([0-9a-f]{64})\s*$/.exec(line) ?? [];
195
+ if (!operation) continue;
196
+ rows.push(index);
197
+ const directory = directories.find(candidate => candidate.image === imageFileName(recipeFileName(location)));
198
+ if (!directory) continue;
199
+ held.add(directory.image);
200
+ const definition = definitionOf(directory);
201
+ repeated.push(definition);
202
+ if (definition.sha256 !== pin) repinned++;
203
+ lines[index] = `${operation} ${siteReference(definition.dollyfile)} ${definition.sha256}`;
204
+ }
205
+ if (!rows.length) fail(`${file}: no FROM or INSTALL row to add after`);
206
+ const added = [...new Set(packages)].filter(image => !held.has(image)).map(image => definitionOf(installed(image, "package")));
207
+ lines.splice(rows.at(-1) + 1, 0, ...added.map(installRow));
208
+ const declared = new Set(lines.map(line => /^REQUIRES HOST (\S+)/.exec(line)?.[1]));
209
+ const hosts = [...new Set([...repeated, ...added].flatMap(definition => definition.hostRequirements))].filter(host => !declared.has(host));
210
+ lines.splice(lines.findLastIndex(line => line.startsWith("REQUIRES HOST ")) + 1, 0, ...hosts.map(host => `REQUIRES HOST ${host}`));
211
+ await writeFile(file, lines.join("\n"));
212
+ console.log(`dolly-build: ${file}: ${added.length ? `added ${added.map(definition => definition.image).join(", ")}` : "no package added"}` +
213
+ `${hosts.length ? ` (${hosts.join(", ")})` : ""}; rows at a new pin: ${repinned}\n` +
214
+ ` npx dolly-build ${file} ${name} builds it`);
215
+ }
216
+
127
217
  async function build(recipePath, outArgument) {
128
218
  const filename = basename(recipePath), source = await readFile(recipePath, "utf8");
129
219
  const parsed = inspectDollyfile(source, filename), image = parsed.image;
130
220
  if (imageFileName(filename) !== image) fail(`${filename}: name it Dollyfile-${image}`);
131
- if (!parsed.from) fail(`${filename}: FROM an installed image`);
221
+ // The build runs the recipe engine of its base, or of the dollyfile package it installs.
222
+ const engine = parsed.artifacts.some(({ operation, location }) => operation === "install" &&
223
+ imageFileName(recipeFileName(location)) === "dollyfile");
224
+ if (!parsed.from && !engine) fail(`${filename}: FROM an installed image, or INSTALL the dollyfile package`);
132
225
  if (!existsSync("public/index.html")) fail("public/index.html: the page of the site (dolly-build init writes one)");
133
226
  // A rebuild replaces the site it wrote before, and nothing else.
134
227
  const previous = join(outArgument, "image");
@@ -137,7 +230,7 @@ async function build(recipePath, outArgument) {
137
230
  }
138
231
  // Every row that names an image names one of the installed directories at its pin.
139
232
  const holders = parsed.artifacts.map(reference => holder(reference) ?? fail(`${filename}:${reference.line}: no installed ` +
140
- `package holds ${reference.location} at that pin (dolly-build init writes the rows of the installed ones)`));
233
+ `package holds ${reference.location} at that pin (dolly-build add ${image} writes the installed pins)`));
141
234
  // One catalog of their closures: the same image at the same pin in each.
142
235
  const records = new Map(), staticSources = new Map(), first = new Map();
143
236
  for (const directory of holders) {
@@ -164,11 +257,10 @@ async function build(recipePath, outArgument) {
164
257
  // modules the image declares, as their packages hold them, and the worker
165
258
  // that isolates the page where the host sends no headers.
166
259
  await cp("public", staging, { recursive: true });
167
- const workspace = join(staging, "workspace");
168
- if (existsSync(workspace)) {
169
- const files = (await readdir(workspace, { recursive: true, withFileTypes: true })).filter(entry => entry.isFile())
170
- .map(entry => relative(workspace, join(entry.parentPath, entry.name)).split(sep).join("/")).sort();
171
- await writeFile(join(staging, "workspace.json"), `${JSON.stringify(files)}\n`);
260
+ for (const tree of ["workspace", "skills"].filter(tree => existsSync(join(staging, tree)))) {
261
+ const files = (await readdir(join(staging, tree), { recursive: true, withFileTypes: true })).filter(entry => entry.isFile())
262
+ .map(entry => relative(join(staging, tree), join(entry.parentPath, entry.name)).split(sep).join("/")).sort();
263
+ await writeFile(join(staging, `${tree}.json`), `${JSON.stringify(files)}\n`);
172
264
  }
173
265
  for (const name of new Set(["dolly", ...declared.map(module => modules.get(module).split("/")[0])])) {
174
266
  await cp(join(scope, name), join(staging, name), { recursive: true, dereference: true });
@@ -194,6 +286,36 @@ async function built(directory, image, source, identities) {
194
286
  metadata?.buildId === identities.imageBuildId && metadata?.runtimeBuildId === identities.buildId;
195
287
  }
196
288
 
289
+ // A request of the build's, which the page's broker admitted, fetched by Node: a host
290
+ // without CORS headers, a redirect between hosts and the environment's proxy (above)
291
+ // work as on any machine. /relay/SCHEME/HOST/PATH?QUERY names it; a build's steps reach
292
+ // any host, as a session's programs do on Dolly's site, and each host is named once.
293
+ const reached = new Set();
294
+ const unsent = new Set(["host", "connection", "origin", "referer", "cookie", "accept-encoding", "content-length"]);
295
+ const unreturned = new Set(["connection", "content-encoding", "content-length", "transfer-encoding", "set-cookie"]);
296
+ async function relay(request, response, url) {
297
+ const [scheme, ...rest] = url.pathname.slice("/relay/".length).split("/");
298
+ if (!["http", "https"].includes(scheme) || !rest.length) return response.writeHead(400).end();
299
+ const target = new URL(`${scheme}://${rest.join("/")}${url.search}`);
300
+ if (!reached.has(target.origin)) {
301
+ reached.add(target.origin);
302
+ process.stderr.write(`dolly-build: the build reaches ${target.origin}\n`);
303
+ }
304
+ const headers = Object.entries(request.headers).filter(([name]) => !unsent.has(name) && !name.startsWith("sec-"));
305
+ const chunks = [];
306
+ for await (const chunk of request) chunks.push(chunk);
307
+ try {
308
+ const answer = await fetch(target, { method: request.method, headers, redirect: "follow",
309
+ body: ["GET", "HEAD"].includes(request.method) ? undefined : Buffer.concat(chunks) });
310
+ // x-dolly-relay-url: where the redirects followed ended, for the page (page-image-build.mjs).
311
+ response.writeHead(answer.status, { ...Object.fromEntries([...answer.headers].filter(([name]) => !unreturned.has(name))),
312
+ ...isolationHeaders, "x-dolly-relay-url": answer.url });
313
+ if (answer.body) Readable.fromWeb(answer.body).pipe(response); else response.end();
314
+ } catch (error) {
315
+ response.writeHead(502, { "content-type": "text/plain" }).end(`${target.origin}: ${error.cause?.message ?? error.message}\n`);
316
+ }
317
+ }
318
+
197
319
  // The image directory, laid out as an image package: the closures' metadata and
198
320
  // recipes, this recipe beside them, the image's metadata and packs, its package
199
321
  // index and its index.mjs. Every installed directory's packs are served for the
@@ -216,7 +338,9 @@ async function buildImage(directory, { filename, source, image, parsed, holders,
216
338
  const roots = [["/", scope], ["/image/", directory],
217
339
  ...directories.map(installed => ["/image/dist/packs/", join(installed.directory, "dist/packs")])];
218
340
  const server = createServer(async (request, response) => {
219
- const path = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
341
+ const url = new URL(request.url, "http://127.0.0.1");
342
+ if (url.pathname.startsWith("/relay/")) return relay(request, response, url);
343
+ const path = decodeURIComponent(url.pathname);
220
344
  if (path === "/") return response.writeHead(200, { ...isolationHeaders, "content-type": "text/html" }).end(page);
221
345
  for (const [prefix, root] of roots.toReversed().filter(([prefix]) => path.startsWith(prefix))) {
222
346
  const file = resolve(root, path.slice(prefix.length));
@@ -228,25 +352,25 @@ async function buildImage(directory, { filename, source, image, parsed, holders,
228
352
  response.writeHead(404).end();
229
353
  });
230
354
  // The browser's own requests (its vendor's updates, clock, autofill) go to a proxy that
231
- // refuses them: it reaches this server and the recipes' sources alone.
355
+ // refuses them: the page reaches this server alone, the build's network through relay().
232
356
  const refusal = createServer((request, response) => response.writeHead(403).end()).on("connect", (request, socket) => socket.destroy());
233
357
  await Promise.all([server, refusal].map(listener => new Promise(listening => listener.listen(0, "127.0.0.1", listening))));
234
358
  const origin = `http://127.0.0.1:${server.address().port}`;
235
- // The recipes' own upstream URLs are the only network the build may use.
236
- const rules = graph.records.flatMap(record => record.sources.filter(entry => sitePath(entry.location) === null))
237
- .map(entry => ({ origin: new URL(entry.location).origin, path: new URL(entry.location).pathname, methods: ["GET"] }));
238
- const browser = await browsers[browserName]({ proxy: { server: `http://127.0.0.1:${refusal.address().port}`,
239
- bypass: [...new Set(["127.0.0.1", ...rules.map(rule => new URL(rule.origin).hostname)])].join(",") } });
359
+ // What the build asks of this server through the relay is not a host it reaches.
360
+ reached.add(origin);
361
+ // The environment's proxy is the relay's alone: Firefox takes HTTP_PROXY from its
362
+ // environment over the proxy it is given and sent its page's own address there.
363
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^(?:https?|all|no)_proxy$/i.test(name)));
364
+ const browser = await launch({ env, proxy: { server: `http://127.0.0.1:${refusal.address().port}`, bypass: "127.0.0.1" } });
240
365
  let inputs;
241
366
  try {
242
367
  const tab = await browser.newPage();
243
368
  tab.setDefaultTimeout(0);
244
369
  await tab.exposeFunction("dollyBuildLog", text => void process.stderr.write(text));
245
- await tab.addInitScript(rules => { globalThis.DOLLY_HTTP_POLICY = { maxRequests: 256, rules }; }, rules);
246
370
  await tab.goto(`${origin}/`);
247
371
  ({ inputs } = await tab.evaluate(`(${buildImageInPage})(${JSON.stringify(`${origin}/dolly/`)}, ` +
248
372
  `${JSON.stringify(`${origin}/image/`)}, ${JSON.stringify(image)}, undefined, ` +
249
- `${JSON.stringify([...modules.values()].map(path => `${origin}/${path}`))})`));
373
+ `${JSON.stringify([...modules.values()].map(path => `${origin}/${path}`))}, ${JSON.stringify(`${origin}/relay/`)})`));
250
374
  await savePageSnapshot(tab, join(directory, `dist/dolly-${image}-system.snapshot`));
251
375
  } finally {
252
376
  await browser.close();
@@ -276,3 +400,11 @@ async function buildImage(directory, { filename, source, image, parsed, holders,
276
400
  await writeFile(join(directory, "amy-index.txt"), amyIndex(catalog.filter(record => held.includes(record.image)), descriptions));
277
401
  await writeFile(join(directory, "index.mjs"), indexSource(image, declared.map(name => `../${modules.get(name)}`)));
278
402
  }
403
+
404
+ // Last, once every constant above is initialized: the relay reads them during a build.
405
+ try {
406
+ if (project) await (positionals[0] === "init" ? scaffold : add)(positionals[1], positionals.slice(2));
407
+ else await build(...positionals.map(path => resolve(path)));
408
+ } catch (error) {
409
+ fail(error.message);
410
+ }
@@ -1,9 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
- import { loadRecipeGraph } from "../src/dollyfile-graph.mjs";
4
- import { recipeFileName } from "../src/dollyfile-view.mjs";
3
+ import { loadRecipeGraph } from "../site/dollyfile-graph.mjs";
4
+ import { recipeFileName } from "../site/dollyfile-view.mjs";
5
5
  import { recipeFiles } from "./recipe-files.mjs";
6
- import { siteReference } from "../src/static-asset.mjs";
6
+ import { siteReference } from "../page/static-asset.mjs";
7
7
 
8
8
  // Loads catalog recipe graphs from a project directory, sharing parsed recipes.
9
9
  export function createDollyfileGraphLoader(projectDir) {
@@ -1,16 +1,15 @@
1
- import { createHash } from "node:crypto";
1
+ import { sha256 } from "./sha256.mjs";
2
2
  import { recipeRecords } from "./dollyfile-graph.mjs";
3
- import { alphabetical } from "../src/alphabetical.mjs";
4
- import { siteReference } from "../src/static-asset.mjs";
3
+ import { alphabetical } from "../site/alphabetical.mjs";
4
+ import { siteReference } from "../page/static-asset.mjs";
5
+ import { recipesDigest } from "../page/image-inputs.mjs";
5
6
 
6
- // The generated files of an image directory (src/images.mjs): its catalog,
7
+ // The generated files of an image directory (page/images.mjs): its catalog,
7
8
  // dist/dolly-images.json, each image's metadata, dist/dolly-IMAGE-system-snapshot.json,
8
9
  // its package index amy-index.txt, and the index.mjs of a directory boot() loads.
9
10
  // The catalog's build, the npm packages and dolly-build (an npm user's image)
10
11
  // write them here.
11
12
 
12
- const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
13
-
14
13
  // An image's catalog record: its recipe, at FILENAME in the directory, and the
15
14
  // recipe graph it roots.
16
15
  export function catalogRecord({ image, filename, source, parsed }, graph) {
@@ -21,7 +20,7 @@ export function catalogRecord({ image, filename, source, parsed }, graph) {
21
20
  dollyfile: filename,
22
21
  byteLength: Buffer.byteLength(source),
23
22
  sha256: sha256(source),
24
- recipes: recipeRecords(graph),
23
+ recipesSha256: recipesDigest(recipeRecords(graph)),
25
24
  artifacts: graph.artifacts,
26
25
  hostRequirements: graph.root.hostRequirements,
27
26
  };
@@ -1,22 +1,36 @@
1
1
  import { open, rm } from "node:fs/promises";
2
- import { MAX_SNAPSHOT_BYTES } from "../src/snapshot-records.mjs";
2
+ import { MAX_SNAPSHOT_BYTES } from "../page/snapshot-records.mjs";
3
3
 
4
4
  // Runs in a page (pass its source to page.evaluate): builds IMAGE of the image
5
5
  // directory at IMAGES, or "custom" from CUSTOM, after its missing dependencies,
6
6
  // with the builder and HTTP broker of the runtime at RUNTIME and the host
7
7
  // modules whose module.json URLs are MODULES; on Dolly's site both directories
8
- // are the site and the modules every one it builds (host/manifests.mjs). Logs
8
+ // are the site and the modules every one it builds (host/manifests.mjs). The
9
+ // broker admits what POLICY admits (docs/http.md), without one any URL. With
10
+ // RELAY, a URL prefix of the page's own origin, the broker fetches each remote
11
+ // request it admits from RELAY + scheme/host/path?query instead. Logs
9
12
  // through the exposed dollyBuildLog(text), keeps the snapshot's Blob in
10
13
  // globalThis.dollySnapshot and returns { inputs, dependenciesBuilt }.
11
- export async function buildImageInPage(runtime, images, image, custom, modules) {
14
+ export async function buildImageInPage(runtime, images, image, custom, modules, relay, policy) {
15
+ // What the runtime needs of the browser, said before anything is built.
16
+ if (!crossOriginIsolated) throw new Error("the build page is not cross-origin isolated");
17
+ if (!WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 5, 3, 1, 4, 1]))) {
18
+ throw new Error("this browser has no WebAssembly memory64, which Dolly needs (Chrome 138, Firefox 146 or later)");
19
+ }
12
20
  const log = text => void globalThis.dollyBuildLog(text);
13
- const [registry, policy, transport, builder, graph] = await Promise.all([
14
- "src/images.mjs", "host/http/policy.mjs", "host/http/local-services.mjs",
15
- "src/image-builder.mjs", "src/image-build.mjs",
21
+ const [registry, http, transport, builder, graph] = await Promise.all([
22
+ "page/images.mjs", "host/http/policy.mjs", "host/http/local-services.mjs",
23
+ "site/image-builder.mjs", "site/image-build.mjs",
16
24
  ].map(path => import(new URL(path, runtime).href)));
17
25
  await registry.loadImages(images);
18
- const network = { ...transport.localServicesTransport(new policy.DollyHttpPolicy(globalThis.DOLLY_HTTP_POLICY,
19
- registry.bootstrapSources(), new URL(images))), site: images };
26
+ // A relayed answer is from the URL the relay reached, after its redirects: a program
27
+ // resolves what it links against that, as without a relay, never against the relay's.
28
+ const remoteFetch = relay && (async (url, init) => {
29
+ const response = await fetch(`${relay}${url.protocol.slice(0, -1)}/${url.host}${url.pathname}${url.search}`, init);
30
+ return Object.defineProperty(response, "url", { value: response.headers.get("x-dolly-relay-url") ?? url.href });
31
+ });
32
+ const network = { ...transport.localServicesTransport(new http.DollyHttpPolicy(policy,
33
+ registry.bootstrapSources(), new URL(images)), {}, remoteFetch), site: images };
20
34
  modules ??= (await import(new URL("host/manifests.mjs", runtime).href)).hostModuleUrls;
21
35
  const build = (name, artifacts) => builder.buildImage(name, artifacts, { http: { network } }, log,
22
36
  { modules, customSource: name === "custom" ? custom : undefined });
@@ -39,8 +53,13 @@ export async function savePageSnapshot(page, output) {
39
53
  const file = await open(output, "wx");
40
54
  try {
41
55
  for (let offset = 0; offset < length; offset += piece) {
42
- const bytes = Buffer.from(await page.evaluate(async ([offset, piece]) =>
43
- new Uint8Array(await globalThis.dollySnapshot.slice(offset, offset + piece).arrayBuffer()).toBase64(), [offset, piece]), "base64");
56
+ // A data URL's base64, which every browser the runtime runs in makes.
57
+ const bytes = Buffer.from(await page.evaluate(([offset, piece]) => new Promise((resolve, reject) => {
58
+ const reader = new FileReader();
59
+ reader.onload = () => resolve(reader.result.slice(reader.result.indexOf(",") + 1));
60
+ reader.onerror = () => reject(reader.error);
61
+ reader.readAsDataURL(globalThis.dollySnapshot.slice(offset, offset + piece));
62
+ }), [offset, piece]), "base64");
44
63
  await file.write(bytes, 0, bytes.length, offset);
45
64
  }
46
65
  } catch (error) {
@@ -1,8 +1,8 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
- import { siteReference } from "../src/static-asset.mjs";
4
- import { imageFileName } from "../src/dollyfile-view.mjs";
5
- import { validName } from "../src/image-names.mjs";
3
+ import { siteReference } from "../page/static-asset.mjs";
4
+ import { imageFileName } from "../site/dollyfile-view.mjs";
5
+ import { validName } from "../page/image-names.mjs";
6
6
 
7
7
  // Recipes are published at their checkout path: core images at the top
8
8
  // level, each demo's in demos/DEMO/. Returns site reference ->
@@ -1,7 +1,7 @@
1
1
  // A published version's directory and public path: /vX.Y.Z/.
2
2
  export const versionName = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
3
3
 
4
- // The version a packaged site carries in its src/version.mjs, read as data.
4
+ // The version a packaged site carries in its page/version.mjs, read as data.
5
5
  export function releaseVersion(source) {
6
6
  const name = `v${/^export const DOLLY_VERSION = "([^"]*)";$/m.exec(source)?.[1]}`;
7
7
  if (!versionName.test(name)) throw new Error("the release carries no version");
package/scripts/serve.mjs CHANGED
@@ -4,11 +4,12 @@ import { readFile, readlink } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { basename, extname, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { sha256 } from "./snapshot-identity.mjs";
7
+ import { sha256 } from "./sha256.mjs";
8
8
  import { releaseVersion, robotsText } from "./release-layout.mjs";
9
9
 
10
10
  export const mimeTypes = new Map([
11
11
  [".html", "text/html; charset=utf-8"],
12
+ [".css", "text/css; charset=utf-8"],
12
13
  [".md", "text/markdown; charset=utf-8"],
13
14
  [".txt", "text/plain; charset=utf-8"],
14
15
  [".wat", "text/plain; charset=utf-8"],
@@ -22,6 +23,8 @@ export const mimeTypes = new Map([
22
23
  [".js", "text/javascript; charset=utf-8"],
23
24
  [".mjs", "text/javascript; charset=utf-8"],
24
25
  [".wasm", "application/wasm"],
26
+ [".woff", "font/woff"],
27
+ [".svg", "image/svg+xml"],
25
28
  [".woff2", "font/woff2"],
26
29
  [".png", "image/png"],
27
30
  [".mp4", "video/mp4"],
@@ -33,12 +36,19 @@ export const isolationHeaders = {
33
36
  "cache-control": "no-store",
34
37
  };
35
38
  const releaseDigest = /^[0-9a-f]{64}$/;
39
+ // The site's Content-Security-Policy, as Pages and this server send it: the
40
+ // strict policy of docs/embedding.md, with the inline styles of its menu and
41
+ // lists, and every destination its default HTTP policy admits.
42
+ export const siteContentSecurityPolicy = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' blob:; " +
43
+ "worker-src 'self' blob:; img-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src *; " +
44
+ "object-src 'none'; base-uri 'none'";
36
45
 
37
46
  // Serves RELEASES/current as it would be deployed: / leads to its version, the
38
47
  // release's files are under /vX.Y.Z/, robots.txt speaks for that version, and
39
- // every other path is 404. Nothing is cacheable: a candidate keeps its version
40
- // while it changes. dist/ and the source checkout are build inputs, never the
41
- // running app.
48
+ // every other path is 404. A candidate keeps its version while it changes, so
49
+ // a file is cached only as its digest: the browser asks again each time and
50
+ // keeps what it has while the digest is the same (ETag). dist/ and the source
51
+ // checkout are build inputs, never the running app.
42
52
  export function createReleaseServer(releases) {
43
53
  let current;
44
54
  async function release() {
@@ -55,8 +65,8 @@ export function createReleaseServer(releases) {
55
65
  files.has(match[2])) throw new Error("invalid release file manifest");
56
66
  files.set(match[2], match[1]);
57
67
  }
58
- const version = await readFile(resolve(releases, digest, "src/version.mjs"));
59
- if (sha256(version) !== files.get("src/version.mjs")) throw new Error("published file changed");
68
+ const version = await readFile(resolve(releases, digest, "page/version.mjs"));
69
+ if (sha256(version) !== files.get("page/version.mjs")) throw new Error("published file changed");
60
70
  return current = { digest, files, base: `/${releaseVersion(version.toString())}/` };
61
71
  }
62
72
  return createServer(async (request, response) => {
@@ -84,13 +94,33 @@ export function createReleaseServer(releases) {
84
94
  response.writeHead(308, { ...isolationHeaders, location: `${path}/` }).end();
85
95
  return;
86
96
  }
97
+ const etag = `"${files.get(file)}"`;
98
+ if (request.headers["if-none-match"] === etag) {
99
+ response.writeHead(304, { ...isolationHeaders, "cache-control": "no-cache", etag }).end();
100
+ return;
101
+ }
87
102
  const body = await readFile(resolve(releases, digest, file));
88
103
  if (sha256(body) !== files.get(file)) throw new Error("published file changed");
89
- response.writeHead(200, {
90
- ...isolationHeaders,
104
+ const headers = {
105
+ ...isolationHeaders, "cache-control": "no-cache", etag, "content-security-policy": siteContentSecurityPolicy,
91
106
  "content-type": /^Dollyfile(?:-|$)/.test(basename(file)) ? "text/plain; charset=utf-8" :
92
- mimeTypes.get(extname(file)) ?? "application/octet-stream",
93
- });
107
+ mimeTypes.get(extname(file)) ?? "application/octet-stream", "accept-ranges": "bytes",
108
+ };
109
+ // A video plays from ranges, as the hosts of a deployment serve them.
110
+ const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range ?? "");
111
+ if (range && (range[1] || range[2])) {
112
+ const start = range[1] ? Number(range[1]) : Math.max(0, body.length - Number(range[2]));
113
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), body.length - 1) : body.length - 1;
114
+ if (start > end) {
115
+ response.writeHead(416, { ...isolationHeaders, "content-range": `bytes */${body.length}` }).end();
116
+ return;
117
+ }
118
+ response.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${body.length}`,
119
+ "content-length": end + 1 - start });
120
+ response.end(request.method === "HEAD" ? undefined : body.subarray(start, end + 1));
121
+ return;
122
+ }
123
+ response.writeHead(200, { ...headers, "content-length": body.length });
94
124
  response.end(request.method === "HEAD" ? undefined : body);
95
125
  } catch {
96
126
  response.writeHead(404, isolationHeaders).end("not found");
@@ -0,0 +1,4 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ // The lowercase hex SHA-256 of bytes or text, as recipes and records pin them.
4
+ export const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");