Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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