// esbuild build for @hive/swarm-ui (project-bootstrap scope). Bundles // main.tsx + its companion CSS, theme.css, and swarm-ui.css into // `dist/static/`, content-hashed (`[name]-[hash]`) so a redeploy's new // bundle gets a new URL, then rewrites those real URLs into index.html // and sw.js. See each output file's own name in the code below for what // it's for — not repeated here to avoid this comment drifting out of // sync with the actual build steps. // // `colors.css` is the ONE static asset deliberately left UNHASHED, at a // fixed `dist/static/colors.css`: nix/host-modules/swarm-ui.nix's stylix // theming swaps that exact path via an `= /static/colors.css` nginx // location override, no npm/esbuild rebuild involved. Hashing it would // silently break that swap. See `colors.css`'s own top comment. // // `dist/sw.js` keeps its own fixed/unhashed filename too — the browser // polls this exact URL to detect a SW update, hashing it would make // every deploy register as a brand new worker. Its `CACHE_VERSION` // placeholder and shell-asset URLs get rewritten below, same as // index.html's. // // PWA icons are NOT produced here — like favicon.svg, they're // rasterized from branding/*.svg and copied in by // nix/packages/swarm-ui.nix's installPhase, outside the npm tree this // script builds. // // Not yet wired into any Rust binary's `ServeDir` — swarm-controller // only serves `/health` today; this just makes `dist/` buildable so // nix/packages/swarm-ui.nix has something to package. `.tsx` → JS: // esbuild transpiles TypeScript natively (types stripped, not checked) // — run `npm run typecheck` separately for real type errors. import { build } from "esbuild"; 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)); 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-.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 }); const mainResult = await build({ absWorkingDir: here, entryPoints: [src("main.tsx")], outdir: staticDir(""), entryNames: "[name]-[hash]", bundle: true, format: "esm", platform: "browser", target: ["es2022"], sourcemap: true, logLevel: "info", jsx: "automatic", jsxImportSource: "preact", metafile: true, }); // 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; }; // 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(""));