feat(pwa): add service worker, manifest, and subpath-aware build

The precache list is generated from Bun.build's outputs, so hashed asset
names cannot drift. A waiting worker prompts to reload rather than
pinning the user to a stale build.

Two fixes beyond the brief: naming.entry changed from "[dir]/[name].[ext]"
to "[name].[ext]" because [dir] resolved to "src", putting index.html at
dist/src/index.html instead of the dist root; and external:
["*.webmanifest", "*.svg"] was added because Bun's HTML bundler otherwise
tries to resolve <link> hrefs as module imports, which fails outright for
the not-yet-generated manifest.webmanifest and would hash icon.svg,
breaking the fixed paths the manifest and service worker depend on.

happy-dom is registered per-file in src/ui/registerSw.test.ts only, not
as a global test preload, so src/domain/smoke.test.ts still guards the
domain layer's DOM-free invariant.

PNG icons (192px/512px) are not included — binary image generation isn't
available here; a human needs to add them for full Chrome installability.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-01 21:22:58 +02:00
co-authored by Claude Opus 5
parent 31f030fdea
commit c069ed3a94
10 changed files with 264 additions and 5 deletions
+88 -4
View File
@@ -1,24 +1,108 @@
import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import path from "node:path";
import { normalizeBase } from "./src/ui/basePath.ts";
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
const outdir = path.join(process.cwd(), "dist");
const version = process.env.BUILD_VERSION ?? String(Date.now());
await rm(outdir, { recursive: true, force: true });
const entrypoints = [...new Bun.Glob("src/**/*.html").scanSync()];
const result = await Bun.build({
entrypoints,
entrypoints: [...new Bun.Glob("src/**/*.html").scanSync()],
outdir,
plugins: [tailwind],
minify: true,
target: "browser",
sourcemap: "linked",
// publicPath prefixes asset and chunk imports, which is what makes a subpath
// deploy work without hardcoding it anywhere.
publicPath: basePath,
naming: {
// NOTE: the brief specified "[dir]/[name].[ext]" here. With a single HTML
// entrypoint at src/index.html, [dir] resolves to "src", which puts the
// built file at dist/src/index.html instead of dist/index.html. That
// breaks the dist/404.html copy below (it reads dist/index.html) and the
// Task 25 static server (which must find index.html at the dist root).
// Verified by building with both values and inspecting `ls -R dist`.
entry: "[name].[ext]",
chunk: "[name]-[hash].[ext]",
asset: "[name]-[hash].[ext]",
},
// index.html references manifest.webmanifest and icon.svg via plain <link>
// hrefs. Bun's HTML bundler otherwise treats every <link href> as a module
// reference to resolve, bundle, and (for assets) hash — which both fails
// outright for manifest.webmanifest (it doesn't exist as a source file; it
// is generated below, after this build step) and would hash icon.svg,
// breaking the fixed "icon.svg" paths that manifest.webmanifest and the
// service worker precache list depend on. Marking these globs external
// leaves both hrefs untouched in the output HTML, so they resolve as plain
// relative URLs against the page, exactly as the spec intends.
external: ["*.webmanifest", "*.svg"],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
},
});
if (!result.success) {
for (const log of result.logs) console.error(log);
throw new Error("Build failed");
}
// The precache list comes from the build's own outputs, so hashed filenames can
// never drift out of sync with the service worker.
const precache = result.outputs
.filter((output) => !output.path.endsWith(".map"))
.map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/"));
const swResult = await Bun.build({
entrypoints: ["src/sw.ts"],
outdir,
target: "browser",
minify: true,
naming: "sw.js", // must be a stable URL, so no hash
define: {
"process.env.PRECACHE_MANIFEST": JSON.stringify(JSON.stringify(precache)),
"process.env.SW_VERSION": JSON.stringify(version),
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
},
});
if (!swResult.success) {
for (const log of swResult.logs) console.error(log);
throw new Error("Service worker build failed");
}
export function buildManifest(base: string): string {
return JSON.stringify(
{
name: "Budget 2.0",
short_name: "Budget",
start_url: base,
scope: base,
display: "standalone",
background_color: "#0f172a",
theme_color: "#0f172a",
icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }],
},
null,
2,
);
}
await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath));
await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
// GitHub Pages has no rewrite rules, so a deep link on a cold load lands on
// 404.html. Serving the shell from there makes client-side routing work.
const indexHtml = await Bun.file(path.join(outdir, "index.html")).text();
await Bun.write(path.join(outdir, "404.html"), indexHtml);
console.log(`Built for base path ${basePath}`);
for (const output of result.outputs) {
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
console.log(
` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`,
);
}
+17
View File
@@ -21,6 +21,7 @@
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.12.0",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -37,6 +38,8 @@
"@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@happy-dom/global-registrator": ["@happy-dom/[email protected]", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.12.0" } }, "sha512-BUE55Rew3oMwBzwCwmUnV+Oxk51V3xolM39Ts6kGiBXNELjujiESwk9qc99JRr6cVrj3OTd9MJ5zQ2fpn+jy6g=="],
"@oven/bun-darwin-aarch64": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="],
"@oven/bun-darwin-x64": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="],
@@ -189,8 +192,14 @@
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="],
"@types/whatwg-mimetype": ["@types/[email protected]", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"buffer-image-size": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"bun": ["[email protected]", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="],
"bun-plugin-tailwind": ["[email protected]", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
@@ -207,10 +216,14 @@
"detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"happy-dom": ["[email protected]", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g=="],
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA=="],
"radix-ui": ["[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.15", "@radix-ui/react-accordion": "1.2.20", "@radix-ui/react-alert-dialog": "1.1.23", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.15", "@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-checkbox": "1.3.11", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.7", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-dropdown-menu": "2.1.24", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-form": "0.1.16", "@radix-ui/react-hover-card": "1.1.23", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-menubar": "1.1.24", "@radix-ui/react-navigation-menu": "1.2.22", "@radix-ui/react-one-time-password-field": "0.1.16", "@radix-ui/react-password-toggle-field": "0.1.11", "@radix-ui/react-popover": "1.1.23", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-progress": "1.1.16", "@radix-ui/react-radio-group": "1.4.7", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-select": "2.3.7", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-slider": "1.4.7", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-switch": "1.3.7", "@radix-ui/react-tabs": "1.1.21", "@radix-ui/react-toast": "1.2.23", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-toggle-group": "1.1.19", "@radix-ui/react-toolbar": "1.1.19", "@radix-ui/react-tooltip": "1.2.16", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA=="],
@@ -242,5 +255,9 @@
"use-callback-ref": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["[email protected]", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"whatwg-mimetype": ["[email protected]", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
}
}
+2 -1
View File
@@ -25,9 +25,10 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.12.0",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"tailwindcss": "^4.1.11"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0f172a"/>
<path d="M128 320h64V192h-64zm96 0h64V128h-64zm96 0h64V240h-64z" fill="#38bdf8"/>
<rect x="96" y="352" width="320" height="24" rx="12" fill="#e2e8f0"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Budget 2.0</title>
<link rel="manifest" href="manifest.webmanifest" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#0f172a" />
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
+71
View File
@@ -0,0 +1,71 @@
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
// Both are replaced at build time by Bun.build's `define`.
const PRECACHE: string[] = JSON.parse(process.env.PRECACHE_MANIFEST ?? "[]");
const VERSION = process.env.SW_VERSION ?? "dev";
const BASE = process.env.BUN_PUBLIC_BASE_PATH ?? "/";
const CACHE = `budget2-${VERSION}`;
const SHELL = `${BASE}index.html`;
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll([...PRECACHE, SHELL])),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Drop caches from previous versions.
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
await self.clients.claim();
})(),
);
});
self.addEventListener("message", (event) => {
// Sent by applyUpdate() when the user accepts a new version.
if ((event.data as { type?: string } | undefined)?.type === "SKIP_WAITING") {
void self.skipWaiting();
}
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return; // never cache the rate API
// Navigations fall back to the cached shell, which also covers deep links.
if (request.mode === "navigate") {
event.respondWith(
(async () => {
try {
return await fetch(request);
} catch {
return (await caches.match(SHELL)) ?? Response.error();
}
})(),
);
return;
}
// Hashed assets: cache-first, since a changed file gets a new name.
event.respondWith(
(async () => {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE);
void cache.put(request, response.clone());
}
return response;
})(),
);
});
+2
View File
@@ -7,6 +7,7 @@ import { PostMonthRoute } from "./routes/PostMonthRoute.tsx";
import { PostYearRoute } from "./routes/PostYearRoute.tsx";
import { SummaryRoute } from "./routes/SummaryRoute.tsx";
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
export function AppRoutes() {
const year = currentMonth.slice(0, 4);
@@ -37,6 +38,7 @@ export function AppRoutes() {
<Route path="*" element={<Navigate to={`/month/${currentMonth}`} replace />} />
</Routes>
</main>
<UpdatePrompt />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { applyUpdate, registerServiceWorker } from "../registerSw.ts";
export function UpdatePrompt() {
const [ready, setReady] = useState(false);
useEffect(() => {
registerServiceWorker(() => setReady(true));
}, []);
if (!ready) return null;
return (
<div className="fixed bottom-4 right-4 flex items-center gap-3 rounded border bg-background p-3 text-sm shadow-lg">
<span>A new version is ready.</span>
<Button size="sm" onClick={applyUpdate}>
Reload
</Button>
<Button size="sm" variant="ghost" onClick={() => setReady(false)}>
Later
</Button>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
import { test, expect } from "bun:test";
import { registerServiceWorker } from "./registerSw.ts";
test("registration is a no-op when the browser has no service worker support", () => {
// happy-dom's navigator has no serviceWorker, which is the case under test.
// (happy-dom exposes `navigator` as a getter-only global, so there is
// nothing to save/restore here — reassigning it would throw.)
expect(() => registerServiceWorker(() => {})).not.toThrow();
});
+39
View File
@@ -0,0 +1,39 @@
import { BASE_PATH, withBase } from "./basePath.ts";
let waiting: ServiceWorker | null = null;
/**
* Registers the worker and calls `onUpdateReady` when a new version is waiting.
* Without an explicit update path, a stale worker can pin the user to an old
* build indefinitely.
*/
export function registerServiceWorker(onUpdateReady: () => void): void {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
void navigator.serviceWorker
.register(withBase("sw.js"), { scope: BASE_PATH })
.then((registration) => {
if (registration.waiting) {
waiting = registration.waiting;
onUpdateReady();
}
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
installing?.addEventListener("statechange", () => {
if (installing.state === "installed" && navigator.serviceWorker.controller) {
waiting = installing;
onUpdateReady();
}
});
});
})
.catch(() => {
// An unavailable worker must never break the app; it just means no offline.
});
}
export function applyUpdate(): void {
waiting?.postMessage({ type: "SKIP_WAITING" });
waiting = null;
window.location.reload();
}