From 64154a7d329e77ca39705f1dd4399e96db0bd32b Mon Sep 17 00:00:00 2001 From: s950tx16wasr10 Date: Mon, 18 May 2026 18:56:47 +0200 Subject: [PATCH] feat(banner): 1Hz poll + cache-bust assets via ?v=buildId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes so the round-duration display stops drifting: 1) poll_interval_ms 4000 -> 1000 in _data/site.yaml. Matches the serverinfo-updater's now-1s poll cadence (companion infra PR), so the browser never holds onto a >1s-stale value. 2) Cache-bust JS + CSS via ?v={buildId}. Caddy serves /assets/* with Cache-Control: max-age=31536000, immutable. CopyWebpackPlugin does not hash the JS bundles (and the existing webpack output for CSS doesn't use contenthash either), so without a query-string version any visitor who ever loaded the page would keep their cached copy indefinitely. buildId is Date.now().toString(36) computed once at eleventy startup, so all assets in one build share a buildId and every npm run build mints a new one. Drop the local 1Hz ticker and last_update anchoring from gamebanners.js — at 1Hz polling the interpolation is redundant (the value updates server-side every second anyway) and the simple poll model is what was requested. --- _data/site.yaml | 2 +- eleventy.config.js | 11 ++++++++ index.njk | 10 +++---- js/gamebanners.js | 67 +++++++--------------------------------------- 4 files changed, 27 insertions(+), 63 deletions(-) diff --git a/_data/site.yaml b/_data/site.yaml index ac07271..6bf38da 100644 --- a/_data/site.yaml +++ b/_data/site.yaml @@ -10,4 +10,4 @@ github_repo: https://github.com/ReduxStation/website # Set to "" to disable the poller and show "server status unavailable". status_endpoint: "/serverinfo.json" -poll_interval_ms: 4000 +poll_interval_ms: 1000 diff --git a/eleventy.config.js b/eleventy.config.js index aeec942..b9d5bd3 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -16,6 +16,17 @@ module.exports = function (eleventyConfig) { eleventyConfig.addNunjucksAsyncShortcode("webpack", shortcodes.webpack); eleventyConfig.addShortcode("currentBuildDate", () => new Date().toISOString()); + // Cache-bust query string baked at build time. Webpack's manifest plugin + // does not add [contenthash] to the copied JS bundles (they go through + // CopyWebpackPlugin, not webpack's regular pipeline), so the + // /assets/js/*.js URLs are stable. Caddy serves /assets/* with + // Cache-Control: max-age=31536000, so without this query string a + // browser that fetched the JS once would never refetch after a content + // change. Pinning to one buildId per `npm run build` flushes caches + // exactly when assets actually change. + const buildId = Date.now().toString(36); + eleventyConfig.addShortcode("buildId", () => buildId); + eleventyConfig.addWatchTarget(path.join(__dirname, "_site/assets/manifest.json")); eleventyConfig.addWatchTarget(path.join(__dirname, "_data/*.yaml")); diff --git a/index.njk b/index.njk index 7bb2be1..af2cc37 100644 --- a/index.njk +++ b/index.njk @@ -6,7 +6,7 @@ {{ site.brand }} - {{ site.tagline }} - + @@ -128,9 +128,9 @@ Built {% currentBuildDate %} - - - - + + + + diff --git a/js/gamebanners.js b/js/gamebanners.js index 65fa992..31980ad 100644 --- a/js/gamebanners.js +++ b/js/gamebanners.js @@ -19,7 +19,6 @@ container.querySelectorAll(".gamebanner").forEach((banner) => { banner.classList.remove("loading", "lobby", "underway", "end"); banner.classList.add("error"); - banner._statusData = null; const v = banner.querySelector(".version"); if (v) v.textContent = "server status unavailable"; const m = banner.querySelector(".map"); @@ -40,7 +39,6 @@ container.querySelectorAll(".gamebanner").forEach((banner) => { banner.classList.remove("loading", "lobby", "underway", "end"); banner.classList.add("error"); - banner._statusData = null; const v = banner.querySelector(".version"); if (v) v.textContent = "connection error"; }); @@ -100,54 +98,17 @@ } } - // Build the contents of the .status line. extraSec is the number of - // seconds elapsed since the data was fetched; the local 1Hz ticker - // passes a growing value so round_duration reads as a live clock - // instead of stepping every server poll. shuttle_timer is left at - // the server value because we don't know whether it counts up or - // down for the current mode — it just resyncs on the next fetch. - function buildStatusString(d, extraSec) { - let s = String(d.players); - const cap = popcapString(d); - if (cap) s += "/" + cap; - if (Number(d.round_duration)) s += " " + secondsToTime(Number(d.round_duration) + extraSec); - if (d.shuttle_mode && Number(d.shuttle_timer)) s += " " + shuttleTime(d.shuttle_mode, Number(d.shuttle_timer)); - return s; - } - - // Pick the timestamp the data was generated at, not the timestamp the - // browser received the response. The serverinfo-updater stamps - // payload.last_update at the moment it polls DD; using that as the - // ticker anchor lets the displayed clock stay aligned with real time - // even though the JSON file is up to one poll-interval stale by the - // time the browser fetches it (3-4s offset otherwise). - // - // Falls back to Date.now() when last_update is missing or when the - // browser clock disagrees with the server clock by > 5 minutes - // (NTP outage, user clock wrong). Without the guard, a multi-minute - // skew would surface as a multi-minute jump in the displayed timer. - function pickBaselineTime(lastUpdateIso) { - if (!lastUpdateIso) return Date.now(); - const t = new Date(lastUpdateIso).getTime(); - if (!isFinite(t)) return Date.now(); - if (Math.abs(Date.now() - t) > 5 * 60 * 1000) return Date.now(); - return t; - } - - function renderBanner(server, lastUpdateIso) { + function renderBanner(server) { const banner = document.getElementById(server.identifier); if (!banner) return 0; if (!server.data || server.data.ERROR || !server.data.players || !server.data.version) { banner.classList.remove("loading", "lobby", "underway", "end"); banner.classList.add("error"); - banner._statusData = null; const v = banner.querySelector(".version"); if (v) v.textContent = (server.data && server.data.errortext) || "connection error"; return 0; } const d = server.data; - banner._statusData = d; - banner._fetchedAt = pickBaselineTime(lastUpdateIso); applyState(banner, d.gamestate); const v = banner.querySelector(".version"); if (v) { @@ -159,7 +120,14 @@ const m = banner.querySelector(".map"); if (m) m.textContent = "Map: " + (d.map_name || "?"); const status = banner.querySelector(".status"); - if (status) status.textContent = buildStatusString(d, 0); + if (status) { + let s = String(d.players); + const cap = popcapString(d); + if (cap) s += "/" + cap; + if (Number(d.round_duration)) s += " " + secondsToTime(Number(d.round_duration)); + if (d.shuttle_mode && Number(d.shuttle_timer)) s += " " + shuttleTime(d.shuttle_mode, Number(d.shuttle_timer)); + status.textContent = s; + } const r = banner.querySelector(".revision"); if (r && d.revision) r.textContent = "rev " + String(d.revision).substr(0, 7); return Number(d.players) || 0; @@ -175,7 +143,7 @@ if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); let total = 0; - (data.servers || []).forEach((s) => { total += renderBanner(s, data.last_update); }); + (data.servers || []).forEach((s) => { total += renderBanner(s); }); if (playercountEl) playercountEl.textContent = total + " online"; if (statusDot) { statusDot.classList.remove("offline"); @@ -186,21 +154,6 @@ } } - // Local 1Hz ticker. Recomputes only the .status line so the round - // timer increments smoothly between server polls. No-op on banners - // whose last fetch errored (_statusData cleared) or hasn't returned - // yet, so the page never shows phantom data. - setInterval(() => { - container.querySelectorAll(".gamebanner").forEach((banner) => { - const d = banner._statusData; - if (!d) return; - const status = banner.querySelector(".status"); - if (!status) return; - const extraSec = Math.floor((Date.now() - banner._fetchedAt) / 1000); - status.textContent = buildStatusString(d, extraSec); - }); - }, 1000); - poll(); setInterval(poll, interval); })();