swarm-ui: content-hash main/theme/swarm-ui-css filenames for real cache-busting
Every redeploy currently changes the nix store path serving swarm-ui's
JS/CSS but never the URL the browser requested (main.js, main.css,
theme.css, swarm-ui.css are all fixed filenames) -- so a browser can get
stuck serving yesterday's bundle after a deploy until someone clears the
cache by hand.
build.mjs now hashes main.tsx's JS bundle + its companion CSS output,
plus theme.css and swarm-ui.css, via esbuild's own metafile (not a
guessed hash algorithm), and rewrites the real URLs into index.html and
sw.js after the build.
colors.css deliberately stays unhashed: nix/host-modules/swarm-ui.nix's
stylix theming swaps that exact fixed path via an `= /static/colors.css`
nginx location override with no npm/esbuild rebuild involved. Hashing it
would silently break that swap on a themed host.
sw.js's CACHE_VERSION is now derived from the real hashed URLs instead of
a hand-bumped literal, so any shell-asset change gets a fresh cache name
and the SW's own activate-time sweep evicts the previous one in full --
fulfilling a promise its own prior comment already made.
Scope: swarm-ui only, per mara's call on hyperhive#4283 ("this is about
swarm ui - everything else will eventually migrate over"). dashboard and
agent are untouched.
This commit is contained in:
parent
5e9b79a719
commit
6b3840b6ac
2 changed files with 169 additions and 47 deletions
|
|
@ -1,25 +1,51 @@
|
|||
// esbuild build for @hive/swarm-ui (project-bootstrap scope). Output
|
||||
// layout (`dist/`):
|
||||
//
|
||||
// dist/index.html served at GET /
|
||||
// dist/static/main.js served at /static/main.js (ESM bundle,
|
||||
// Preact + wouter-preact)
|
||||
// dist/static/main.js.map source map sibling
|
||||
// dist/static/main.css every component's own `import
|
||||
// dist/index.html served at GET / — its asset <link>/
|
||||
// <script> URLs are rewritten below to the
|
||||
// real hashed filenames esbuild picked
|
||||
// dist/static/main-<hash>.js served at /static/main-<hash>.js (ESM
|
||||
// bundle, Preact + wouter-preact)
|
||||
// dist/static/main-<hash>.js.map source map sibling
|
||||
// dist/static/main-<hash>.css every component's own `import
|
||||
// './Foo.css'` (Shell.css, Panel.css, …),
|
||||
// folded by esbuild into one companion
|
||||
// output alongside main.js — no separate
|
||||
// build step, this falls out of bundling
|
||||
// main.tsx with `bundle: true`
|
||||
// dist/static/colors.css served at /static/colors.css
|
||||
// dist/static/theme.css served at /static/theme.css
|
||||
// dist/static/swarm-ui.css served at /static/swarm-ui.css — just the
|
||||
// shared base reset now (@import resolved
|
||||
// from @hive/shared), NOT component styles
|
||||
// output alongside main-<hash>.js — no
|
||||
// separate build step, this falls out of
|
||||
// bundling main.tsx with `bundle: true`
|
||||
// dist/static/colors.css served at /static/colors.css — the ONE
|
||||
// static asset deliberately left UNHASHED:
|
||||
// nix/host-modules/swarm-ui.nix's stylix
|
||||
// theming swaps this exact fixed path via
|
||||
// an `= /static/colors.css` nginx location
|
||||
// override, with no npm/esbuild rebuild
|
||||
// involved. Hashing it would silently break
|
||||
// that swap (the override would stop
|
||||
// matching anything, and a themed host
|
||||
// would revert to the default palette). See
|
||||
// `colors.css`'s own top comment.
|
||||
// dist/static/theme-<hash>.css served at /static/theme-<hash>.css
|
||||
// dist/static/swarm-ui-<hash>.css served at /static/swarm-ui-<hash>.css
|
||||
// — just the shared base reset now (@import
|
||||
// resolved from @hive/shared), NOT
|
||||
// component styles
|
||||
// dist/manifest.webmanifest served at /manifest.webmanifest (PWA)
|
||||
// dist/sw.js served at /sw.js (PWA) — plain JS, no
|
||||
// esbuild pass needed (see its own
|
||||
// top comment for why); copied as-is.
|
||||
// esbuild pass needed (see its own top
|
||||
// comment for why); its `CACHE_VERSION`
|
||||
// placeholder and shell-asset URLs are
|
||||
// rewritten below, same as index.html.
|
||||
// Filename itself stays fixed/unhashed —
|
||||
// the browser polls this exact URL to
|
||||
// detect a SW update, hashing it would
|
||||
// make every deploy register as a brand
|
||||
// new, unrelated worker.
|
||||
//
|
||||
// main/theme/swarm-ui's filenames are content-hashed so a redeploy's new
|
||||
// bundle gets a new URL — nginx's Cache-Control (a follow-up, host-side)
|
||||
// can then mark them `immutable, max-age=1y` for real, instead of caching
|
||||
// a fixed URL that silently serves stale bytes after a deploy changes the
|
||||
// nix store path underneath it.
|
||||
//
|
||||
// PWA icons (icon-192.png/icon-512.png/icon-512-maskable.png) are NOT
|
||||
// produced here — like favicon.svg, they're rasterized from
|
||||
|
|
@ -37,8 +63,9 @@
|
|||
// wired into CI.
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -46,12 +73,23 @@ const src = (p) => resolve(here, "src", p);
|
|||
const dist = (p) => resolve(here, "dist", p);
|
||||
const staticDir = (p) => resolve(here, "dist", "static", p);
|
||||
|
||||
// Every metafile path esbuild returns is relative to `absWorkingDir`
|
||||
// (pinned to `here` on every build call below, regardless of the caller's
|
||||
// own cwd) and always POSIX-style (`dist/static/main-<hash>.js`) — turn
|
||||
// one into the URL nginx actually serves it at. A plain prefix strip, not
|
||||
// `path.relative`: the latter resolves a relative argument against
|
||||
// `process.cwd()`, which is exactly the caller-cwd dependency pinning
|
||||
// `absWorkingDir` on every build call below was meant to avoid.
|
||||
const toUrl = (metafilePath) => "/" + metafilePath.replace(/^dist\//, "");
|
||||
|
||||
rmSync(dist(""), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(""), { recursive: true });
|
||||
|
||||
await build({
|
||||
const mainResult = await build({
|
||||
absWorkingDir: here,
|
||||
entryPoints: [src("main.tsx")],
|
||||
outfile: staticDir("main.js"),
|
||||
outdir: staticDir(""),
|
||||
entryNames: "[name]-[hash]",
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
|
|
@ -60,20 +98,103 @@ await build({
|
|||
logLevel: "info",
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "preact",
|
||||
metafile: true,
|
||||
});
|
||||
|
||||
for (const entry of ["colors.css", "theme.css", "swarm-ui.css"]) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
outfile: staticDir(entry),
|
||||
bundle: true,
|
||||
loader: { ".css": "css" },
|
||||
logLevel: "info",
|
||||
});
|
||||
}
|
||||
// Look up a build's real output path for one of its entry points, by the
|
||||
// entry's own source path — rather than guessing esbuild's hash
|
||||
// algorithm or assuming an output ordering. `entryPoint` in the metafile
|
||||
// is relative to `absWorkingDir` (`here`, pinned on every build call
|
||||
// below); `path.relative` with two absolute arguments never touches
|
||||
// `process.cwd()`, so this is stable regardless of the caller's own cwd.
|
||||
const outputFor = (result, entrySrcPath) => {
|
||||
const wantEntry = relative(here, entrySrcPath);
|
||||
const found = Object.entries(result.metafile.outputs).find(
|
||||
([, o]) => o.entryPoint === wantEntry,
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`esbuild produced no output for entry point ${wantEntry} -- ` +
|
||||
"either it failed silently or the metafile's entryPoint " +
|
||||
"convention changed; update outputFor() to match.",
|
||||
);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
copyFileSync(src("index.html"), dist("index.html"));
|
||||
copyFileSync(src("manifest.webmanifest"), dist("manifest.webmanifest"));
|
||||
copyFileSync(src("sw.js"), dist("sw.js"));
|
||||
// main.tsx's bundle: the JS entry itself, plus its companion CSS output
|
||||
// (`cssBundle` — every component's own `import './Foo.css'`, folded into
|
||||
// one file alongside the JS, same as before this hashed).
|
||||
const [mainJsPath, mainJsInfo] = outputFor(mainResult, src("main.tsx"));
|
||||
if (!mainJsInfo.cssBundle) {
|
||||
throw new Error(
|
||||
"main.tsx produced no companion CSS bundle -- index.html/sw.js still " +
|
||||
"reference /static/main.css, so this build can no longer keep that " +
|
||||
"promise. Either a component's own CSS import got removed, or " +
|
||||
"esbuild's output shape changed; update both the build and the " +
|
||||
"files that reference /static/main.css together.",
|
||||
);
|
||||
}
|
||||
const mainJsUrl = toUrl(mainJsPath);
|
||||
const mainCssUrl = toUrl(mainJsInfo.cssBundle);
|
||||
|
||||
// theme.css / swarm-ui.css: hashed, same as main.tsx above. colors.css is
|
||||
// deliberately NOT in this list — see the top comment's explanation of
|
||||
// why it stays at a fixed path.
|
||||
const hashedCssResult = await build({
|
||||
absWorkingDir: here,
|
||||
entryPoints: [src("theme.css"), src("swarm-ui.css")],
|
||||
outdir: staticDir(""),
|
||||
entryNames: "[name]-[hash]",
|
||||
bundle: true,
|
||||
loader: { ".css": "css" },
|
||||
logLevel: "info",
|
||||
metafile: true,
|
||||
});
|
||||
const themeCssUrl = toUrl(outputFor(hashedCssResult, src("theme.css"))[0]);
|
||||
const swarmUiCssUrl = toUrl(outputFor(hashedCssResult, src("swarm-ui.css"))[0]);
|
||||
|
||||
// colors.css: fixed, unhashed path — see the top comment.
|
||||
await build({
|
||||
absWorkingDir: here,
|
||||
entryPoints: [src("colors.css")],
|
||||
outfile: staticDir("colors.css"),
|
||||
bundle: true,
|
||||
loader: { ".css": "css" },
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
// A build-derived cache-bust version for sw.js's own CACHE_NAME: any
|
||||
// change to any hashed shell-asset URL (a real content change, or an
|
||||
// asset added/removed) changes this, which changes CACHE_NAME, which
|
||||
// makes the SW's own `activate` handler evict the old cache in full —
|
||||
// no more hand-bumping a CACHE_VERSION const on every shell change.
|
||||
const buildVersion = createHash("sha256")
|
||||
.update(JSON.stringify([mainJsUrl, mainCssUrl, themeCssUrl, swarmUiCssUrl]))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
|
||||
const rewriteAssetUrls = (text) =>
|
||||
text
|
||||
.replaceAll("/static/main.js", mainJsUrl)
|
||||
.replaceAll("/static/main.css", mainCssUrl)
|
||||
.replaceAll("/static/theme.css", themeCssUrl)
|
||||
.replaceAll("/static/swarm-ui.css", swarmUiCssUrl);
|
||||
|
||||
writeFileSync(
|
||||
dist("index.html"),
|
||||
rewriteAssetUrls(readFileSync(src("index.html"), "utf8")),
|
||||
);
|
||||
writeFileSync(
|
||||
dist("sw.js"),
|
||||
rewriteAssetUrls(readFileSync(src("sw.js"), "utf8")).replace(
|
||||
'"__BUILD_VERSION__"',
|
||||
JSON.stringify(buildVersion),
|
||||
),
|
||||
);
|
||||
writeFileSync(
|
||||
dist("manifest.webmanifest"),
|
||||
readFileSync(src("manifest.webmanifest")),
|
||||
);
|
||||
|
||||
console.log("swarm-ui build ok →", dist(""));
|
||||
|
|
|
|||
|
|
@ -7,26 +7,27 @@
|
|||
// a cached fetch would be worse than showing nothing (mara: "scope looks
|
||||
// good" — approving that as a hard rule, not a short-TTL middle ground).
|
||||
//
|
||||
// Network-first for the shell, not cache-first: `main.js`/`main.css` are
|
||||
// unhashed filenames, so a cache-first strategy would risk wedging an
|
||||
// operator on yesterday's JS after a deploy until they manually cleared
|
||||
// it (a deploy changes the nix store path serving these but not the URL
|
||||
// the browser cached against — a separate, already-filed fix). This SW
|
||||
// only ever serves its cache on a FAILED fetch (offline/flaky network),
|
||||
// never in preference to a successful network response.
|
||||
// Network-first for the shell, not cache-first: most assets below are
|
||||
// content-hashed (a fresh URL per deploy, so a normal visit always gets
|
||||
// the current bundle regardless of this SW), but `colors.css` stays
|
||||
// deliberately unhashed for the host-side stylix theme swap, so this
|
||||
// SW's own cached copy of it can only ever refresh via a real network
|
||||
// round-trip. This SW only ever serves its cache on a FAILED fetch
|
||||
// (offline/flaky network), never in preference to a live response.
|
||||
//
|
||||
// Plain JS, not TypeScript, deliberately — the DOM lib swarm-ui's own
|
||||
// tsconfig uses and the WebWorker lib a service worker's globals
|
||||
// (`self`, `ExtendableEvent`, `FetchEvent`, `caches`, ...) need are
|
||||
// mutually exclusive in one tsc program, and this file is small/
|
||||
// self-contained enough that a second tsconfig just to typecheck it
|
||||
// isn't worth the config surface. Same treatment `build.mjs` already
|
||||
// gets in this package.
|
||||
// tsconfig uses and the WebWorker lib a service worker's globals need
|
||||
// (`self`, `ExtendableEvent`, `FetchEvent`, `caches`, ...) are mutually
|
||||
// exclusive in one tsc program, and this file is small enough that a
|
||||
// second tsconfig just to typecheck it isn't worth the config surface.
|
||||
// Same treatment `build.mjs` already gets in this package.
|
||||
//
|
||||
// CACHE_VERSION is bumped by hand for now — once the filenames above are
|
||||
// content-hashed, the cache name can derive from the build itself and
|
||||
// this manual step goes away.
|
||||
const CACHE_VERSION = "v1";
|
||||
// CACHE_VERSION is filled in by build.mjs from a hash of every hashed
|
||||
// shell-asset URL below, so any real content change gets a fresh cache
|
||||
// name and `activate`'s old-cache sweep evicts the previous one in
|
||||
// full. This literal placeholder is never served; the build always
|
||||
// replaces it.
|
||||
const CACHE_VERSION = "__BUILD_VERSION__";
|
||||
const CACHE_NAME = `swarm-ui-shell-${CACHE_VERSION}`;
|
||||
|
||||
// Every navigation (any client-side route wouter handles — /agents,
|
||||
|
|
|
|||
Loading…
Reference in a new issue