v0.7.7 — two releases shipped to a browser that never received them
buildStamp()'s no-git fallback was the literal "nogit", and the .s9pk Dockerfile copies the tree in without .git — so git rev-parse fails on every packaged build. That string is also 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 returning browsers refetched nothing. v0.7.5's setup screen and v0.7.6's door fix were both correct and neither arrived. The fallback is now the package version plus the build timestamp, always distinct. And serveStatic sent no Cache-Control at all, which is the other half — a cached play.html pins a player to the whole build it names. A request carrying ?v= is now immutable for a year; everything else is no-cache. ?v= rather than "not HTML" because build-web.ts tags the modules and nothing else. Neither half is sufficient alone. 864 tests pass, two new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b4f09f05cb
commit
af68aac78d
@@ -19,6 +19,46 @@ page as `v0.1.0 · <sha> · <date>`, 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
|
## 0.7.6 — 2026-08-29
|
||||||
|
|
||||||
### The solitaire door could not reach solitaire
|
### The solitaire door could not reach solitaire
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
`?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
|
door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup.
|
||||||
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
|
**And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every
|
||||||
session, alongside #39's four unplayed v0.7.4 features.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.6",
|
"version": "0.7.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+15
-1
@@ -60,7 +60,21 @@ execFileSync(
|
|||||||
*/
|
*/
|
||||||
function buildStamp(): string {
|
function buildStamp(): string {
|
||||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: 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 {
|
try {
|
||||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||||
.toString()
|
.toString()
|
||||||
|
|||||||
+30
-4
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|||||||
res.end(text);
|
res.end(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
|
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<void> {
|
||||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||||
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
// `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.
|
// 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 {
|
try {
|
||||||
const info = await stat(full);
|
const info = await stat(full);
|
||||||
if (!info.isFile()) throw new Error('not a file');
|
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);
|
createReadStream(full).pipe(res);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
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
|
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||||
// nothing advertises an administrative surface to someone probing for one.
|
// nothing advertises an administrative surface to someone probing for one.
|
||||||
if (!opts.adminSecret) {
|
if (!opts.adminSecret) {
|
||||||
await serveStatic(opts.distDir, url.pathname, res);
|
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||||
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await serveStatic(opts.distDir, url.pathname, res);
|
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||||
})().catch((err: unknown) => {
|
})().catch((err: unknown) => {
|
||||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2628,6 +2628,36 @@ describe('the static build', () => {
|
|||||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
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', () => {
|
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
|
// 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
|
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
|
||||||
|
|||||||
Reference in New Issue
Block a user