@daugasauron/dolly-build 0.1.5 → 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.
- package/README.md +259 -6
- package/host/requirements.mjs +1 -1
- package/host/runtime/abi.mjs +41 -0
- package/package.json +10 -4
- package/packages.json +101 -0
- package/{src → page}/image-entry.mjs +10 -6
- package/{src → page}/image-inputs.mjs +11 -0
- package/{src → page}/image-names.mjs +2 -2
- package/page/sha256.mjs +65 -0
- package/{src → page}/snapshot-records.mjs +63 -44
- package/{src → page}/static-asset.mjs +36 -20
- package/{src → page}/version.mjs +1 -1
- package/scripts/dolly-build-init/Dollyfile +3 -7
- package/scripts/dolly-build-init/public/icon.svg +1 -0
- package/scripts/dolly-build-init/public/index.html +6 -58
- package/scripts/dolly-build-init/public/main.mjs +52 -0
- package/scripts/dolly-build-init/public/page.css +3 -0
- package/scripts/dolly-build-init/public/skills/changelog/SKILL.md +5 -0
- package/scripts/dolly-build-init/workflow.yml +42 -0
- package/scripts/dolly-build.mjs +194 -62
- package/scripts/dollyfile-graph.mjs +3 -3
- package/scripts/image-directory.mjs +6 -7
- package/scripts/page-image-build.mjs +29 -10
- package/scripts/recipe-files.mjs +3 -3
- package/scripts/release-layout.mjs +1 -1
- package/scripts/serve.mjs +40 -10
- package/scripts/sha256.mjs +4 -0
- package/scripts/share-pages-snapshots.mjs +10 -30
- package/scripts/snapshot-identity.mjs +1 -2
- package/scripts/system-snapshot-format.mjs +11 -9
- package/scripts/wasm-interface.mjs +2 -2
- package/{src → site}/dollyfile-graph.mjs +1 -1
- package/{src → site}/dollyfile-view.mjs +1 -1
- /package/{src → page}/process-abi.mjs +0 -0
- /package/{src → page}/wasm-interface.mjs +0 -0
- /package/{src → site}/alphabetical.mjs +0 -0
package/scripts/dolly-build.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// An npm user's image: builds a Dollyfile
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// npx dolly-build
|
|
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
|
-
//
|
|
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 "../
|
|
31
|
-
import { imageFileName, inspectDollyfile } from "../
|
|
32
|
-
import { imageInputs } from "../
|
|
33
|
-
import { validName } from "../
|
|
34
|
-
import { siteReference, sitePath } from "../
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 "../
|
|
4
|
-
import { recipeFileName } from "../
|
|
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 "../
|
|
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 {
|
|
1
|
+
import { sha256 } from "./sha256.mjs";
|
|
2
2
|
import { recipeRecords } from "./dollyfile-graph.mjs";
|
|
3
|
-
import { alphabetical } from "../
|
|
4
|
-
import { siteReference } from "../
|
|
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 (
|
|
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
|
-
|
|
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 "../
|
|
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).
|
|
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,
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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) {
|
package/scripts/recipe-files.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { siteReference } from "../
|
|
4
|
-
import { imageFileName } from "../
|
|
5
|
-
import { validName } from "../
|
|
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
|
|
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 "./
|
|
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.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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, "
|
|
59
|
-
if (sha256(version) !== files.get("
|
|
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
|
-
|
|
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");
|