diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7ac40..68fc64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,46 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.7 — 2026-08-30 + +### Two releases shipped to a browser that never received them + +Jesse installed v0.7.5, clicked **Play solitaire**, and landed in a dealt game instead of the new +setup screen. v0.7.6 diagnosed that as a routing bug, fixed it, installed, verified — and it happened +again, identically. The second report is what made the real cause findable: the fix was correct both +times and neither one ever reached the browser. + +**`buildStamp()`'s no-git fallback was the literal `nogit`, and the `.s9pk` build has no git.** The +Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there on every +packaged build — and that string is not only the visible stamp, it is the cache-bust key every module +URL carries. So v0.7.4, v0.7.5 and v0.7.6 all published `./web/main.js?v=nogit`, byte-identical, and +a returning player's browser correctly concluded it had the file already. The fallback is now the +package version plus the build's own timestamp, which is always distinct and needs nothing from the +environment. Proven rather than assumed: two builds of an identical git-less tree now stamp +`0.7.6-mtf6l8rm` and `0.7.6-mtf6lant`. + +**And the server sent no `Cache-Control` at all**, which is the other half — the pages are the one +thing that cannot be versioned in their own URL, since a player types the address or follows a +bookmark, so a cached `play.html` pins that player to the whole build it names including every `?v=` +inside it. Fixed the exact way round that matters: a request carrying `?v=` may be stored for a year +and marked `immutable`, and anything else is `no-cache`. `?v=` rather than "not HTML" because +`build-web.ts` tags the modules and nothing else — a year of `immutable` on an untagged image or on +the replay manifest would outlive several releases of it. + +Neither half is sufficient alone: without the varying tag there is nothing for a fresh page to point +at, and without the header the fresh page is itself served from cache. + +**What this says about the two releases before it.** v0.7.5's setup screen and v0.7.6's door fix were +both real, both correct, and both verified on `phoenix.local` by reading what the server served — +which was true, and was never the thing in doubt. What went unverified was the browser, and a +hard-reload would have told us on the first report. Worth remembering the next time a fix "has had no +effect": check that it arrived before re-diagnosing it. + +864 tests pass, two of them new — one pinning the no-git fallback as something that varies per build, +one pinning the header rule and that the `?v=` flag actually reaches `serveStatic`. + +--- + ## 0.7.6 — 2026-08-29 ### The solitaire door could not reach solitaire diff --git a/TODO.md b/TODO.md index c38e38c..dcf0a4e 100644 --- a/TODO.md +++ b/TODO.md @@ -323,10 +323,19 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main before ever looking at solitaire's own state, and a bare `./play.html` load could not tell "I clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem `?lobby` already solved for the door on the other side (D11), just never applied to this one. The - door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup. Still - not verified past that: nobody has clicked all the way through the setup screen's own fields and - confirmed the dealt game matches what was chosen. Worth being an early item in the next play - session, alongside #39's four unplayed v0.7.4 features. + door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup. + + **And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every + packaged build published the same cache-bust key (`?v=nogit`, because the `.s9pk` build has no + `.git` for `git rev-parse`), and the server sent no `Cache-Control` at all, so neither release + ever reached the browser that asked for it. Both earlier fixes were correct and both were + verified by reading what the SERVER served — which was true and was never the thing in doubt. + **The lesson worth keeping: when a fix appears to have had no effect, check that it arrived + before re-diagnosing it.** A hard-reload would have answered it on the first report. + + Still not verified past that: nobody has clicked all the way through the setup screen's own + fields and confirmed the dealt game matches what was chosen. Worth being an early item in the + next play session, alongside #39's four unplayed v0.7.4 features. --- diff --git a/package.json b/package.json index 689c8f4..6bfb305 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.6", + "version": "0.7.7", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 0aba2a3..7a3110b 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -60,7 +60,21 @@ execFileSync( */ function buildStamp(): string { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }; - let git = 'nogit'; + /** + * THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key. + * + * It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the + * Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time. + * Every packaged release therefore published `?v=nogit`, byte-identical to the release before it, + * and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen + * and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser + * that asked for them (Jesse, twice, 2026-08-29 — "setup did not work"). + * + * The version plus the build's own timestamp is always distinct, needs nothing from the + * environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key + * that says so costs one refetch, while one that lies costs a release nobody receives. + */ + let git = `${pkg.version}-${Date.now().toString(36)}`; try { const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }) .toString() diff --git a/src/server/http.ts b/src/server/http.ts index 68b5051..db28527 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { res.end(text); } -async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise { +async function serveStatic( + distDir: string, + urlPath: string, + res: ServerResponse, + /** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */ + buildTagged = false, +): Promise { const rel = urlPath === '/' ? '/index.html' : urlPath; // `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request // for `/../../etc/passwd` must not escape the one directory this is allowed to read from. @@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse try { const info = await stat(full); if (!info.isFile()) throw new Error('not a file'); - res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size }); + /** + * ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE. + * + * Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to + * the pages as much as the modules. The pages are the one thing that CANNOT be versioned in + * their own URL — a player types the address or follows a bookmark — so a cached `play.html` + * pins that player to the entire build it names, including every `?v=` tag inside it. That is + * half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts` + * publishing `?v=nogit` on every packaged release was the other half, and neither is enough on + * its own. + * + * `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the + * script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay + * manifest — has no way to announce a change, so a year of `immutable` on one would outlive + * several releases of whatever it holds. + */ + res.writeHead(200, { + 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', + 'Content-Length': info.size, + 'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache', + }); createReadStream(full).pipe(res); } catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); @@ -281,7 +307,7 @@ export function startServer(opts: ServerOptions): void { // Unset means the routes are not here — indistinguishable from any other unknown path, so // nothing advertises an administrative surface to someone probing for one. if (!opts.adminSecret) { - await serveStatic(opts.distDir, url.pathname, res); + await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v')); return; } if (req.headers['x-admin-secret'] !== opts.adminSecret) { @@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void { return; } - await serveStatic(opts.distDir, url.pathname, res); + await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v')); })().catch((err: unknown) => { sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' }); }); diff --git a/test/web.test.ts b/test/web.test.ts index f3c97cd..df94f49 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -2628,6 +2628,36 @@ describe('the static build', () => { assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays'); }); + it('cache-busts with a tag that varies per build even where there is no git', () => { + /** + * THE BUG THIS PINS COST TWO RELEASES. `buildStamp`'s no-git fallback was the literal `nogit`, + * and the `.s9pk` Dockerfile copies the working tree in WITHOUT `.git` — so every packaged + * release published `?v=nogit`, byte-identical to the one before it, and a returning player's + * browser refetched nothing. v0.7.5's setup screen and v0.7.6's fix to it both installed + * correctly on `phoenix.local` and neither reached the browser that asked for them. + * + * Asserted against the SCRIPT rather than a built page, because the property is about what the + * fallback does when `git rev-parse` fails, which a normal build here never exercises. + */ + const src = readFileSync(join(root, 'scripts/build-web.ts'), 'utf8'); + const fallback = /let git = ([^;]+);/.exec(src)?.[1] ?? ''; + assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more'); + assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again'); + assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build'); + }); + + it('lets a build-tagged URL be cached and nothing else', () => { + // The other half of the same bug: the pages carry the `?v=` tags but cannot be versioned in + // their own URL, so a cached `play.html` pins a player to the whole build it names. Only a + // request that actually carries `?v=` may be stored — an untagged image or the replay manifest + // has no way to announce a change. + const src = readFileSync(join(root, 'src/server/http.ts'), 'utf8'); + assert.match(src, /'Cache-Control':\s*buildTagged\s*\?/, 'static responses no longer vary their caching'); + assert.match(src, /immutable/, 'a tagged asset is not allowed to be cached at all'); + assert.match(src, /serveStatic\(opts\.distDir, url\.pathname, res, url\.searchParams\.has\('v'\)\)/, + 'the ?v= tag is not reaching serveStatic, so every response falls back to no-cache'); + }); + it('opens the multiplayer door from the splash, straight into the lobby', () => { // This door sat `disabled` and labelled "Coming soon" from before the server existed until // v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a