Two builds could contend for one URL, and everything downstream of that was guesswork. The stopgap — max-age=0 on everything — was the only honest header for a filename that means different things on different days, but it left the real hole open: `addAll` fetches through the HTTP cache, so a precache could capture a new bundle beside an old stylesheet and then serve that pair until the next deploy. Unlikely, not impossible, and unfalsifiable after the fact. So every asset now carries a digest of its contents in its name. The mixed cache stops being unlikely and becomes unrepresentable: the old stylesheet still has its own name and nothing has overwritten it. Those files are cached for a year and never revalidated, which is the second thing a digest buys. Four keep their names, each for its own reason. index.html is the door and needs a fixed address. sw.js is how an update is noticed at all — a browser compares it byte for byte — so a hashed one would need updating in order to be updated. The manifest and its icons are how an installed app is recognised, and moving them can stop a browser updating it. The build's order is the dependency order: a font is named before the stylesheet that points at it is hashed, and the solver worker before the bundle that names it. The worker's URL is substituted into the bundle before the bundle is hashed, which dissolves the apparent circularity between them and means a bundle can only ever reach the worker it was built against. They speak a protocol of their own, so the alternative is not a missing file but a conversation at cross purposes. The worker's version is now a digest of everything it precaches, and the manifest is generated. Both were kept by hand and had drifted: thirteen commits touched the app and three remembered to bump the version. And a new version waits. No skipWaiting, so it installs and sits there while the running one carries on; the app takes it in `begin_play`, the one place a board starts whichever way it was reached, because that is the only moment a reload costs a player nothing. Taking over on arrival would swap the code under a board somebody is halfway through — and would let the new worker's activation delete assets the running one still means to fetch, since the solver is not loaded until the first solve. Deleting the old caches on activate is only safe because of that: activation waits for the last page on the old version to go. The headers are two rules that cannot both match. Cloudflare joins duplicate header names with a comma rather than letting the narrower rule win, so a Cache-Control on /* would be glued to the front of the immutable one and, being the lower max-age, would beat it. A 404.html is there too: without one Pages answers a missing asset with index.html and a 200, which fails later, somewhere else, as a parse error. Checked end to end against two real builds: the second installs and waits while the page keeps serving the first, opening a board applies it, the old cache is deleted, and the JS keeps its name across a stylesheet-only change. Offline the whole app still comes up from cache, solver included.
- commit
- 65a78924d32ed61869df110711f0e2e9373cd0bf
- parent
- dbdf91b9aa60aa03365455085b53c0972ca952c6
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 33 | +++++- |
| M | assets/ | 41 | ++++-- |
| M | assets/ | 4 | +- |
| M | assets/ | 112 | ++++++++++++++----- |
| M | build.sh | 18 | +--- |
| M | src/ | 7 | +- |
| M | src/ | 11 | ++ |
| M | src/ | 50 | ++++++++- |
| A | tools/ | 181 | ++++++++++++++++++++++++++++++ |
@@ -101,16 +101,41 @@ game's own input path, which is how a screenshot of a real run gets taken.
101101 102102 ## Offline PWA103103 104-`./build.sh` compiles to JavaScript, bundles with esbuild, and copies `assets/` — the shell, the105-manifest, the service worker, the solver worker, the fonts, the icons and the stylesheet — into106-`dist/`. It is the deploy's build command as well as the local one, so what ships is what was107-tested. It uses a Gleam that is already installed and fetches a pinned one when there is not.104+`./build.sh` compiles to JavaScript, then `tools/bundle.mjs` turns that into a directory that can105+be served. It uses a Gleam that is already installed and fetches a pinned one when there is not, so106+it is the deploy's build command as well as the local one and what ships is what was tested.108107 109108 Not `lustre/dev build`, which is the obvious choice and does not work off this machine:110109 lustre_dev_tools is itself an Erlang program, so building with it needs `escript` — a runtime this111110 app never uses and the deploy image does not have. The dev server still uses it, and still wants112111 Erlang; nothing else does.113112 113+**Every asset carries a digest of its own contents in its name**, under `/assets/`. That is what114+makes the cache correct rather than merely careful: two builds never contend for one URL, so a115+mixed cache — the new bundle beside the old stylesheet — is not unlikely, it is unrepresentable.116+Those files are then cached for a year and never revalidated. Four things keep their names on117+purpose: `index.html` is the door and needs a fixed address, `sw.js` is how an update is noticed at118+all and a hashed one would need updating in order to be updated, and the manifest and its icons are119+how an installed app is recognised.120+121+The order is the dependency order and is load bearing. A font's name is settled before the122+stylesheet that points at it is hashed, and the solver worker's before the bundle that names it,123+or a digest covers text that then changes underneath it. The worker's URL is substituted into the124+bundle before the bundle is hashed, so a bundle can only ever reach the worker it was built125+against — which matters because the two speak a protocol of their own, and an old bundle meeting a126+new worker is not an error but a conversation at cross purposes.127+128+The service worker's version is a digest of everything it precaches, so it changes exactly when the129+contents do. It was a number kept by hand and it had drifted: thirteen commits touched the app and130+three of them remembered to bump it.131+132+**A new version waits.** The worker does not call `skipWaiting`, so it installs and sits there while133+the running one carries on. The app takes it in `begin_play` — the one place a board starts,134+whichever way it was reached — because that is the only moment a reload costs a player nothing.135+Taking over on arrival would swap the code under a board somebody is halfway through, and would let136+the new worker's activation delete assets the running one still means to fetch: the solver is not137+loaded until the first solve.138+114139 Serve `dist/` from an HTTPS origin (or localhost) to install it. The service worker uses a versioned cache, removes superseded115140 caches on activation, refreshes same-origin resources while online, and falls back to cached116141 resources or the app shell offline.@@ -1,17 +1,28 @@
1-# Cloudflare Pages reads this file and it decides whether a deploy reaches a2-# device that already has the app.1+# Read by Cloudflare Pages.32 #4-# The default it applies to everything but the HTML is `max-age=14400`: four5-# hours during which the browser answers from its own cache without asking. That6-# is a promise this build cannot keep, because the filenames are not content7-# hashed — klotski.js is always klotski.js — so a device that fetched the bundle8-# in the last four hours keeps running the old one after a deploy. The service9-# worker cannot save it either: the worker is network-first, but its `fetch` goes10-# through this very cache, so "network" was already answering from disk.3+# Two rules, and they are written so they cannot both match one file. Cloudflare4+# merges every matching rule and joins duplicate header names with a comma, so a5+# Cache-Control on /* would be glued to the front of the one below it and, being6+# the lower max-age of the two, would win. A catch-all for this header is not a7+# default here, it is a veto.118 #12-# max-age=0 with must-revalidate is not "do not cache". The copy is kept and the13-# ETag revalidates it, so an unchanged file costs a conditional request and a 30414-# with no body. Being offline is the service worker's job, and it still has its15-# own copy of everything.16-/*17- Cache-Control: public, max-age=0, must-revalidate9+# Everything under /assets/ carries a digest of its own contents in its name, so10+# that URL will never hold different bytes. It is answered from disk for a year11+# without asking. Everything else keeps its name, so it is asked about every12+# time: an ETag makes that a conditional request and a 304 with no body.13+14+/assets/*15+ Cache-Control: public, max-age=31536000, immutable16+17+/index.html18+ Cache-Control: no-cache19+20+/sw.js21+ Cache-Control: no-cache22+23+/manifest.webmanifest24+ Cache-Control: no-cache25+ Content-Type: application/manifest+json26+27+/icons/*28+ Cache-Control: no-cache@@ -11,8 +11,8 @@
1111 read rather than announced. Tiny5 ships one weight, so the contrast between1212 a title and a caption is a change of *face* rather than of weight — which is1313 the stronger signal anyway, and the one a bitmap face can actually hold. */14-@font-face { font-family: Tiny5; src: url(/fonts/tiny5.woff2) format('woff2'); font-display: block; }15-@font-face { font-family: DMMono; src: url(/fonts/dm-mono.woff2) format('woff2'); font-display: block; }14+@font-face { font-family: Tiny5; src: url(./fonts/tiny5.woff2) format('woff2'); font-display: block; }15+@font-face { font-family: DMMono; src: url(./fonts/dm-mono.woff2) format('woff2'); font-display: block; }1616 1717 :root {1818 --ink: #2B2B22;@@ -1,48 +1,104 @@
1-const VERSION = "klotski-v6";2-const CORE = [3- "/",4- "/klotski.js",5- "/styles.css",6- "/bootstrap.js",7- "/solver-worker.js",8- "/manifest.webmanifest",9- "/fonts/tiny5.woff2",10- "/fonts/dm-mono.woff2",11- "/icons/icon-192.png",12- "/icons/icon-512.png",13- "/icons/icon-maskable.png",14-];1+// The offline copy.2+//3+// The version and the manifest below are both written by the build. The version4+// is a digest of everything precached, so the cache key changes exactly when the5+// contents do — never when nothing has, and never *not* when something has. It6+// used to be a number kept by hand, and by the tenth deploy it had drifted:7+// thirteen commits touched the app and three of them remembered to bump it.8+//9+// That is also what makes an update happen at all. A browser decides whether to10+// install a new worker by comparing this file byte for byte with the one it has,11+// so the manifest being *in here* is the mechanism, not a convenience. It is why12+// this file is never fingerprinted itself: a hashed service worker would have to13+// be updated in order to be updated.14+//15+// Everything under /assets/ carries a digest in its own name, so those files are16+// immutable and are answered from the cache without ever asking. The shell, the17+// manifest and the icons keep their names and are asked about every time.18+const VERSION = "__VERSION__";19+const CORE = __CORE__;20+21+const SHELL = "/index.html";1522 1623 self.addEventListener("install", (event) => {1724 event.waitUntil(caches.open(VERSION).then((cache) => cache.addAll(CORE)));18- self.skipWaiting();25+ // Deliberately no skipWaiting. A new worker waits until the page decides it is26+ // a good moment, which for this app is between one puzzle and the next: see27+ // `applyUpdate` in browser_ffi. Taking over immediately would swap the code28+ // under a board somebody is halfway through, and would let this worker's29+ // activation delete assets the running one still intends to lazily fetch —30+ // the solver worker is not loaded until the first solve.31+});32+33+self.addEventListener("message", (event) => {34+ if (event.data === "SKIP_WAITING") self.skipWaiting();1935 });2036 2137 self.addEventListener("activate", (event) => {2238 event.waitUntil(23- caches.keys().then((keys) =>24- Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))),25- ),39+ caches40+ .keys()41+ .then((keys) =>42+ Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))),43+ )44+ .then(() => self.clients.claim()),2645 );27- self.clients.claim();46+ // Safe to delete the old caches here *because* of the missing skipWaiting47+ // above: activation waits for every page running the previous version to go,48+ // so nothing is still reading what is being thrown away.2849 });2950 3051 self.addEventListener("fetch", (event) => {31- if (event.request.method !== "GET") return;32- const url = new URL(event.request.url);52+ const request = event.request;53+ if (request.method !== "GET") return;54+ const url = new URL(request.url);3355 if (url.origin !== self.location.origin) return;3456 57+ // A navigation is answered from the shell that was precached with this58+ // version, so the page and the assets it names can never come from two59+ // different builds. The network is not consulted: a new shell arrives by60+ // installing a new worker, which is the only thing that should move an61+ // offline app forward.62+ if (request.mode === "navigate") {63+ event.respondWith(64+ caches.match(SHELL).then((cached) => cached || fetch(request)),65+ );66+ return;67+ }68+69+ // Fingerprinted, so the copy on disk is the only copy that URL will ever have.70+ if (url.pathname.startsWith("/assets/")) {71+ event.respondWith(72+ caches.match(request).then(73+ (cached) =>74+ cached ||75+ fetch(request).then((response) => {76+ if (response.ok) {77+ const copy = response.clone();78+ caches.open(VERSION).then((cache) => cache.put(request, copy));79+ }80+ return response;81+ }),82+ ),83+ );84+ return;85+ }86+87+ // Everything else keeps its name, so it is asked about and only falls back.3588 event.respondWith(36- fetch(event.request).then((response) => {89+ fetch(request)90+ .then((response) => {3791 if (response.ok) {3892 const copy = response.clone();39- caches.open(VERSION).then((cache) => cache.put(event.request, copy));93+ caches.open(VERSION).then((cache) => cache.put(request, copy));4094 }4195 return response;42- }).catch(() => caches.match(event.request).then((cached) => {43- if (cached) return cached;44- if (event.request.mode === "navigate") return caches.match("/");45- return new Response("Offline", { status: 503, statusText: "Offline" });46- })),96+ })97+ .catch(() =>98+ caches.match(request).then((cached) => {99+ if (cached) return cached;100+ return new Response("Offline", { status: 503, statusText: "Offline" });101+ }),102+ ),47103 );48104 });@@ -60,20 +60,4 @@ fi
6060 6161 gleam test6262 gleam build --target javascript63-64-# The bundle needs something to call. The compiler emits a module per Gleam65-# module and nothing that runs one, so this is the line `lustre/dev build`66-# appends for you.67-OUT="build/dev/javascript/klotski"68-printf 'import { main } from "./klotski.mjs";\nmain();\n' > "${OUT}/_entry.mjs"69-70-rm -rf dist71-mkdir -p dist72-npx --yes "esbuild@${ESBUILD_VERSION}" "${OUT}/_entry.mjs" \73- --bundle --minify --format=esm --outfile=dist/klotski.js74-75-# index.html is checked in rather than generated, so what ships is a file76-# somebody can read. Everything else in assets/ is served as it stands.77-cp -R assets/. dist/78-echo "built dist/"79-ls -la dist63+node tools/bundle.mjs@@ -874,7 +874,12 @@ fn begin_play(model: Model) -> #(Model, Effect(Message)) {
874874 started_at: browser.now(),875875 board: layout.board_layout(model.layout, model.family),876876 )877- #(model, effect.batch([page(model), paint(model)]))877+ // The one place a board starts, whichever way it was reached, and so the878+ // one place a waiting version is allowed in: the record is written, the879+ // last board is finished with, and a reload costs a player the wait and880+ // nothing else. Almost every time there is nothing waiting and this does881+ // nothing at all.882+ #(model, effect.batch([browser.apply_update(), page(model), paint(model)]))878883 }879884 }880885 }@@ -152,6 +152,17 @@ pub fn register_service_worker() -> Effect(message) {
152152 effect.from(fn(_dispatch) { register_service_worker_ffi() })153153 }154154 155+@external(javascript, "./browser_ffi.mjs", "applyUpdate")156+fn apply_update_ffi() -> Nil157+158+/// Take a waiting version, if there is one. Nothing happens when there is not.159+///160+/// Called at the seam between one board and the next, which is the only moment161+/// in this app where a reload costs a player nothing.162+pub fn apply_update() -> Effect(message) {163+ effect.from(fn(_dispatch) { apply_update_ffi() })164+}165+155166 @external(javascript, "./browser_ffi.mjs", "loadFonts")156167 fn load_fonts_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil157168 @@ -254,6 +254,13 @@ export async function promptInstall(dispatch, message) {
254254 dispatch(message);255255 }256256 257+// A new version is never applied while somebody is playing.258+//259+// The worker that installs it waits, and the app calls `applyUpdate` at the one260+// moment a reload costs nothing: between finishing with one board and opening261+// the next. PROXX does the same thing for the same reason. Reloading on the spot262+// is what a document editor does, because it has nowhere better; a puzzle has a263+// seam and should use it.257264 export function registerServiceWorker() {258265 if (!("serviceWorker" in navigator)) return;259266 window.addEventListener("load", () => {@@ -261,13 +268,54 @@ export function registerServiceWorker() {
261268 });262269 }263270 271+/// Take a waiting version, if there is one. Nothing happens when there is not.272+///273+/// The registration is asked at the moment of use rather than watched from the274+/// moment of registration. Watching looked tidier and lost the race: a browser275+/// checks for a new worker on its own during a navigation, so the install can276+/// finish before the page has attached a listener for it, and the update then277+/// sits there forever because the event that announced it has already gone.278+/// There is nothing to miss if you never try to remember.279+///280+/// A plain reload would not do it either: during a navigation the old page lives281+/// until the response arrives, so the old worker is still controlling and the282+/// waiting one stays waiting. It has to be told to skip, and the reload has to283+/// wait until it has actually taken over.284+export function applyUpdate() {285+ if (!("serviceWorker" in navigator)) return;286+ navigator.serviceWorker287+ .getRegistration()288+ .then((registration) => {289+ const worker = registration && registration.waiting;290+ if (!worker) return;291+ navigator.serviceWorker.addEventListener(292+ "controllerchange",293+ () => window.location.reload(),294+ { once: true },295+ );296+ worker.postMessage("SKIP_WAITING");297+ })298+ .catch(() => {});299+}300+264301 // ------------------------------------------------------------- the surveyor265302 303+// The build substitutes the worker's fingerprinted URL for `__SOLVER_WORKER__`304+// before it hashes this bundle, so a bundle can only ever reach the worker it305+// was built against. That matters more here than it would for a missing file:306+// the two speak a protocol of their own, so an old bundle meeting a new worker307+// is not an error, it is a conversation at cross purposes.308+//309+// The `typeof` guard is what makes the same source run unbundled, where nothing310+// has been substituted and the file is served under its own name.311+const SOLVER_WORKER =312+ typeof __SOLVER_WORKER__ === "string" ? __SOLVER_WORKER__ : "/solver-worker.js";313+266314 let solver = null;267315 268316 function worker() {269317 if (solver) return solver;270- solver = new Worker("/solver-worker.js");318+ solver = new Worker(SOLVER_WORKER);271319 return solver;272320 }273321 @@ -0,0 +1,181 @@
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.20+import { execFileSync } from "node:child_process";21+import { createHash } from "node:crypto";22+import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";23+import { basename, join } from "node:path";24+25+const ESBUILD = process.env.ESBUILD_VERSION ?? "0.28.1";26+const OUT = "dist";27+const ASSETS = join(OUT, "assets");28+const META = "build/bundle-meta";29+30+const esbuild = (args) =>31+ execFileSync("npx", ["--yes", `esbuild@${ESBUILD}`, ...args], {32+ stdio: ["ignore", "pipe", "inherit"],33+ encoding: "utf8",34+ });35+36+// esbuild reports what it actually wrote, which is the only reliable way to37+// learn a name it chose. Asking the directory would guess.38+const 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]);42+43+const 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+};50+51+rmSync(OUT, { recursive: true, force: true });52+mkdirSync(ASSETS, { recursive: true });53+mkdirSync(META, { recursive: true });54+55+// 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.59+esbuild([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+]);69+const styles = one(`${META}/css.json`, ".css");70+71+// 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.73+esbuild([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+]);82+const solver = one(`${META}/worker.json`, ".js");83+84+// 3. The palette script that runs before first paint. Also a leaf.85+esbuild([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+]);94+const bootstrap = one(`${META}/bootstrap.json`, ".js");95+96+// 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.100+const entry = "build/dev/javascript/klotski/_entry.mjs";101+writeFileSync(entry, 'import { main } from "./klotski.mjs";\nmain();\n');102+esbuild([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+]);112+const app = one(`${META}/app.json`, ".js");113+114+// 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.116+cpSync("assets/icons", join(OUT, "icons"), { recursive: true });117+cpSync("assets/manifest.webmanifest", join(OUT, "manifest.webmanifest"));118+for (const licence of readdirSync("assets/fonts").filter((f) => f.endsWith(".txt"))) {119+ cpSync(join("assets/fonts", licence), join(ASSETS, "fonts", licence));120+}121+122+// 6. The shell, with the three names it did not know at rest.123+const shell = readFileSync("assets/index.html", "utf8")124+ .replace("/styles.css", styles)125+ .replace("/bootstrap.js", bootstrap)126+ .replace("/klotski.js", app);127+writeFileSync(join(OUT, "index.html"), shell);128+129+// 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.133+writeFileSync(134+ join(OUT, "404.html"),135+ '<!doctype html>\n<meta charset="utf-8">\n<title>Not found</title>\n<p>Not found.\n',136+);137+138+// 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.141+const core = [142+ "/index.html",143+ "/manifest.webmanifest",144+ styles,145+ bootstrap,146+ app,147+ solver,148+ ...outputsOf(`${META}/css.json`, ".woff2"),149+ ...readdirSync("assets/icons").map((icon) => `/icons/${icon}`),150+].sort();151+152+const digest = createHash("sha256");153+digest.update(shell);154+for (const path of core) {155+ const disk = path.startsWith("/assets/")156+ ? join(OUT, path.slice(1))157+ : join(OUT, path.slice(1));158+ digest.update(path);159+ digest.update(readFileSync(disk));160+}161+const version = "klotski-" + digest.digest("hex").slice(0, 12);162+163+writeFileSync(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+);173+174+// 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.178+cpSync("assets/_headers", join(OUT, "_headers"));179+180+console.log(`built ${OUT}/ version ${version}`);181+for (const path of core) console.log(" " + path);