/**
* Stage the static half of the build into public/: the shell, the service
* worker, the manifest and the icon.
*
* This was a `sed` chain in package.json. It grew a job a shell one-liner
* cannot do: stamping the service worker with a hash of the built shell, so
* that a deploy is *detectable* by a browser that already has the app. The
* browser decides a worker is new by comparing its bytes, so an update the
* worker file does not mention is an update no reader is ever offered.
*
* Deriving that stamp from the built bytes rather than from a constant someone
* remembers to bump is the point: the previous scheme was a hand-edited
* `CACHE_VERSION`, and it had already been forgotten once and fixed in a
* follow-up commit.
*/
import { mkdir } from "node:fs/promises";
import { basename, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "..");
const OUT = resolve(ROOT, process.env.PUBLIC_DIR ?? "public");
/** Trailing slash matters: it is a ``, not a prefix. */
const BASE_PATH = process.env.BASE_PATH ?? "/";
/** The literal the service worker carries so this script has something to replace. */
export const BUILD_PLACEHOLDER = "__BUILD__";
/**
* A short, stable name for exactly these bytes.
*
* Deterministic on purpose: an identical rebuild produces an identical id, so
* nobody is told to reload for a deploy that changed nothing. A timestamp would
* have been easier and would have prompted every reader on every rebuild.
*/
export function buildId(parts: Array): string {
const hasher = new Bun.CryptoHasher("sha256");
for (const part of parts) hasher.update(part);
return hasher.digest("hex").slice(0, 12);
}
/**
* Stamp the build id into the worker source.
*
* Throws when the placeholder is gone. That is the failure this whole mechanism
* is prone to — an edit to sw.js drops the marker, the substitution quietly
* matches nothing, and readers stop being offered updates with nothing broken
* enough to notice. Better to fail the build.
*/
export function injectBuild(source: string, id: string): string {
if (!source.includes(BUILD_PLACEHOLDER)) {
throw new Error(
`sw.js no longer contains ${BUILD_PLACEHOLDER}; without it a deploy is undetectable by an installed app`,
);
}
return source.replaceAll(BUILD_PLACEHOLDER, id);
}
/** Bytes of a built asset, or null if this stage ran without it. */
async function bytesOf(path: string): Promise {
const file = Bun.file(path);
return (await file.exists()) ? new Uint8Array(await file.arrayBuffer()) : null;
}
async function main(): Promise {
await mkdir(OUT, { recursive: true });
const shell = (await Bun.file(resolve(ROOT, "index.html")).text()).replaceAll(
"__BASE__",
BASE_PATH,
);
await Bun.write(resolve(OUT, "index.html"), shell);
const workerSource = await Bun.file(
resolve(ROOT, "src/client/sw.js"),
).text();
// Everything a reader would be reloading *for*. Not the feed: it is rewritten
// twice a day by the refresh and served network-first, so new events reach an
// open page without one — and calling that a new version of the app would
// train readers to ignore the notice.
const built = ["main.js", "styles.css"];
const assets: Array = [shell, workerSource];
for (const name of built) {
const bytes = await bytesOf(resolve(OUT, name));
if (bytes === null) {
console.warn(
`build-static: ${name} is missing from ${basename(OUT)}/ — run the full \`bun run build\``,
);
continue;
}
assets.push(bytes);
}
const id = buildId(assets);
await Bun.write(resolve(OUT, "sw.js"), injectBuild(workerSource, id));
for (const name of ["manifest.webmanifest", "icon.svg"]) {
await Bun.write(
resolve(OUT, name),
Bun.file(resolve(ROOT, "src/client", name)),
);
}
console.log(`build-static: staged ${basename(OUT)}/ at build ${id}`);
}
// Importable for its two pure halves without staging anything.
if (import.meta.main) await main();