Web UI Build and Embedding¶
This document explains how the web UI bundle in web/dist/ is produced, how
Go's embed directive bakes it into the sqi-server binary, how cache-busting
works in production, and what to do when the UI looks stale in a built binary.
For the development workflow, see web-development.md.
How web/dist/ is generated¶
web/dist/ is the production output of the Vite build:
cd web
npm run build # tsc -b (type-check) then vite build
This writes:
web/dist/
├── index.html # SPA shell, references the hashed assets below
└── assets/
├── index-<hash>.js # application bundle
└── index-<hash>.css # styles
build.outDir is dist (the Vite default, set explicitly in vite.config.ts).
The <hash> segments are content fingerprints — see
cache-busting below.
You rarely run npm run build by hand. The Makefile does it for you:
make build-webbuilds the bundle intoweb/dist/.make build-serverandmake builddepend onbuild-web, so the embedded bundle is always rebuilt from current source before the Go build.
npm ci (the slow, clean reinstall) is gated on a stamp file
(web/node_modules/.make-stamp) keyed to package.json and package-lock.json,
so it only re-runs when dependencies actually change. The build step itself
(npm run build = tsc -b && vite build, the Vite portion ~0.5 s) runs every
time, keeping the guarantee that web/dist/ matches current source.
How Go's embed directive picks it up¶
Package web (web/embed.go) embeds the directory at compile time:
//go:embed dist
var distFS embed.FS
func Dist() (fs.FS, error) { /* returns an fs.FS rooted at dist/ */ }
Whatever files exist under web/dist/ when go build runs are baked into the
binary. internal/ui consumes the fs.FS from web.Dist() and serves it over
HTTP:
GET /assets/index-<hash>.js(and any other embedded file) → that file, with its content type detected automatically.- Any path with no file extension and no matching embedded file →
index.html(SPA fallback). The front-end router is root-based, so client-side routes look like ordinary paths (/,/jobs,/submit,/workers/{id}) and all resolve on the client. The legacy/ui/*prefix is also honored for backward compatibility. - A path that does carry a file extension (e.g.
/assets/old.js,/favicon.ico) with no matching embedded file →404, so a stale or mistyped asset URL is not masked by the HTML shell.
Two embed details worth knowing:
- The directive lives in
web/embed.go, notinternal/ui, because Go's//go:embedcan only reference files at or below the embedding file's own directory. - The plain (non-
all:) embed form excludes dot/underscore-prefixed names. This deliberately keeps OS sidecar junk (e.g. macOS._index.htmlresource forks) and editor dotfiles out of the binary. The Vite build emits only normally-named files, so nothing needed is dropped. If a future bundler emits_-prefixed paths, switch to//go:embed all:distand strip sidecars first.
web/dist/index.html is tracked in git (everything else under dist/ is
git-ignored) so //go:embed dist always has at least one file to embed and a
clean checkout builds without running the web build first. A real npm run
build overwrites that placeholder with the full app.
Cache-busting in production¶
Vite fingerprints asset filenames with a content hash (index-<hash>.js). When
the bundle's content changes, the hash changes, so the URL changes — browsers
fetch the new file instead of serving a stale cached copy under the old name.
internal/ui sets cache headers to match this scheme:
| Response | Cache-Control |
Why |
|---|---|---|
index.html (the SPA shell) |
no-cache |
Must be revalidated every load so a freshly deployed binary's shell — which references the new hashed asset names — is picked up immediately. |
Hashed assets (/assets/*) |
public, max-age=3600 |
Content-addressed: a given URL's bytes never change, so it is safe to cache. |
The shell is always re-fetched and always points at the current asset hashes; the
heavy JS/CSS are cached by URL. A new build changes the hashes, the freshly
fetched index.html references them, and clients pull the new assets — no manual
cache invalidation required.
Troubleshooting: the UI looks stale in a built binary¶
If a running sqi-server serves an old UI after you changed source:
- Confirm the bundle was rebuilt. The embed is a compile-time snapshot of
web/dist/. Rebuild it and the binary together:
make build # runs build-web (npm build) then the Go build
A bare go build ./cmd/sqi-server embeds whatever is already in
web/dist/ — stale output if you skipped the web build.
- Force a clean web build if
dist/might be out of date:
cd web && npm run build
Note make clean intentionally does not wipe web/dist/ (the tracked
index.html must remain for the embed). Rebuild via npm run build rather
than deleting the directory.
- Rule out a dependency-stamp miss. If you changed
package.json/package-lock.jsonbut the stamp didn't trigger a reinstall, remove it:
rm -f web/node_modules/.make-stamp && make build-web
-
Rule out browser caching. The shell is
no-cache, but a proxy or an aggressive browser extension can still hold an oldindex.html. Hard-reload (DevTools open, "Disable cache") to confirm whether the stale UI is in the binary or the browser. -
Check for embed exclusions. If an expected asset 404s, verify its name is not dot/underscore-prefixed (those are excluded by the embed form, see above).
A quick way to confirm what a binary actually embeds:
./bin/sqi-server serve &
curl -s http://localhost:8080/ | grep -o 'assets/index-[^"]*' # the hashed names it references