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

@@ -8,7 +8,7 @@ HTTP handler that serves the embedded admin SPA build under a configurable path
## Overview
`boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. [cabana](../cabana/README.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app.
`boardwalk` embeds the compiled admin SPA (`dist/`, produced by `npm --prefix admin run build`) into the binary and serves it. The build is path-agnostic: `index.html` carries a placeholder token that the handler replaces once, at construction, with the prefix the admin is mounted under, so one build works at any backend URI. The handler also computes a build id, so an open admin tab can tell that a new version was deployed. [cabana](../cabana/README.md) mounts it when it activates the admin routes. It stands in for the server-rendered backend layouts of WinterCMS, which the Go port replaces with a single-page app.
## Role in the framework
@@ -29,6 +29,10 @@ Use cases:
- Long-lived immutable caching for hashed files under `assets/`, `no-cache` for other files and `no-store` for `index.html`.
- Security headers on every response: a restrictive Content-Security-Policy (`frame-ancestors 'none'`, `base-uri 'none'`, `object-src 'none'`, `script-src 'self'`, `form-action 'self'`, `frame-src 'none'`), frame denial, `nosniff`, a same-origin referrer policy and `noindex, nofollow`.
- Explicit content types for scripts, styles, fonts, SVG and JSON, with a MIME lookup fallback.
- A build id (`boardwalk.BuildID`), computed once when the handler is built: 12 hex characters of a sha256 over the raw dist tree plus the binary's build information from `runtime/debug.ReadBuildInfo` (the Go version, the main module path, version and sum, `vcs.revision` and `vcs.modified`, and every dependency's path, version and sum, replacements included).
- The build id in the `X-Summer-Build` header (`boardwalk.BuildHeader`) on every response the handler writes, the `api/` hand-off included.
- `{prefix}/build.json` (`boardwalk.BuildPath`) answers `{"build":"<id>"}` as `no-store` JSON.
- The `summer-admin-build` meta in `index.html` gets the build id in place of `boardwalk.BuildToken`; an index without the token is served unchanged and the SPA's version check stays off.
## Usage
@@ -46,6 +50,8 @@ if err != nil {
mux.Handle("/backend/", spa)
```
The build id changes when any file of the admin build changes and on any redeploy of a different binary: a new commit (`vcs.revision`), a change of `vcs.modified`, a dependency version bump (a plugin release included) or a toolchain change. Without build information the id is the dist hash alone. Plugin admin asset files are not hashed directly; they reach the id through their module versions and the application's revision. A build with `-buildvcs=false` that takes plugins only from local `replace` directives does not see edits inside the replaced modules. An identical rebuild may still produce a new id once, which only shows the reload banner.
## API reference
| Identifier | Description |
@@ -54,6 +60,10 @@ mux.Handle("/backend/", spa)
| `boardwalk.Dist` | Returns the embedded build as an `fs.FS` rooted at `dist/`. |
| `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. |
| `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. |
| `boardwalk.BuildID` | Computes the build id from a dist tree and the binary's build information (`nil` for the dist alone). |
| `boardwalk.BuildHeader` | The response header, `X-Summer-Build`, that carries the build id on every response. |
| `boardwalk.BuildToken` | The placeholder in the `summer-admin-build` meta of `dist/index.html` that is replaced by the build id. |
| `boardwalk.BuildPath` | The path under the prefix, `build.json`, that answers the current build id. |
| `boardwalk.ContentType` | The Content-Type served for a file name, with explicit UTF-8 JavaScript and CSS types. Shared with the plugin asset route in cabana. |
| `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the CSP with `script-src 'self'`, `form-action 'self'` and `frame-src 'none'`, noindex) on a response. |
@@ -61,7 +71,7 @@ mux.Handle("/backend/", spa)
- SummerCMS modules: none.
- Third-party: none.
- Standard library: `bytes`, `embed`, `errors`, `fmt`, `html`, `io/fs`, `mime`, `net/http`, `path`, `strings`, `time`.
- Standard library: `bytes`, `crypto/sha256`, `embed`, `encoding/hex`, `errors`, `fmt`, `html`, `io`, `io/fs`, `mime`, `net/http`, `path`, `runtime/debug`, `slices`, `strconv`, `strings`, `time`.
The embedded `dist/` tree is generated from the `admin/` Vite project, whose build writes to `modules/boardwalk/dist`.

View File

@@ -5,18 +5,30 @@
// index.html once for the configured backend.uri, serves hashed assets with
// long-lived caching, falls back to index.html for client-side routes and
// hands every unmatched api/ path back to the caller so API misses stay JSON.
//
// The handler also computes one build id when it is built (BuildID: the dist
// tree plus the binary's build information). Every response carries it in the
// X-Summer-Build header, {prefix}/build.json answers it, and index.html gets
// it in the summer-admin-build meta, so an open admin tab can tell that a new
// version was deployed.
package boardwalk
import (
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
"errors"
"fmt"
"html"
"io"
"io/fs"
"mime"
"net/http"
"path"
"runtime/debug"
"slices"
"strconv"
"strings"
"time"
)
@@ -27,6 +39,25 @@ var distFS embed.FS
// BaseToken is replaced in dist/index.html by the configured admin prefix.
const BaseToken = "__SUMMER_ADMIN_BASE__"
// BuildHeader is the response header that carries the build id on every
// response the handler writes.
const BuildHeader = "X-Summer-Build"
// BuildToken is the token in dist/index.html's summer-admin-build meta that
// the handler replaces with the build id. An index without it is served
// unchanged and the SPA turns its version check off.
const BuildToken = "__SUMMER_ADMIN_BUILD__"
// BuildPath is the path under the prefix that answers the current build as
// JSON ({"build":"<id>"}, no-store). It wins over a dist file of that name.
const BuildPath = "build.json"
// buildIDLength matches the length cabana uses for plugin asset versions.
const buildIDLength = 12
// readBuildInfo reads the binary's build information; tests replace it.
var readBuildInfo = debug.ReadBuildInfo
// contentSecurityPolicy keeps the admin out of frames and allows scripts
// only from its own origin; the build contains no inline script. Injected
// markup can neither post a native form off-origin (form-action 'self') nor
@@ -78,7 +109,94 @@ func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handl
if err != nil {
return nil, err
}
return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil
// The id is computed once, when the handler is built; it is hex, so it
// needs no escaping in the meta.
build, err := BuildID(root, buildInfo())
if err != nil {
return nil, err
}
index = bytes.ReplaceAll(index, []byte(BuildToken), []byte(build))
return &handler{root: root, prefix: prefix, index: index, build: build, notFoundAPI: notFoundAPI}, nil
}
// buildInfo returns the binary's build information, or nil when it is not
// available.
func buildInfo() *debug.BuildInfo {
info, ok := readBuildInfo()
if !ok {
return nil
}
return info
}
// BuildID identifies one deploy of the admin: the first 12 lowercase hex
// characters of a sha256 over
//
// - the dist tree, raw (before any prefix rewrite): every regular file in
// fs.WalkDir order as its slash path, its length and its contents;
// - then, when info is not nil, the binary's build identity: the Go
// version, the main module path, version and sum, the vcs.revision and
// vcs.modified settings when present, and every dependency's path,
// version and sum (and its replacement's), sorted by path.
//
// Other build settings (flags, GOOS and the like) are left out. With info nil
// the id is the dist hash alone. The same tree with the same info always
// gives the same id; a changed dist file or a different binary (a new commit,
// a dependency bump, a toolchain change) gives a new one.
func BuildID(root fs.FS, info *debug.BuildInfo) (string, error) {
sum := sha256.New()
err := fs.WalkDir(root, ".", func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.Type().IsRegular() {
return nil
}
body, err := fs.ReadFile(root, name)
if err != nil {
return err
}
_, _ = io.WriteString(sum, name)
_, _ = sum.Write([]byte{0})
_, _ = io.WriteString(sum, strconv.Itoa(len(body)))
_, _ = sum.Write([]byte{0})
_, _ = sum.Write(body)
return nil
})
if err != nil {
return "", fmt.Errorf("boardwalk: build id: %w", err)
}
if info != nil {
writeBuildInfo(sum, info)
}
return hex.EncodeToString(sum.Sum(nil))[:buildIDLength], nil
}
// writeBuildInfo writes the deploy-identifying part of info, one line per
// item, after a NUL-separated marker.
func writeBuildInfo(w io.Writer, info *debug.BuildInfo) {
_, _ = io.WriteString(w, "\x00buildinfo\x00")
fmt.Fprintf(w, "go %s\n", info.GoVersion)
fmt.Fprintf(w, "main %s %s %s\n", info.Main.Path, info.Main.Version, info.Main.Sum)
for _, key := range []string{"vcs.revision", "vcs.modified"} {
for _, s := range info.Settings {
if s.Key == key {
fmt.Fprintf(w, "setting %s=%s\n", s.Key, s.Value)
break
}
}
}
deps := slices.Clone(info.Deps)
slices.SortStableFunc(deps, func(a, b *debug.Module) int {
return strings.Compare(a.Path, b.Path)
})
for _, d := range deps {
fmt.Fprintf(w, "dep %s %s %s", d.Path, d.Version, d.Sum)
if d.Replace != nil {
fmt.Fprintf(w, " => %s %s %s", d.Replace.Path, d.Replace.Version, d.Replace.Sum)
}
_, _ = io.WriteString(w, "\n")
}
}
// RewriteIndex points relative asset URLs at prefix and injects the prefix
@@ -98,11 +216,13 @@ type handler struct {
root fs.FS
prefix string
index []byte
build string
notFoundAPI http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
SetSecurityHeaders(w.Header())
w.Header().Set(BuildHeader, h.build)
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
rel = strings.TrimPrefix(rel, "/")
if rel == "api" || strings.HasPrefix(rel, "api/") {
@@ -110,6 +230,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
name := strings.TrimPrefix(path.Clean("/"+rel), "/")
if name == BuildPath {
h.serveBuild(w)
return
}
if name == "" || name == "index.html" {
h.serveIndex(w, r)
return
@@ -136,6 +260,14 @@ func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
}
// serveBuild answers the handler's build id as {"build":"<id>"}; the id is
// hex, so it is written without marshalling.
func (h *handler) serveBuild(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = io.WriteString(w, `{"build":"`+h.build+`"}`)
}
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
body, err := fs.ReadFile(h.root, name)
if err != nil {

View File

@@ -494,3 +494,39 @@ func TestPhase101BoardwalkExports(t *testing.T) {
}
}
}
// buildFixture is an in-memory dist whose index carries both tokens.
func buildFixture() fstest.MapFS {
return fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<html><head>` +
`<meta name="summer-admin-base" content="` + BaseToken + `" />` +
`<meta name="summer-admin-build" content="` + BuildToken + `" />` +
`<script type="module" src="./assets/app.js"></script></head></html>`)},
"assets/app.js": &fstest.MapFile{Data: []byte(`console.log("acme")`)},
}
}
func TestBuildSmoke(t *testing.T) {
root := buildFixture()
h, err := newHandler(root, testPrefix, &apiSpy{})
if err != nil {
t.Fatal(err)
}
want, err := BuildID(root, buildInfo())
if err != nil {
t.Fatal(err)
}
index := get(h, testPrefix+"/")
if !strings.Contains(index.Body.String(), `<meta name="summer-admin-build" content="`+want+`" />`) {
t.Fatalf("index has no build meta %q: %s", want, index.Body.String())
}
build := get(h, testPrefix+"/"+BuildPath)
if build.Body.String() != `{"build":"`+want+`"}` {
t.Fatalf("build.json = %s, want %s", build.Body.String(), want)
}
for _, rec := range []*httptest.ResponseRecorder{index, build} {
if got := rec.Header().Get(BuildHeader); got != want {
t.Fatalf("%s = %q, want %q", BuildHeader, got, want)
}
}
}

View File

@@ -543,3 +543,6 @@ permissions:
manage_users_comment: "Create, edit, deactivate and delete administrator accounts."
manage_roles: "Manage roles"
manage_roles_comment: "Create and edit roles and the permissions they grant."
version:
available: "A new version of the admin is available."
reload: "Reload"

View File

@@ -577,3 +577,6 @@ permissions:
manage_users_comment: "Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów."
manage_roles: "Zarządzanie rolami"
manage_roles_comment: "Tworzenie i edycja ról oraz uprawnień, które nadają."
version:
available: "Dostępna jest nowa wersja panelu."
reload: "Odśwież"