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

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<meta name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
</head>

View File

@@ -3,6 +3,7 @@ import { computed, watchEffect } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { documentTitleForRoute } from './app/pageTitle'
import AppShell from './components/shell/AppShell.vue'
import UpdateBanner from './components/ui/UpdateBanner.vue'
import { remountRevision } from './state/useRemount'
const route = useRoute()
@@ -17,11 +18,13 @@ watchEffect(() => {
<!-- Keyed by path, not full path: a new controller or record remounts its
view, while list query changes (search, sort, page) keep it. The
remount revision is part of the key, so a view can remount itself in
place (Create and new). -->
place (Create and new). The new-version banner sits outside the
v-if, so it also shows on the login screen. -->
<AppShell v-if="inShell">
<RouterView v-slot="{ Component, route: current }">
<component :is="Component" :key="`${current.path}#${remountRevision}`" />
</RouterView>
</AppShell>
<RouterView v-else />
<UpdateBanner />
</template>

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

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
import { dismissNewBuild, newBuildAvailable } from '../../app/buildUpdate'
import { t } from '../../app/i18n'
import { page } from '../../state/usePreferences'
// New-version banner (buildUpdate.ts): shown while the server answers a build
// different from the one this tab booted with. It never reloads by itself;
// Reload goes through page.reload, so a dirty form's beforeunload prompt
// still asks, and Close hides it until another build is deployed.
</script>
<template>
<!-- Bottom right, 88px up so it clears the sticky form footer, like the toasts. -->
<div
v-if="newBuildAvailable"
data-update-banner
role="status"
aria-live="polite"
class="fixed right-4 bottom-[88px] z-50 flex max-w-[420px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
>
<span class="font-medium">{{ t('backend::lang.version.available') }}</span>
<button
type="button"
data-update-reload
class="shrink-0 rounded-pager bg-accent px-3 py-1.5 font-medium text-on-accent transition-opacity duration-150 ease-out hover:opacity-90"
@click="page.reload()"
>
{{ t('backend::lang.version.reload') }}
</button>
<button
type="button"
data-update-close
:aria-label="t('backend::lang.form.close')"
class="flex size-8 shrink-0 items-center justify-center rounded-pager text-[#c3cbda] transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
@click="dismissNewBuild()"
>
<X :size="16" aria-hidden="true" />
</button>
</div>
</template>

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { startBuildWatch } from './app/buildUpdate'
import { loadFieldTypes } from './app/fieldTypes'
import { loadStrings } from './app/i18n'
import { expireSession, me } from './state/useAuth'
@@ -37,6 +38,9 @@ async function boot(): Promise<void> {
app.use(router)
await router.isReady()
app.mount('#app')
// The new-version check; it stays off when the served index carries no
// build (the dev server).
startBuildWatch()
}
void boot()