feat(boardwalk): expose the admin build id and show a reload banner when a new build is deployed (quick-261028-rqb)

- boardwalk.BuildID hashes the dist tree plus the binary's build info once
  per handler; X-Summer-Build on every response, {prefix}/build.json and the
  summer-admin-build meta carry it
- the SPA reads the booted build, re-checks on focus, visibility and every
  5 minutes, and shows UpdateBanner on a different build
This commit is contained in:
Jakub Zych
2026-10-28 21:19:36 +01:00
parent 4957e5ce25
commit 291b18fb1e
12 changed files with 404 additions and 5 deletions

View File

@@ -0,0 +1,142 @@
// New-version check. An admin tab opened before a deploy keeps running the
// old bundle against the new backend, sometimes for days, because the session
// refreshes silently; a plugin field type the old bundle does not know then
// shows as "Unsupported field type". The server (boardwalk) writes the build
// id into the served index and answers it at {base}/build.json. The tab
// remembers the build it booted with (runtime.build), re-checks on focus, on
// visibility and on an interval, and on a different build shows a
// non-blocking banner (UpdateBanner). It never reloads by itself, so unsaved
// edits stay where they are.
import { readonly, ref } from 'vue'
import { runtime } from './runtime'
/** Re-check interval while the tab is visible: 5 minutes. */
export const BUILD_CHECK_INTERVAL = 5 * 60 * 1000
/** Minimum gap between two checks: 30 seconds. */
export const BUILD_CHECK_MIN_GAP = 30 * 1000
/**
* Whether seen is a different build than booted. Ids are not ordered: any
* difference is a different deploy, a rollback included. An empty id on
* either side is never a new build.
*/
export function isNewBuild(booted: string, seen: string): boolean {
return booted !== '' && seen !== '' && booted !== seen
}
/**
* The current build from {base}/build.json, or "" when the request fails,
* the response is not ok or the body has no string build. It never throws.
*/
export async function fetchBuild(): Promise<string> {
try {
// Resolve fetch per call so tests can replace globalThis.fetch.
const response = await globalThis.fetch(`${runtime.base}/build.json`, {
cache: 'no-store',
credentials: 'same-origin',
})
if (!response.ok) {
return ''
}
const body: unknown = await response.json()
if (typeof body !== 'object' || body === null) {
return ''
}
const build = (body as { build?: unknown }).build
return typeof build === 'string' ? build : ''
} catch {
return ''
}
}
const available = ref(false)
let latest = ''
let dismissed = ''
/** True while a build different from the booted one waits for a reload. */
export const newBuildAvailable = readonly(available)
/**
* Records a build seen on the server. A build different from the booted one
* shows the banner, unless it is the build the administrator dismissed.
*/
export function noteBuild(seen: string, booted: string = runtime.build): void {
if (isNewBuild(booted, seen) && seen !== dismissed) {
latest = seen
available.value = true
}
}
/** Hides the banner until a build different from the current one is seen. */
export function dismissNewBuild(): void {
dismissed = latest
available.value = false
}
/** Clears the module state; for tests. */
export function resetBuildUpdate(): void {
latest = ''
dismissed = ''
available.value = false
}
export interface BuildWatchOptions {
/** The build the tab booted with; default runtime.build. */
booted?: string
/** Reads the current build; default fetchBuild. */
check?: () => Promise<string>
/** Interval between checks while visible; default BUILD_CHECK_INTERVAL. */
interval?: number
/** Minimum gap between two checks; default BUILD_CHECK_MIN_GAP. */
minGap?: number
win?: Pick<Window, 'addEventListener' | 'removeEventListener' | 'setInterval' | 'clearInterval'>
doc?: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>
now?: () => number
}
/**
* Starts the new-version check and returns its stop function. With no booted
* build (the served index carries none, as under the dev server) it installs
* nothing. A check runs on visibilitychange to visible, on window focus and
* on the interval, skipped while the tab is hidden, while a check is in
* flight or within minGap of the last check.
*/
export function startBuildWatch(options: BuildWatchOptions = {}): () => void {
const booted = options.booted ?? runtime.build
if (booted === '') {
return () => undefined
}
const check = options.check ?? fetchBuild
const interval = options.interval ?? BUILD_CHECK_INTERVAL
const minGap = options.minGap ?? BUILD_CHECK_MIN_GAP
const win = options.win ?? window
const doc = options.doc ?? document
const now = options.now ?? Date.now
let inFlight = false
let lastStart = Number.NEGATIVE_INFINITY
const run = async (): Promise<void> => {
if (doc.visibilityState === 'hidden' || inFlight || now() - lastStart < minGap) {
return
}
lastStart = now()
inFlight = true
try {
noteBuild(await check(), booted)
} finally {
inFlight = false
}
}
const trigger = (): void => {
run().catch(() => undefined)
}
doc.addEventListener('visibilitychange', trigger)
win.addEventListener('focus', trigger)
const timer = win.setInterval(trigger, interval)
return () => {
doc.removeEventListener('visibilitychange', trigger)
win.removeEventListener('focus', trigger)
win.clearInterval(timer)
}
}

View File

@@ -1,8 +1,12 @@
// Runtime configuration read once from the served index.html. The Go server
// (boardwalk) writes the configured backend.uri into the summer-admin-base
// meta; the SPA derives its router base and API base from it (D-02, D-03).
// It also writes the build id into the summer-admin-build meta, the build the
// tab booted with (buildUpdate.ts compares it with build.json).
export const DEFAULT_BASE = '/backend'
const TOKEN = '__SUMMER_ADMIN_BASE__'
const BUILD_TOKEN = '__SUMMER_ADMIN_BUILD__'
const BUILD_SHAPE = /^[0-9a-f]{6,64}$/
export function readBase(doc: Document = document): string {
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content.trim() ?? ''
@@ -13,6 +17,18 @@ export function readBase(doc: Document = document): string {
return trimmed === '' ? DEFAULT_BASE : trimmed
}
/**
* The build id from the summer-admin-build meta, or "" when the meta is
* missing, empty, the unreplaced token (the dev server) or not lowercase hex.
*/
export function readBuild(doc: Document = document): string {
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-build"]')?.content.trim() ?? ''
if (content === BUILD_TOKEN || !BUILD_SHAPE.test(content)) {
return ''
}
return content
}
const base = readBase()
export const runtime = {
@@ -20,4 +36,6 @@ export const runtime = {
base,
/** Admin API root, the mount path plus /api/v1. */
api: `${base}/api/v1`,
/** The build id this tab booted with; "" turns the version check off. */
build: readBuild(),
} as const