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,
|
# 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 \
|
||||||
|
|||||||
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)
|
# 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"
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@ -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
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 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>
|
||||||
|
|||||||
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