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:
142
admin/src/app/buildUpdate.ts
Normal file
142
admin/src/app/buildUpdate.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user