- 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
143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
// 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)
|
|
}
|
|
}
|