1// Everything between `gleam build` and a directory that can be served.2//3// The one idea here is that a file's name carries a digest of its contents, so4// two builds never contend for one URL. That is what makes a cache correct by5// construction rather than by promise: a mixed cache — the new bundle beside the6// old stylesheet — stops being unlikely and becomes unrepresentable, because the7// old stylesheet still has its own name and nothing has overwritten it. It is8// also what lets those files be cached forever and never revalidated.9//10// Four things deliberately keep their names. index.html is the door and has to11// be at a fixed address. sw.js is how an update is noticed at all, and a hashed12// one would need updating in order to be updated. The web manifest and its icons13// are how an installed app is recognised, and moving them can stop a browser14// updating an installed app.15//16// The order below is the dependency order and is load bearing: a font's name17// must be settled before the stylesheet that points at it is hashed, and the18// solver worker's before the bundle that names it, or a digest covers text that19// then changes underneath it.20import { execFileSync } from "node:child_process";21import { createHash } from "node:crypto";22import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";23import { basename, join } from "node:path";2425const ESBUILD = process.env.ESBUILD_VERSION ?? "0.28.1";26const OUT = "dist";27const ASSETS = join(OUT, "assets");28const META = "build/bundle-meta";2930const esbuild = (args) =>31 execFileSync("npx", ["--yes", `esbuild@${ESBUILD}`, ...args], {32 stdio: ["ignore", "pipe", "inherit"],33 encoding: "utf8",34 });3536// esbuild reports what it actually wrote, which is the only reliable way to37// learn a name it chose. Asking the directory would guess.38const outputsOf = (metafile, extension) =>39 Object.keys(JSON.parse(readFileSync(metafile, "utf8")).outputs)40 .filter((path) => path.endsWith(extension))41 .map((path) => "/" + path.split("dist/")[1]);4243const one = (metafile, extension) => {44 const found = outputsOf(metafile, extension);45 if (found.length !== 1) {46 throw new Error(`expected one ${extension} from ${metafile}, got ${found.length}`);47 }48 return found[0];49};5051rmSync(OUT, { recursive: true, force: true });52mkdirSync(ASSETS, { recursive: true });53mkdirSync(META, { recursive: true });5455// 1. The stylesheet and the two faces it names. One call, because esbuild56// fingerprints the fonts, rewrites the url() that points at them, and only57// then hashes the stylesheet over the rewritten text — which is the ordering58// this whole file exists to respect.59esbuild([60 "assets/styles.css",61 "--bundle",62 "--minify",63 "--loader:.woff2=file",64 "--asset-names=fonts/[name]-[hash]",65 "--entry-names=[name]-[hash]",66 `--outdir=${ASSETS}`,67 `--metafile=${META}/css.json`,68]);69const styles = one(`${META}/css.json`, ".css");7071// 2. The solver, on its own. A leaf: nothing points into it, and it is a classic72// worker rather than a module, so it is bundled as one.73esbuild([74 "assets/solver-worker.js",75 "--bundle",76 "--minify",77 "--format=iife",78 "--entry-names=[name]-[hash]",79 `--outdir=${ASSETS}`,80 `--metafile=${META}/worker.json`,81]);82const solver = one(`${META}/worker.json`, ".js");8384// 3. The palette script that runs before first paint. Also a leaf.85esbuild([86 "assets/bootstrap.js",87 "--bundle",88 "--minify",89 "--format=iife",90 "--entry-names=[name]-[hash]",91 `--outdir=${ASSETS}`,92 `--metafile=${META}/bootstrap.json`,93]);94const bootstrap = one(`${META}/bootstrap.json`, ".js");9596// 4. The app, with the solver's settled name compiled into it. The substitution97// happens before the digest is taken, so the digest covers the final bytes98// and a bundle can only ever reach the worker it was built against. That is99// what dissolves the apparent circularity between the two.100const entry = "build/dev/javascript/klotski/_entry.mjs";101writeFileSync(entry, 'import { main } from "./klotski.mjs";\nmain();\n');102esbuild([103 entry,104 "--bundle",105 "--minify",106 "--format=esm",107 `--define:__SOLVER_WORKER__=${JSON.stringify(solver)}`,108 "--entry-names=klotski-[hash]",109 `--outdir=${ASSETS}`,110 `--metafile=${META}/app.json`,111]);112const app = one(`${META}/app.json`, ".js");113114// 5. What keeps its name. The icons are copied as they are; the manifest points115// at them and is left alone for the same reason they are.116cpSync("assets/icons", join(OUT, "icons"), { recursive: true });117cpSync("assets/manifest.webmanifest", join(OUT, "manifest.webmanifest"));118for (const licence of readdirSync("assets/fonts").filter((f) => f.endsWith(".txt"))) {119 cpSync(join("assets/fonts", licence), join(ASSETS, "fonts", licence));120}121122// 6. The shell, with the three names it did not know at rest.123const shell = readFileSync("assets/index.html", "utf8")124 .replace("/styles.css", styles)125 .replace("/bootstrap.js", bootstrap)126 .replace("/klotski.js", app);127writeFileSync(join(OUT, "index.html"), shell);128129// Pages serves index.html with a 200 for anything it cannot find unless there is130// a 404 to serve instead. A missing asset arriving as HTML with a 200 is the131// worst shape a failure can take: it fails later, somewhere else, as a parse132// error, and it poisons the precache on the way past.133writeFileSync(134 join(OUT, "404.html"),135 '<!doctype html>\n<meta charset="utf-8">\n<title>Not found</title>\n<p>Not found.\n',136);137138// 7. The offline copy, and the digest that is its version. Sorted, so that two139// builds of the same bytes produce the same worker and a browser sees no140// update where there is none.141const core = [142 // Cloudflare Pages redirects /index.html to /. A redirected response cannot143 // satisfy a service-worker navigation, so the canonical URL is the cache key.144 "/",145 "/manifest.webmanifest",146 styles,147 bootstrap,148 app,149 solver,150 ...outputsOf(`${META}/css.json`, ".woff2"),151 ...readdirSync("assets/icons").map((icon) => `/icons/${icon}`),152].sort();153154const digest = createHash("sha256");155digest.update(shell);156for (const path of core) {157 const disk = path === "/" ? join(OUT, "index.html") : join(OUT, path.slice(1));158 digest.update(path);159 digest.update(readFileSync(disk));160}161const version = "klotski-" + digest.digest("hex").slice(0, 12);162163writeFileSync(164 join(OUT, "sw.js"),165 // Every occurrence, not the first. A token named in a comment above the line166 // it belongs to silently ate the substitution once already: the constant kept167 // its placeholder, the cache took the placeholder as its name, and the build168 // reported success.169 readFileSync("assets/sw.js", "utf8")170 .replaceAll("__VERSION__", version)171 .replaceAll("__CORE__", JSON.stringify(core, null, 2)),172);173174// 8. The headers. Two rules that cannot both match, because Cloudflare joins175// duplicate header names with a comma rather than letting the narrower rule176// win — so a Cache-Control on /* would be glued to the front of every other177// Cache-Control and, being the lower max-age, would beat it.178cpSync("assets/_headers", join(OUT, "_headers"));179180console.log(`built ${OUT}/ version ${version}`);181for (const path of core) console.log(" " + path);