修复:部署无法到达已打开的页面(新增构建版本标记 + 陈旧页面自动重载) (#39)
This commit is contained in:
1 parent
9d404199df
commit
e90e7a30fc
8 files changed
+450
-10
No files matched your search
@@ -45,6 +45,7 @@ import { GuideHost } from './ui/guide.js';
|
|||||||
import { installDeviceSupport } from './ui/device.js';
|
import { installDeviceSupport } from './ui/device.js';
|
||||||
import { LoadoutHost } from './screens/loadout.js';
|
import { LoadoutHost } from './screens/loadout.js';
|
||||||
import { installLoadoutSync } from './ui/loadoutSync.js';
|
import { installLoadoutSync } from './ui/loadoutSync.js';
|
||||||
|
import { startBuildGuard } from './ui/buildGuard.js';
|
||||||
|
|
||||||
const RESTORE_GRACE_MS = 1500;
|
const RESTORE_GRACE_MS = 1500;
|
||||||
const JOIN_DELAY_MS = 350;
|
const JOIN_DELAY_MS = 350;
|
||||||
@@ -351,6 +352,18 @@ async function boot() {
|
|||||||
setTimeout(() => splash.remove(), 300);
|
setTimeout(() => splash.remove(), 300);
|
||||||
}
|
}
|
||||||
globalThis.__SP__ = { store, net, data, version: 1 };
|
globalThis.__SP__ = { store, net, data, version: 1 };
|
||||||
|
// A page keeps the modules it imported at load time for its whole lifetime, so a deploy cannot reach an open tab
|
||||||
|
// (ui/buildGuard.js): watch `/healthz.build`. Outside a match the page reloads itself; during a match the guard says
|
||||||
|
// so instead (the connection banner offers 刷新页面) and reloads once the match — settlement screen included — is over,
|
||||||
|
// so a running game is never thrown away.
|
||||||
|
try {
|
||||||
|
startBuildGuard({
|
||||||
|
inMatch: () => selectRoute(store.get()) === 'game',
|
||||||
|
onStale: ({ waiting }) => { if (waiting) store.patch('ui', { buildStale: true }); },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[app] build guard failed to start', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
boot().catch((err) => {
|
boot().catch((err) => {
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ export const initialState = Object.freeze({
|
|||||||
ticker: [],
|
ticker: [],
|
||||||
emotes: [],
|
emotes: [],
|
||||||
clock: { offset: 0, rtt: null, synced: false },
|
clock: { offset: 0, rtt: null, synced: false },
|
||||||
ui: { pendingJoin: null, restoring: false },
|
ui: { pendingJoin: null, restoring: false, buildStale: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The app-wide store singleton. */
|
/** The app-wide store singleton. */
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// public/js/ui/buildGuard.js — the "this page is running an outdated build" guard.
|
||||||
|
//
|
||||||
|
// WHY: every module a page imports lives in its module map for the whole page lifetime, so a deploy can never reach an
|
||||||
|
// already-open tab — not even a client-only one, which is served `Cache-Control: no-cache` and would be picked up by a
|
||||||
|
// reload. A client-only battle fix shipped exactly that way and stayed invisible on the reporting player's page: the
|
||||||
|
// nginx log shows the page loaded the PRE-fix module blob and made no module request at all for the rest of the session.
|
||||||
|
//
|
||||||
|
// HOW: the server stamps a short hash of the runtime it serves into `/healthz.build` (server/index.js computeBuildTag).
|
||||||
|
// The first successful check of a page records that tag as "the build this page runs" — IN MEMORY, because one page is
|
||||||
|
// one set of loaded modules: nothing must survive a reload, and a duplicated tab must not inherit the first tab's tag.
|
||||||
|
// A later check reporting a DIFFERENT tag means the server changed under this page:
|
||||||
|
// * outside a match the page reloads itself, once;
|
||||||
|
// * during a match the guard never throws the player's game away — `onStale` fires (the connection banner offers
|
||||||
|
// 刷新页面) and the reload happens by itself as soon as the match, settlement screen included, is over.
|
||||||
|
// A check that cannot read a build (`/healthz` down or restarting, no `build` field, no answer in time) is 'unknown' and
|
||||||
|
// NEVER reloads: a restarting server must not turn a playing page into the browser's error page. A new tag is acted on
|
||||||
|
// only after two checks in a row report the SAME one, so a deploy that is still copying files cannot ping-pong a page.
|
||||||
|
//
|
||||||
|
// Kept dependency-free and injectable (fetch / reload / inMatch / timers) so test/ui/buildGuard.test.js can drive it.
|
||||||
|
|
||||||
|
/** How often a page re-asks the server for its build tag. */
|
||||||
|
export const BUILD_CHECK_MS = 60_000;
|
||||||
|
/** Give up on a `/healthz` that does not answer: a request left hanging must not stop the guard from ever checking again. */
|
||||||
|
export const BUILD_FETCH_TIMEOUT_MS = 5_000;
|
||||||
|
/** How many checks in a row must report the same NEW build before the page acts on it. */
|
||||||
|
export const BUILD_CONFIRMATIONS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch `/healthz` and return its `build` tag (null when unavailable, not reported, or too slow). Never throws.
|
||||||
|
* @param {Function} fetchFn @param {{ timeoutMs?: number, setTimeout?: Function, clearTimeout?: Function }} [o]
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
export async function fetchBuild(fetchFn, o = {}) {
|
||||||
|
const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : BUILD_FETCH_TIMEOUT_MS;
|
||||||
|
const setT = o.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
|
||||||
|
const clearT = o.clearTimeout || ((h) => globalThis.clearTimeout(h));
|
||||||
|
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
|
let timer = null;
|
||||||
|
const tooSlow = new Promise((_, reject) => {
|
||||||
|
timer = setT(() => { try { ctrl?.abort(); } catch { /* ignore */ } reject(new Error('timeout')); }, timeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await Promise.race([fetchFn('/healthz', { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }), tooSlow]);
|
||||||
|
if (!res || !res.ok) return null;
|
||||||
|
const body = await res.json();
|
||||||
|
return body && typeof body.build === 'string' && body.build ? body.build : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (timer != null) clearT(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One check, against the build this page already knows.
|
||||||
|
* @param {{ fetchFn?: Function, known?: string|null, timeoutMs?: number, setTimeout?: Function, clearTimeout?: Function }} [o]
|
||||||
|
* @returns {Promise<{ status: 'first'|'current'|'new'|'unknown', build: string|null }>}
|
||||||
|
*/
|
||||||
|
export async function checkBuildOnce(o = {}) {
|
||||||
|
const fetchFn = o.fetchFn || ((url, init) => globalThis.fetch(url, init));
|
||||||
|
const build = await fetchBuild(fetchFn, o);
|
||||||
|
if (!build) return { status: 'unknown', build: null }; // unreachable / not reported: never a reload
|
||||||
|
if (!o.known) return { status: 'first', build };
|
||||||
|
return { status: o.known === build ? 'current' : 'new', build };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for a new build.
|
||||||
|
* @param {{ fetchFn?: Function, reload?: Function, inMatch?: () => boolean, intervalMs?: number, timeoutMs?: number,
|
||||||
|
* setInterval?: Function, clearInterval?: Function, setTimeout?: Function, clearTimeout?: Function,
|
||||||
|
* onStale?: (info: { build: string, known: string|null, waiting: boolean }) => void }} [o]
|
||||||
|
* @returns {{ stop: () => void, check: () => Promise<object>, stale: () => boolean, known: () => string|null }}
|
||||||
|
*/
|
||||||
|
export function startBuildGuard(o = {}) {
|
||||||
|
const fetchFn = o.fetchFn || ((url, init) => globalThis.fetch(url, init));
|
||||||
|
const reload = o.reload || (() => globalThis.location.reload());
|
||||||
|
const inMatch = typeof o.inMatch === 'function' ? o.inMatch : () => false;
|
||||||
|
const setIv = o.setInterval || ((fn, ms) => globalThis.setInterval(fn, ms));
|
||||||
|
const clearIv = o.clearInterval || ((h) => globalThis.clearInterval(h));
|
||||||
|
const intervalMs = Number.isFinite(o.intervalMs) && o.intervalMs > 0 ? o.intervalMs : BUILD_CHECK_MS;
|
||||||
|
let known = null; // the build this page runs — memory only (one page == one set of loaded modules)
|
||||||
|
let candidate = null; // the last new build seen; needs BUILD_CONFIRMATIONS checks in a row to be acted on
|
||||||
|
let seen = 0; // consecutive checks that reported `candidate`
|
||||||
|
let stale = false; // a new build is confirmed → reload as soon as no match is on screen
|
||||||
|
let pending = false;
|
||||||
|
let timer = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const stopTimer = () => { if (timer != null) { clearIv(timer); timer = null; } };
|
||||||
|
const reloadNow = () => { stopped = true; stopTimer(); reload(); };
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
if (stopped) return { status: 'stopped', build: null };
|
||||||
|
if (pending) return { status: 'pending', build: null };
|
||||||
|
pending = true;
|
||||||
|
let r;
|
||||||
|
try { r = await checkBuildOnce({ fetchFn, known, timeoutMs: o.timeoutMs, setTimeout: o.setTimeout, clearTimeout: o.clearTimeout }); }
|
||||||
|
finally { pending = false; }
|
||||||
|
if (stopped) return { status: 'stopped', build: null };
|
||||||
|
if (r.status === 'unknown') { candidate = null; seen = 0; return r; } // review: a failed check never reloads
|
||||||
|
if (r.status === 'first') { known = r.build; return r; }
|
||||||
|
if (r.status === 'current') { candidate = null; seen = 0; return r; }
|
||||||
|
// a different build: only BUILD_CONFIRMATIONS checks in a row with the SAME tag count (a deploy that is still
|
||||||
|
// copying files exposes a tag that keeps moving, and must not ping-pong the page)
|
||||||
|
if (candidate !== r.build) { candidate = r.build; seen = 1; } else { seen++; }
|
||||||
|
if (seen < BUILD_CONFIRMATIONS) return { status: 'new', build: r.build };
|
||||||
|
const first = !stale;
|
||||||
|
stale = true;
|
||||||
|
const waiting = inMatch();
|
||||||
|
if (first) { try { o.onStale?.({ build: r.build, known, waiting }); } catch { /* ignore */ } }
|
||||||
|
if (!waiting) reloadNow();
|
||||||
|
return { status: 'stale', build: r.build, known };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => { check().catch(() => {}); };
|
||||||
|
check().catch(() => {});
|
||||||
|
timer = setIv(tick, intervalMs);
|
||||||
|
return {
|
||||||
|
stop: () => { stopped = true; stopTimer(); },
|
||||||
|
check,
|
||||||
|
stale: () => stale,
|
||||||
|
known: () => known,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// Connection banner (global chrome, mounted once by main.js): reconnecting / closed / rejected-hello states with
|
// Connection banner (global chrome, mounted once by main.js): reconnecting / closed / rejected-hello states with
|
||||||
// the matching action, and "正在同步同盟状态…" while a resumed session waits for its room/match state. Outside a
|
// the matching action, "正在同步同盟状态…" while a resumed session waits for its room/match state, and "服务器已更新"
|
||||||
// match it sits at the bottom centre; in a match (html.sp-in-match, set by the match screen) it moves under the top bar
|
// while a match still runs on a page the server has moved past (ui/buildGuard.js sets ui.buildStale). Outside a
|
||||||
// so it never covers the combat view switcher or the shop bar. While it shows, html.sp-conn moves the toasts below it
|
// match the banner sits at the bottom centre; in a match (html.sp-in-match, set by the match screen) it moves under the
|
||||||
// (classes instead of CSS :has(), which Firefox ESR / Safari < 15.4 lack).
|
// top bar so it never covers the combat view switcher or the shop bar. While it shows, html.sp-conn moves the toasts
|
||||||
|
// below it (classes instead of CSS :has(), which Firefox ESR / Safari < 15.4 lack).
|
||||||
|
|
||||||
import { html, Button, Icon, useTicker } from './components.js';
|
import { html, Button, Icon, useTicker } from './components.js';
|
||||||
import { net, CLIENT_ERR_TEXT } from '../net.js';
|
import { net, CLIENT_ERR_TEXT } from '../net.js';
|
||||||
@@ -10,9 +11,9 @@ import { useStore, shallowEqual } from '../store.js';
|
|||||||
import { useDocClass } from './device.js';
|
import { useDocClass } from './device.js';
|
||||||
|
|
||||||
/** Whether the banner shows for this connection state (mirrors the early returns below). */
|
/** Whether the banner shows for this connection state (mirrors the early returns below). */
|
||||||
export function bannerVisible(conn, entered, restoring) {
|
export function bannerVisible(conn, entered, restoring, buildStale = false) {
|
||||||
if (!entered || !conn) return false;
|
if (!entered || !conn) return false;
|
||||||
if (conn.status === 'online') return !!restoring;
|
if (conn.status === 'online') return !!restoring || !!buildStale;
|
||||||
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return false;
|
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -21,13 +22,23 @@ export function ConnectionBanner() {
|
|||||||
const conn = useStore((s) => s.connection, shallowEqual);
|
const conn = useStore((s) => s.connection, shallowEqual);
|
||||||
const entered = useStore((s) => s.session.entered);
|
const entered = useStore((s) => s.session.entered);
|
||||||
const restoring = useStore((s) => s.ui.restoring);
|
const restoring = useStore((s) => s.ui.restoring);
|
||||||
|
const buildStale = useStore((s) => !!s.ui.buildStale);
|
||||||
useTicker(conn.status === 'reconnecting' ? 500 : 0);
|
useTicker(conn.status === 'reconnecting' ? 500 : 0);
|
||||||
useDocClass('sp-conn', bannerVisible(conn, entered, restoring));
|
useDocClass('sp-conn', bannerVisible(conn, entered, restoring, buildStale));
|
||||||
if (!entered) return null;
|
if (!entered) return null;
|
||||||
if (conn.status === 'online' && !restoring) return null;
|
if (conn.status === 'online' && !restoring && !buildStale) return null;
|
||||||
if (conn.status === 'online' && restoring) {
|
if (conn.status === 'online' && restoring) {
|
||||||
return html`<div class="conn-banner" role="status"><${Icon} name="refresh" /><span>正在同步同盟状态…</span></div>`;
|
return html`<div class="conn-banner" role="status"><${Icon} name="refresh" /><span>正在同步同盟状态…</span></div>`;
|
||||||
}
|
}
|
||||||
|
if (conn.status === 'online' && buildStale) {
|
||||||
|
// the server has a newer build than this page: the guard reloads by itself once the match is over, the button is
|
||||||
|
// for a player who would rather do it now
|
||||||
|
return html`<div class="conn-banner" role="alert">
|
||||||
|
<${Icon} name="refresh" />
|
||||||
|
<span>服务器已更新,本局结束后刷新</span>
|
||||||
|
<${Button} size="sm" variant="secondary" icon="refresh" onClick=${() => location.reload()}>刷新页面<//>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null;
|
if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null;
|
||||||
const secs = conn.retryAt ? Math.max(0, Math.ceil((conn.retryAt - Date.now()) / 1000)) : 0;
|
const secs = conn.retryAt ? Math.max(0, Math.ceil((conn.retryAt - Date.now()) / 1000)) : 0;
|
||||||
const replaced = conn.status === 'closed' && conn.lastError?.code === 'REPLACED';
|
const replaced = conn.status === 'closed' && conn.lastError?.code === 'REPLACED';
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import fsp from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -118,6 +119,70 @@ const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
|
|||||||
const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/
|
const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/
|
||||||
const MAX_URL_LENGTH = 4096;
|
const MAX_URL_LENGTH = 4096;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------
|
||||||
|
// build tag — the "your page is stale" signal (public/js/ui/buildGuard.js)
|
||||||
|
// ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files that make up the runtime the BROWSER loads. A change in any of them is a new build: an already-open page
|
||||||
|
* keeps the modules it imported at load time (ES modules live in the page's module map for its whole lifetime), so
|
||||||
|
* without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix
|
||||||
|
* shipped exactly that way and stayed invisible on a page that had been opened before the deploy.
|
||||||
|
*
|
||||||
|
* `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they
|
||||||
|
* change without a restart the server still runs the old simulation and data — a page that reloaded into the new files
|
||||||
|
* would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update).
|
||||||
|
*/
|
||||||
|
export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']);
|
||||||
|
|
||||||
|
/** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */
|
||||||
|
const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~');
|
||||||
|
|
||||||
|
/** @type {{ tag: string|null }|null} */
|
||||||
|
let buildCache = null;
|
||||||
|
|
||||||
|
/** Every file under `abs` (or `abs` itself), as `[relative path, size, mtimeMs]`, sorted by path. Missing → []. */
|
||||||
|
function buildEntries(abs, rel, out) {
|
||||||
|
let stat;
|
||||||
|
try { stat = fs.statSync(abs); } catch { return; }
|
||||||
|
if (stat.isFile()) { out.push([rel, stat.size, stat.mtimeMs]); return; }
|
||||||
|
if (!stat.isDirectory()) return;
|
||||||
|
let names;
|
||||||
|
try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
||||||
|
for (const d of names.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
||||||
|
if (isIgnoredBuildName(d.name)) continue;
|
||||||
|
const child = path.join(abs, d.name);
|
||||||
|
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||||
|
if (d.isDirectory()) buildEntries(child, childRel, out);
|
||||||
|
else if (d.isFile()) { try { const s = fs.statSync(child); out.push([childRel, s.size, s.mtimeMs]); } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short hash of the served browser runtime (size + mtime of every BUILD_INPUTS file); null when nothing is readable. */
|
||||||
|
export function computeBuildTag(root = ROOT) {
|
||||||
|
const out = [];
|
||||||
|
for (const rel of BUILD_INPUTS) buildEntries(path.join(root, rel), rel, out);
|
||||||
|
if (!out.length) return null;
|
||||||
|
out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||||
|
const h = createHash('sha1');
|
||||||
|
for (const [rel, size, mtime] of out) h.update(`${rel}\0${size}\0${Math.floor(mtime)}\n`);
|
||||||
|
return h.digest('hex').slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The build tag of THIS process. Computed once (`startServer` warms it at startup): the tag describes the files the
|
||||||
|
* process is actually serving, every update restarts the server (DEPLOY.md), and re-reading the tree on a timer would
|
||||||
|
* let a half-finished deploy — or a file that changed while the process kept running — move the tag under a page.
|
||||||
|
* @param {string} [root] used by the first call only (tests)
|
||||||
|
*/
|
||||||
|
export function buildTag(root = ROOT) {
|
||||||
|
if (buildCache === null) buildCache = { tag: computeBuildTag(root) };
|
||||||
|
return buildCache.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cache: the next `buildTag()` re-reads the tree (tests, and `startServer`). */
|
||||||
|
export function resetBuildTag() { buildCache = null; }
|
||||||
|
|
||||||
const gzipAsync = promisify(zlib.gzip);
|
const gzipAsync = promisify(zlib.gzip);
|
||||||
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||||
|
|
||||||
@@ -572,6 +637,9 @@ export async function startServer(opts = {}) {
|
|||||||
const network = new Network({ registry, handler: lobby, log, options: netOptions });
|
const network = new Network({ registry, handler: lobby, log, options: netOptions });
|
||||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, log });
|
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, log });
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||||
|
resetBuildTag();
|
||||||
|
buildTag();
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
@@ -595,6 +663,9 @@ export async function startServer(opts = {}) {
|
|||||||
if (parts.rawPath === '/healthz') {
|
if (parts.rawPath === '/healthz') {
|
||||||
sendJson(req, res, 200, {
|
sendJson(req, res, 200, {
|
||||||
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||||
|
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
|
||||||
|
// older than this reloads itself, so a deploy reaches clients that never reload
|
||||||
|
build: buildTag(),
|
||||||
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
|
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// test/build.test.js — the served-runtime build tag (server/index.js computeBuildTag / buildTag): the signal that lets
|
||||||
|
// an already-open page notice a deploy (public/js/ui/buildGuard.js; /healthz `build`).
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { computeBuildTag, buildTag, resetBuildTag, BUILD_INPUTS } from '../server/index.js';
|
||||||
|
|
||||||
|
/** A throwaway root with the browser runtime layout (one file per BUILD_INPUTS entry). */
|
||||||
|
function fixture() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-'));
|
||||||
|
for (const rel of BUILD_INPUTS) {
|
||||||
|
const abs = path.join(root, rel);
|
||||||
|
if (path.extname(rel)) {
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, 'x');
|
||||||
|
} else {
|
||||||
|
fs.mkdirSync(abs, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(abs, 'file.js'), 'x');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('BUILD_INPUTS: only the runtime the browser loads (server/, data/ and shared/ are not part of it)', () => {
|
||||||
|
assert.deepEqual([...BUILD_INPUTS], ['public/index.html', 'public/js', 'public/css']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeBuildTag: stable for one tree, different when a runtime file changes (size or mtime)', () => {
|
||||||
|
const root = fixture();
|
||||||
|
try {
|
||||||
|
const a = computeBuildTag(root);
|
||||||
|
assert.match(a, /^[0-9a-f]{12}$/);
|
||||||
|
assert.equal(computeBuildTag(root), a, 'same tree → same tag');
|
||||||
|
// a changed file (new size) is a new build
|
||||||
|
fs.writeFileSync(path.join(root, 'public/js/file.js'), 'yy');
|
||||||
|
const b = computeBuildTag(root);
|
||||||
|
assert.notEqual(b, a, 'a changed runtime file is a new build');
|
||||||
|
// …and so is a rewrite with the same size but a new mtime (a deploy of identical bytes keeps its timestamp)
|
||||||
|
const target = path.join(root, 'public/css/file.js');
|
||||||
|
const st = fs.statSync(target);
|
||||||
|
fs.utimesSync(target, st.atime, new Date(st.mtimeMs + 5000));
|
||||||
|
assert.notEqual(computeBuildTag(root), b);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeBuildTag: dot files and editor backups are ignored (the static server never serves them)', () => {
|
||||||
|
const root = fixture();
|
||||||
|
try {
|
||||||
|
const a = computeBuildTag(root);
|
||||||
|
fs.writeFileSync(path.join(root, 'public/js/.DS_Store'), 'x');
|
||||||
|
fs.writeFileSync(path.join(root, 'public/css/main.css~'), 'x');
|
||||||
|
fs.mkdirSync(path.join(root, 'public/js/.cache'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, 'public/js/.cache/leftover.js'), 'x');
|
||||||
|
assert.equal(computeBuildTag(root), a, 'a .DS_Store / backup / dot directory in the tree is not a new build');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeBuildTag: nothing readable → null (an unknown build never reloads a page)', () => {
|
||||||
|
assert.equal(computeBuildTag(path.join(os.tmpdir(), 'sp-does-not-exist-xyz')), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTag: computed once per process — a file that changes afterwards does not move it until the next process', () => {
|
||||||
|
const root = fixture();
|
||||||
|
try {
|
||||||
|
resetBuildTag();
|
||||||
|
const first = buildTag(root);
|
||||||
|
assert.match(String(first), /^[0-9a-f]{12}$/);
|
||||||
|
fs.writeFileSync(path.join(root, 'public/js/file.js'), 'changed while the process ran');
|
||||||
|
assert.equal(buildTag(root), first, 'the tag describes what this process is serving, not whatever appears on disk');
|
||||||
|
resetBuildTag(); // a restart recomputes it
|
||||||
|
assert.notEqual(buildTag(root), first);
|
||||||
|
} finally {
|
||||||
|
resetBuildTag();
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// test/ui/buildGuard.test.js — public/js/ui/buildGuard.js: the "this page runs an outdated build" guard that lets a
|
||||||
|
// deploy reach a tab that was already open (a page keeps its imported ES modules for its whole lifetime — the reason a
|
||||||
|
// client-only battle fix stayed invisible on the reporting player's page).
|
||||||
|
//
|
||||||
|
// Review-driven contract: a check that cannot read a build NEVER reloads, a new build is acted on only after two checks
|
||||||
|
// in a row agree, and a match on screen is never thrown away — the guard waits for it (and its settlement) to end.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { checkBuildOnce, startBuildGuard, fetchBuild, BUILD_CHECK_MS, BUILD_FETCH_TIMEOUT_MS } from '../../public/js/ui/buildGuard.js';
|
||||||
|
|
||||||
|
/** A fake fetch answering /healthz with `build` (null → the frame carries none). */
|
||||||
|
const fetchOf = (build) => async (url, init) => {
|
||||||
|
assert.equal(url, '/healthz');
|
||||||
|
assert.equal(init.cache, 'no-store');
|
||||||
|
return { ok: true, status: 200, json: async () => ({ ok: true, build }) };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('fetchBuild: the tag, or null when unreachable / not reported / too slow', async () => {
|
||||||
|
assert.equal(await fetchBuild(fetchOf('aaa')), 'aaa');
|
||||||
|
assert.equal(await fetchBuild(async () => { throw new Error('offline'); }), null);
|
||||||
|
assert.equal(await fetchBuild(async () => ({ ok: false, status: 503, json: async () => ({}) })), null);
|
||||||
|
assert.equal(await fetchBuild(fetchOf(null)), null);
|
||||||
|
// a request that never answers must not keep the guard waiting forever
|
||||||
|
const t0 = Date.now();
|
||||||
|
assert.equal(await fetchBuild(() => new Promise(() => {}), { timeoutMs: 20 }), null);
|
||||||
|
assert.ok(Date.now() - t0 < BUILD_FETCH_TIMEOUT_MS, 'gives up on its own timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkBuildOnce: first sighting, unchanged, changed, and unknown when the server says nothing', async () => {
|
||||||
|
assert.deepEqual(await checkBuildOnce({ fetchFn: fetchOf('aaa') }), { status: 'first', build: 'aaa' });
|
||||||
|
assert.deepEqual(await checkBuildOnce({ fetchFn: fetchOf('aaa'), known: 'aaa' }), { status: 'current', build: 'aaa' });
|
||||||
|
assert.deepEqual(await checkBuildOnce({ fetchFn: fetchOf('bbb'), known: 'aaa' }), { status: 'new', build: 'bbb' });
|
||||||
|
assert.deepEqual(await checkBuildOnce({ fetchFn: async () => { throw new Error('offline'); }, known: 'aaa' }), { status: 'unknown', build: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A guard driven by hand: run the interval callback on demand. */
|
||||||
|
function manual({ inMatch = false } = {}) {
|
||||||
|
const ticks = [];
|
||||||
|
const calls = { reload: 0, stale: [] };
|
||||||
|
let build = 'aaa';
|
||||||
|
let reachable = true;
|
||||||
|
let busy = inMatch;
|
||||||
|
const guard = startBuildGuard({
|
||||||
|
fetchFn: async () => {
|
||||||
|
if (!reachable) throw new Error('offline');
|
||||||
|
return { ok: true, status: 200, json: async () => ({ build }) };
|
||||||
|
},
|
||||||
|
reload: () => { calls.reload++; },
|
||||||
|
inMatch: () => busy,
|
||||||
|
setInterval: (fn) => { ticks.push(fn); return ticks.length; },
|
||||||
|
clearInterval: () => {},
|
||||||
|
onStale: (info) => calls.stale.push(info),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
calls, ticks, guard,
|
||||||
|
setBuild: (b) => { build = b; },
|
||||||
|
setReachable: (b) => { reachable = b; },
|
||||||
|
setInMatch: (b) => { busy = b; },
|
||||||
|
/** run the interval callback and let its await settle */
|
||||||
|
tick: async () => { for (const fn of ticks) fn(); await new Promise((r) => setTimeout(r, 0)); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('startBuildGuard: records this page\'s build in memory, reloads once on a NEW build (after two agreeing checks)', async () => {
|
||||||
|
const m = manual();
|
||||||
|
await new Promise((r) => setTimeout(r, 0)); // the immediate first check
|
||||||
|
assert.equal(m.guard.known(), 'aaa');
|
||||||
|
assert.equal(m.calls.reload, 0);
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0, 'same build → no reload');
|
||||||
|
m.setBuild('bbb');
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0, 'one sighting is not enough: a half-copied deploy must not ping-pong the page');
|
||||||
|
assert.equal(m.guard.stale(), false);
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 1, 'the same new build twice → the page reloads');
|
||||||
|
assert.deepEqual(m.calls.stale, [{ build: 'bbb', known: 'aaa', waiting: false }]);
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 1, 'the guard stopped after reloading');
|
||||||
|
assert.equal(m.guard.stale(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startBuildGuard: a NEW build that is then not served twice is dropped (rolling deploy / reverts)', async () => {
|
||||||
|
const m = manual();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
m.setBuild('bbb');
|
||||||
|
await m.tick();
|
||||||
|
m.setBuild('aaa'); // the fleet rolled back to this page's build
|
||||||
|
await m.tick();
|
||||||
|
m.setBuild('bbb');
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0, 'never saw the same new build twice in a row');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startBuildGuard: an unreadable /healthz never reloads — not even after a new build was seen', async () => {
|
||||||
|
const m = manual();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
m.setBuild('bbb');
|
||||||
|
await m.tick(); // one sighting of the new build
|
||||||
|
m.setReachable(false); // …and now the server is restarting
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0, 'a failed check is unknown, never a reason to reload');
|
||||||
|
assert.equal(m.guard.stale(), false);
|
||||||
|
m.setReachable(true);
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0, 'the sightings must be consecutive: the failures in between reset the count');
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 1, 'two checks in a row agree → reload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startBuildGuard: a match on screen is never thrown away — it waits for the match (and its settlement) to end', async () => {
|
||||||
|
const m = manual({ inMatch: true });
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
m.setBuild('bbb');
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0, 'in a match: the reload waits');
|
||||||
|
assert.equal(m.guard.stale(), true, 'the staleness is remembered');
|
||||||
|
assert.deepEqual(m.calls.stale, [{ build: 'bbb', known: 'aaa', waiting: true }], 'the UI is told to offer a reload');
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0);
|
||||||
|
m.setInMatch(false); // back in the lobby / room: the match is over
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 1, 'once nothing is at stake the page reloads');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startBuildGuard: stop() ends the watch', async () => {
|
||||||
|
const m = manual();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
m.guard.stop();
|
||||||
|
m.setBuild('bbb');
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.calls.reload, 0);
|
||||||
|
assert.equal(m.guard.stale(), false);
|
||||||
|
assert.equal(BUILD_CHECK_MS, 60_000);
|
||||||
|
});
|
||||||
@@ -272,7 +272,8 @@ describe('connection banner class (replaces CSS :has())', () => {
|
|||||||
test('bannerVisible mirrors the banner', () => {
|
test('bannerVisible mirrors the banner', () => {
|
||||||
assert.equal(bannerVisible({ status: 'online' }, true, false), false);
|
assert.equal(bannerVisible({ status: 'online' }, true, false), false);
|
||||||
assert.equal(bannerVisible({ status: 'online' }, true, true), true, 'syncing after a resume');
|
assert.equal(bannerVisible({ status: 'online' }, true, true), true, 'syncing after a resume');
|
||||||
assert.equal(bannerVisible({ status: 'reconnecting', everOnline: true }, true, false), true);
|
assert.equal(bannerVisible({ status: 'online' }, true, false, true), true, 'a stale build during a match');
|
||||||
|
assert.equal(bannerVisible({ status: 'reconnecting', everOnline: true }, true, false, true), true);
|
||||||
assert.equal(bannerVisible({ status: 'connecting', everOnline: false }, true, false), false, 'first connect is silent');
|
assert.equal(bannerVisible({ status: 'connecting', everOnline: false }, true, false), false, 'first connect is silent');
|
||||||
assert.equal(bannerVisible({ status: 'closed' }, false, false), false, 'not before the title');
|
assert.equal(bannerVisible({ status: 'closed' }, false, false), false, 'not before the title');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user