feat: opt-in PWA/offline behind ENABLE_CHESS_PWA flag; add cache-version bump

- sw.js + manifest.json: cache-first service worker for instant repeat-visit
  loads and full offline use. Registered only when window.CHESS_PWA is true.
- Disabled by default: an active SW serves returning visitors from cache, so
  they'd stop hitting the server and disappear from the Fly logs. Enable at
  build time with --build-arg ENABLE_CHESS_PWA=1 (Dockerfile flips the flag);
  ENABLE_CHESS_PWA=1 make dev does the same locally.
- scripts/bump_version.sh + 'make bump': bump the asset cache version across
  index.html (?v=N) and sw.js (CACHE) in sync. Required before deploy so the
  service worker / browser cache pick up changed assets. Bumped v25 -> v26.
- README: document cache versioning + PWA flag.
This commit is contained in:
Michael Pilosov 2026-06-25 20:47:19 +00:00
parent 85640e6f8d
commit cf24009e31
8 changed files with 204 additions and 10 deletions

View File

@ -1,8 +1,26 @@
# Fully client-side app — production is just static files served by a tiny, # 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. # 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 \ ENV SERVER_HOST=0.0.0.0 \
SERVER_PORT=8888 \ SERVER_PORT=8888 \

View File

@ -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) # Static dev server (client-side app; refresh picks up edits)
dev: dev:
@ -18,6 +18,11 @@ parity:
uv run python parity/dump_python.py > parity/.py_frames.json uv run python parity/dump_python.py > parity/.py_frames.json
cd parity && bun run check.js 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
lint: fmt lint: fmt
uvx ruff check src/chess_pressure/ uvx ruff check src/chess_pressure/
@ -59,6 +64,7 @@ help:
@echo " make serve optional Python fallback server (:8888)" @echo " make serve optional Python fallback server (:8888)"
@echo " make games regenerate static/games.json from games.py" @echo " make games regenerate static/games.json from games.py"
@echo " make parity verify JS engine matches python-chess" @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 lint ruff format + check"
@echo " make fmt ruff format + auto-fix" @echo " make fmt ruff format + auto-fix"
@echo " make build build sdist + wheel" @echo " make build build sdist + wheel"

View File

@ -59,6 +59,7 @@ Other useful targets:
make serve # optional Python fallback server (also serves the static app) make serve # optional Python fallback server (also serves the static app)
make games # regenerate static/games.json after editing games.py make games # regenerate static/games.json after editing games.py
make parity # verify the JS engine matches python-chess 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 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 `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. 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) ## API (optional Python server)
The static app does not use these, but `app.py` still exposes them: 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) engine.js # Browser port of engine.py (chess.js)
chess.min.js # Vendored chess.js 1.4.0 (bundled global) chess.min.js # Vendored chess.js 1.4.0 (bundled global)
games.json # Built-in games, generated from games.py 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) app.js, ... # Frontend (HTML, JS, CSS, piece images)
scripts/export_games.py # games.py -> static/games.json 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 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 ## License

View File

@ -8,6 +8,7 @@
const ROOT = new URL("./src/chess_pressure/static/", import.meta.url).pathname; const ROOT = new URL("./src/chess_pressure/static/", import.meta.url).pathname;
const PORT = Number(process.env.PORT || 8888); const PORT = Number(process.env.PORT || 8888);
const PWA = process.env.ENABLE_CHESS_PWA === "1"; // mirror the Docker build flag
Bun.serve({ Bun.serve({
port: PORT, port: PORT,
@ -18,11 +19,19 @@ Bun.serve({
// Strip leading slashes and neutralize any ".." path traversal. // Strip leading slashes and neutralize any ".." path traversal.
const rel = pathname.replace(/^\/+/, "").replace(/\.\.(\/|\\|$)/g, ""); const rel = pathname.replace(/^\/+/, "").replace(/\.\.(\/|\\|$)/g, "");
const file = Bun.file(ROOT + rel); const file = Bun.file(ROOT + rel);
if (await file.exists()) { if (!(await file.exists())) return new Response("Not found", { status: 404 });
return new Response(file, { headers: { "Cache-Control": "no-cache" } });
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"}`,
);

31
scripts/bump_version.sh Executable file
View File

@ -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/^/ /'

View File

@ -4,8 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chess Pressure</title> <title>Chess Pressure</title>
<link rel="stylesheet" href="/style.css?v=25"> <link rel="stylesheet" href="/style.css?v=26">
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css"> <link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
<link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
<header> <header>
@ -124,11 +125,20 @@
<a href="https://github.com/mindthemath/chess-pressure">source</a> <a href="https://github.com/mindthemath/chess-pressure">source</a>
</footer> </footer>
<!-- PWA flag — flipped to true at build time by ENABLE_CHESS_PWA=1 (off by default). -->
<script>window.CHESS_PWA = false;</script>
<script>
if (window.CHESS_PWA && "serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
</script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script> <script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
<script src="/chess.min.js"></script> <script src="/chess.min.js"></script>
<script src="/gif.js"></script> <script src="/gif.js"></script>
<script src="/engine.js"></script> <script src="/engine.js"></script>
<script src="/app.js?v=25"></script> <script src="/app.js?v=26"></script>
</body> </body>
</html> </html>

View File

@ -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" }
]
}

View File

@ -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);
}),
);
});