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:
parent
85640e6f8d
commit
cf24009e31
22
Dockerfile
22
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 \
|
||||
|
||||
8
Makefile
8
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"
|
||||
|
||||
38
README.md
38
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
|
||||
|
||||
@ -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"}`,
|
||||
);
|
||||
|
||||
31
scripts/bump_version.sh
Executable file
31
scripts/bump_version.sh
Executable 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/^/ /'
|
||||
@ -4,8 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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="manifest" href="/manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -124,11 +125,20 @@
|
||||
<a href="https://github.com/mindthemath/chess-pressure">source</a>
|
||||
</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://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
|
||||
<script src="/chess.min.js"></script>
|
||||
<script src="/gif.js"></script>
|
||||
<script src="/engine.js"></script>
|
||||
<script src="/app.js?v=25"></script>
|
||||
<script src="/app.js?v=26"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
13
src/chess_pressure/static/manifest.json
Normal file
13
src/chess_pressure/static/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
71
src/chess_pressure/static/sw.js
Normal file
71
src/chess_pressure/static/sw.js
Normal 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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user