// 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 { 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 /** 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 doc?: Pick 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 => { 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) } }