修复:部署无法到达已打开的页面(新增构建版本标记 + 陈旧页面自动重载) (#39)
This commit is contained in:
1 parent
9d404199df
commit
e90e7a30fc
8 files changed
+450
-10
No files matched your search
@@ -34,6 +34,7 @@ import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
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 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 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 serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, log });
|
||||
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) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
@@ -595,6 +663,9 @@ export async function startServer(opts = {}) {
|
||||
if (parts.rawPath === '/healthz') {
|
||||
sendJson(req, res, 200, {
|
||||
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(),
|
||||
});
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user