diff --git a/Dockerfile b/Dockerfile index 81f7959..04d4a07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,26 @@ # Fully client-side app — production is just static files served by a tiny, # fast-booting static-web-server (Rust). No Python in the runtime image. -FROM ghcr.io/static-web-server/static-web-server:2 +# +# PWA/offline service worker is OFF by default (so all visitors hit the server +# and show up in logs). Enable it at build time: +# docker build --build-arg ENABLE_CHESS_PWA=1 . +# fly deploy --build-arg ENABLE_CHESS_PWA=1 -COPY src/chess_pressure/static/ /public/ +ARG ENABLE_CHESS_PWA=0 + +FROM alpine:3 AS build +ARG ENABLE_CHESS_PWA +WORKDIR /src +COPY src/chess_pressure/static/ ./static/ +RUN if [ "$ENABLE_CHESS_PWA" = "1" ]; then \ + sed -i 's/window.CHESS_PWA = false/window.CHESS_PWA = true/' static/index.html; \ + echo "chess-pressure: PWA ENABLED"; \ + else \ + echo "chess-pressure: PWA disabled"; \ + fi + +FROM ghcr.io/static-web-server/static-web-server:2 +COPY --from=build /src/static /public ENV SERVER_HOST=0.0.0.0 \ SERVER_PORT=8888 \ diff --git a/Makefile b/Makefile index 134a90d..c6ac494 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev serve games parity lint fmt build publish fly-install deploy logs status help +.PHONY: dev serve games parity bump lint fmt build publish fly-install deploy logs status help # Static dev server (client-side app; refresh picks up edits) dev: @@ -18,6 +18,11 @@ parity: uv run python parity/dump_python.py > parity/.py_frames.json cd parity && bun run check.js +# Bump static asset cache-bust version (index.html + sw.js). Run before deploy +# when any cached asset changed — required for the PWA service worker to update. +bump: + ./scripts/bump_version.sh + # Lint lint: fmt uvx ruff check src/chess_pressure/ @@ -59,6 +64,7 @@ help: @echo " make serve optional Python fallback server (:8888)" @echo " make games regenerate static/games.json from games.py" @echo " make parity verify JS engine matches python-chess" + @echo " make bump bump asset cache version (run before deploy)" @echo " make lint ruff format + check" @echo " make fmt ruff format + auto-fix" @echo " make build build sdist + wheel" diff --git a/README.md b/README.md index 89b6b62..4f49909 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Other useful targets: make serve # optional Python fallback server (also serves the static app) make games # regenerate static/games.json after editing games.py make parity # verify the JS engine matches python-chess +make bump # bump the asset cache version (run before deploy — see below) make deploy # deploy the static image to Fly.io ``` @@ -69,6 +70,38 @@ Production is a ~9 MB image built on `static/` and nothing else, so it boots fast and scales to zero cleanly. The only cold-start cost is the initial page load; all gameplay afterwards is local. +### Cache versioning (run before every deploy) + +Static assets are cache-busted by a single version number that appears in two +places: `index.html` (`style.css?v=N`, `app.js?v=N`) and `sw.js` +(`CACHE = "chess-pressure-vN"`). **Whenever you change a cached asset, bump it:** + +```bash +make bump # v25 -> v26 across index.html and sw.js, kept in sync +``` + +This matters for the normal browser cache, and is **critical when the PWA is +enabled** — the service worker will otherwise keep serving the old assets to +returning visitors forever. Forgetting `make bump` is the #1 "why am I seeing old +code?" cause. Commit the bump along with your asset change, then `make deploy`. + +### Offline / PWA (opt-in, off by default) + +The app can install a service worker (`sw.js` + `manifest.json`) that caches the +whole app shell, so **repeat visits load instantly and work fully offline**. It is +**disabled by default on purpose**: an active service worker serves returning +visitors entirely from cache, so they no longer hit the server and won't appear in +the Fly logs. Enable it at build time: + +```bash +docker build --build-arg ENABLE_CHESS_PWA=1 . +fly deploy --build-arg ENABLE_CHESS_PWA=1 # to ship it on Fly +ENABLE_CHESS_PWA=1 make dev # to test it locally +``` + +The flag flips `window.CHESS_PWA` in `index.html`; when false, the service worker +is never registered. Remember to `make bump` on every deploy once it's on. + ## API (optional Python server) The static app does not use these, but `app.py` still exposes them: @@ -93,10 +126,13 @@ src/chess_pressure/ engine.js # Browser port of engine.py (chess.js) chess.min.js # Vendored chess.js 1.4.0 (bundled global) games.json # Built-in games, generated from games.py + sw.js # Service worker (opt-in PWA/offline cache) + manifest.json # PWA manifest app.js, ... # Frontend (HTML, JS, CSS, piece images) scripts/export_games.py # games.py -> static/games.json +scripts/bump_version.sh # bump the asset cache version (make bump) parity/ # JS-vs-python engine parity test -dev-server.js # bun static dev server +dev-server.js # bun static dev server (ENABLE_CHESS_PWA=1 to test PWA) ``` ## License diff --git a/dev-server.js b/dev-server.js index 709115e..d8f4dea 100644 --- a/dev-server.js +++ b/dev-server.js @@ -8,6 +8,7 @@ const ROOT = new URL("./src/chess_pressure/static/", import.meta.url).pathname; const PORT = Number(process.env.PORT || 8888); +const PWA = process.env.ENABLE_CHESS_PWA === "1"; // mirror the Docker build flag Bun.serve({ port: PORT, @@ -18,11 +19,19 @@ Bun.serve({ // Strip leading slashes and neutralize any ".." path traversal. const rel = pathname.replace(/^\/+/, "").replace(/\.\.(\/|\\|$)/g, ""); const file = Bun.file(ROOT + rel); - if (await file.exists()) { - return new Response(file, { headers: { "Cache-Control": "no-cache" } }); + if (!(await file.exists())) return new Response("Not found", { status: 404 }); + + const headers = { "Cache-Control": "no-cache" }; + // Flip the PWA flag in index.html on the fly so `ENABLE_CHESS_PWA=1 make dev` + // can exercise the service worker without editing source. + if (PWA && rel === "index.html") { + const html = (await file.text()).replace("window.CHESS_PWA = false", "window.CHESS_PWA = true"); + return new Response(html, { headers: { ...headers, "Content-Type": "text/html;charset=utf-8" } }); } - return new Response("Not found", { status: 404 }); + return new Response(file, { headers }); }, }); -console.log(`chess-pressure dev server → http://0.0.0.0:${PORT} (serving ${ROOT})`); +console.log( + `chess-pressure dev server → http://0.0.0.0:${PORT} (serving ${ROOT}) PWA=${PWA ? "on" : "off"}`, +); diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh new file mode 100755 index 0000000..433534e --- /dev/null +++ b/scripts/bump_version.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Bump the static-asset cache-bust version in one shot. +# +# The version appears in two places that must stay in sync: +# - index.html -> style.css?v=N and app.js?v=N (browser HTTP cache) +# - sw.js -> CACHE = "chess-pressure-vN" (service worker cache, PWA) +# +# Run this whenever you change app.js / style.css (or any cached asset) so +# returning visitors pick up the new files instead of a stale cached copy. +# This matters always for the browser cache, and is *critical* when the PWA +# service worker is enabled (ENABLE_CHESS_PWA=1) — without a bump the SW keeps +# serving the old assets forever. +set -euo pipefail + +cd "$(dirname "$0")/.." +STATIC="src/chess_pressure/static" +INDEX="$STATIC/index.html" +SW="$STATIC/sw.js" + +cur=$(grep -oE '\?v=[0-9]+' "$INDEX" | head -1 | grep -oE '[0-9]+' || true) +if [ -z "${cur:-}" ]; then + echo "error: could not find a '?v=N' version in $INDEX" >&2 + exit 1 +fi +next=$((cur + 1)) + +sed -i -E "s/\?v=$cur\b/?v=$next/g" "$INDEX" +sed -i -E "s/chess-pressure-v$cur\b/chess-pressure-v$next/g" "$SW" + +echo "cache version bumped: v$cur -> v$next" +grep -nE '\?v=[0-9]+|chess-pressure-v[0-9]+' "$INDEX" "$SW" | sed 's/^/ /' diff --git a/src/chess_pressure/static/index.html b/src/chess_pressure/static/index.html index 1648768..ad3b558 100644 --- a/src/chess_pressure/static/index.html +++ b/src/chess_pressure/static/index.html @@ -4,8 +4,9 @@ Chess Pressure - + +
@@ -124,11 +125,20 @@ source + + + - + diff --git a/src/chess_pressure/static/manifest.json b/src/chess_pressure/static/manifest.json new file mode 100644 index 0000000..f127f77 --- /dev/null +++ b/src/chess_pressure/static/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Chess Pressure", + "short_name": "Pressure", + "description": "Visualize which squares white and black control across a chess game.", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a1a", + "theme_color": "#1a1a1a", + "icons": [ + { "src": "/img/chesspieces/wikipedia/wN.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/img/chesspieces/wikipedia/wN.png", "sizes": "512x512", "type": "image/png", "purpose": "any" } + ] +} diff --git a/src/chess_pressure/static/sw.js b/src/chess_pressure/static/sw.js new file mode 100644 index 0000000..f3987d9 --- /dev/null +++ b/src/chess_pressure/static/sw.js @@ -0,0 +1,71 @@ +/* Chess Pressure service worker — opt-in offline / instant repeat-visit cache. + * + * Only registered when window.CHESS_PWA is true (gated by the ENABLE_CHESS_PWA + * build flag). When active, repeat visits are served entirely from cache and + * never hit the server — which also means returning visitors won't appear in + * the Fly logs, so this is off by default. + * + * Bump CACHE on every deploy so clients pick up new assets. + */ +const CACHE = "chess-pressure-v26"; + +// Same-origin app shell. Cached by canonical path; the fetch handler matches +// with ignoreSearch so cache-busting query strings (?v=NN) still hit. +const CORE = [ + "/", + "/index.html", + "/app.js", + "/engine.js", + "/chess.min.js", + "/gif.js", + "/gif.worker.js", + "/style.css", + "/games.json", + "/manifest.json", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((c) => c.addAll(CORE)).then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + + const sameOrigin = new URL(req.url).origin === self.location.origin; + + // Navigations always resolve to the cached app shell (offline-first SPA). + if (req.mode === "navigate") { + event.respondWith( + caches.match("/index.html", { ignoreSearch: true }).then((r) => r || fetch(req)), + ); + return; + } + + // Everything else: cache-first, then network (and cache for next time). + event.respondWith( + caches.match(req, { ignoreSearch: sameOrigin }).then((cached) => { + if (cached) return cached; + return fetch(req) + .then((resp) => { + if (resp && (resp.ok || resp.type === "opaque")) { + const copy = resp.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return resp; + }) + .catch(() => cached); + }), + ); +});