mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat(web): tell open tabs when a new build ships
A tab left open across a deploy keeps running the previous build's JavaScript indefinitely. index.html is fetched only on a full page load, all later navigation is client-side, and hashed bundles are served `immutable`, so nothing reveals that the code is stale. This produced a false-positive bug report where two correctly-deployed fixes appeared to be missing. Publishes a build id and offers a reload when the running document falls behind. The toast never reloads on its own; the only automatic reload is recovery from a chunk that no longer exists. Build id derivation ------------------- The obvious approach — hash the emitted asset filenames, which already embed content hashes — does not work: Bun's minified identifier naming is not deterministic. Building an unchanged tree twice produces byte-different output roughly one run in three (same length, ~100k differing bytes, all of it mangled names). Output hashes therefore move with no source change, which would fire the toast on redeploys of identical code and train people to ignore it. The id is instead derived from the bundle's source inputs, so it changes if and only if something we control changed. Verified stable across eight consecutive builds while the entry hash flipped between both variants. This non-determinism also means two builds of the same commit embed different bytes into the server binary, which is worth addressing separately for reproducible builds. Detection --------- SWR with `refreshInterval` + `revalidateOnFocus`, per the repo's React effects policy. SWR does not poll while the document is hidden, so background tabs stay quiet without extra gating. Unknown state on either side — missing meta tag, failed fetch, 503 during a dev rebuild — never produces a prompt. Stylesheet hashing ------------------ Tailwind's output was stable-named and therefore served `no-cache`, letting a tab revalidate into new CSS while running old JS. Tailwind purges unused classes per build, so classes the old bundle still emits could silently lose their styles. It is now content-hashed and moves with the build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d9e9c4536
commit
695a981f42
15 changed files with 608 additions and 20 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
import { importChunk } from "../../../lib/import-chunk";
|
||||
|
||||
/**
|
||||
* Synchronizes a DOM container with a Graphviz-rendered SVG. Pipes the
|
||||
* supplied DOT string through `@viz-js/viz` (the same layout engine Fabro
|
||||
|
|
@ -28,7 +30,7 @@ export function useCanvasRender(
|
|||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { instance } = await import("@viz-js/viz");
|
||||
const { instance } = await importChunk(() => import("@viz-js/viz"));
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
const svg = viz.renderSVGElement(dot);
|
||||
|
|
|
|||
|
|
@ -9,10 +9,17 @@ import { Toaster as SonnerToaster, toast as sonnerToast, useSonner } from "sonne
|
|||
|
||||
export type ToastTone = "info" | "error";
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface ToastInput {
|
||||
message: string;
|
||||
tone?: ToastTone;
|
||||
/** Pass `Infinity` for a toast that stays until dismissed or acted on. */
|
||||
autoDismissMs?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
|
|
@ -27,6 +34,14 @@ function push(toast: ToastInput): string {
|
|||
const id = `toast-${nextToastId++}`;
|
||||
const options = {
|
||||
id,
|
||||
...(toast.action
|
||||
? {
|
||||
action: {
|
||||
label: toast.action.label,
|
||||
onClick: toast.action.onClick,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(toast.tone === "error"
|
||||
? { duration: Infinity }
|
||||
: toast.autoDismissMs != null
|
||||
|
|
|
|||
49
apps/fabro-web/app/hooks/use-build-version-guard.ts
Normal file
49
apps/fabro-web/app/hooks/use-build-version-guard.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useToast } from "../components/toast";
|
||||
import {
|
||||
documentBuildId,
|
||||
isStaleBuild,
|
||||
useLatestBuildId,
|
||||
} from "../lib/build-version";
|
||||
|
||||
const NEW_VERSION_MESSAGE = "A new version of Fabro is available.";
|
||||
|
||||
/**
|
||||
* Synchronizes a reload prompt with the build id the server is publishing.
|
||||
*
|
||||
* Client-side routing never re-fetches `index.html`, and hashed bundles are
|
||||
* served `immutable`, so a tab left open across a deploy keeps running the
|
||||
* previous build's JavaScript indefinitely with nothing to reveal it. This
|
||||
* offers a reload when that happens; it never reloads on its own.
|
||||
*/
|
||||
export function useBuildVersionGuard(): void {
|
||||
const { push } = useToast();
|
||||
// Read once. It describes the document this tab loaded, which cannot change
|
||||
// without a full page load — and that remounts the hook anyway.
|
||||
const [loadedBuildId] = useState<string | null>(documentBuildId);
|
||||
const latestBuildId = useLatestBuildId();
|
||||
const promptedForRef = useRef<string | null>(null);
|
||||
|
||||
const stale = isStaleBuild(loadedBuildId, latestBuildId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stale || !latestBuildId) return;
|
||||
// One prompt per distinct build. Repeated polls of the same new build must
|
||||
// not re-nag, but dismissing this one must not suppress a later, genuinely
|
||||
// different build.
|
||||
if (promptedForRef.current === latestBuildId) return;
|
||||
promptedForRef.current = latestBuildId;
|
||||
|
||||
push({
|
||||
message: NEW_VERSION_MESSAGE,
|
||||
// Persistent by design: a prompt that vanishes after a few seconds is one
|
||||
// the user will miss, which is the whole failure this exists to fix.
|
||||
autoDismissMs: Infinity,
|
||||
action: {
|
||||
label: "Reload",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
}, [latestBuildId, push, stale]);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { importChunk } from "../lib/import-chunk";
|
||||
|
||||
/**
|
||||
* Synchronizes a DOT source with the imperative @viz-js SVG renderer and a DOM
|
||||
* container. Async renders are ignored after identity changes or unmount.
|
||||
|
|
@ -27,7 +29,7 @@ export function useRenderedVizDiagram<TIdentity>({
|
|||
async function render() {
|
||||
setError(null);
|
||||
onRenderStart?.();
|
||||
const { instance } = await import("@viz-js/viz");
|
||||
const { instance } = await importChunk(() => import("@viz-js/viz"));
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
buildTerminalWebSocketUrl,
|
||||
parseTerminalServerMessage,
|
||||
} from "../components/terminal-view-helpers";
|
||||
import { importChunk } from "../lib/import-chunk";
|
||||
|
||||
export type ConnectionStatus = "connecting" | "ready" | "closed" | "error";
|
||||
|
||||
|
|
@ -93,8 +94,8 @@ export function useTerminalSession({
|
|||
setError(null);
|
||||
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
importChunk(() => import("@xterm/xterm")),
|
||||
importChunk(() => import("@xterm/addon-fit")),
|
||||
]);
|
||||
if (disposed || !terminalEl.current) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Link, Outlet, useLocation, useMatches } from "react-router";
|
|||
import { FabroToaster } from "../components/toast";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { TooltipProvider } from "../components/ui";
|
||||
import { useBuildVersionGuard } from "../hooks/use-build-version-guard";
|
||||
import { DemoModeProvider } from "../lib/demo-mode";
|
||||
import { useAuthMe } from "../lib/queries";
|
||||
import { navigation } from "./navigation";
|
||||
|
|
@ -31,6 +32,9 @@ export default function AppShell() {
|
|||
const { data: auth, error, isLoading } = useAuthMe();
|
||||
const { pathname } = useLocation();
|
||||
const matches = useMatches();
|
||||
// Before the early returns below, so the check keeps running while the shell
|
||||
// is in its loading or error state.
|
||||
useBuildVersionGuard();
|
||||
|
||||
if (isLoading && !auth) {
|
||||
return <div className="min-h-full bg-page" />;
|
||||
|
|
|
|||
59
apps/fabro-web/app/lib/build-version.test.ts
Normal file
59
apps/fabro-web/app/lib/build-version.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { fetchBuildId, isStaleBuild } from "./build-version";
|
||||
|
||||
describe("isStaleBuild", () => {
|
||||
test("reports stale only when both ids are known and differ", () => {
|
||||
expect(isStaleBuild("abc", "def")).toBe(true);
|
||||
expect(isStaleBuild("abc", "abc")).toBe(false);
|
||||
});
|
||||
|
||||
// A false "new version" claim is worse than a missed one: it trains people to
|
||||
// ignore the toast. Anything unknown must stay silent.
|
||||
test("stays silent when either side is unknown", () => {
|
||||
expect(isStaleBuild(null, "def")).toBe(false);
|
||||
expect(isStaleBuild("abc", null)).toBe(false);
|
||||
expect(isStaleBuild(null, null)).toBe(false);
|
||||
expect(isStaleBuild("", "def")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBuildId", () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
function stubFetch(response: { ok: boolean; body?: unknown }) {
|
||||
globalThis.fetch = (async () => ({
|
||||
ok: response.ok,
|
||||
json: async () => response.body,
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
test("returns the published build id", async () => {
|
||||
stubFetch({ ok: true, body: { buildId: "8f2yqj8q" } });
|
||||
expect(await fetchBuildId("/build-id.json")).toBe("8f2yqj8q");
|
||||
});
|
||||
|
||||
test("returns null for a non-ok response", async () => {
|
||||
stubFetch({ ok: false });
|
||||
expect(await fetchBuildId("/build-id.json")).toBeNull();
|
||||
});
|
||||
|
||||
// A server that returns something unexpected must not be read as "a new
|
||||
// build shipped" — that would fire the toast on every poll.
|
||||
test("returns null for a malformed body", async () => {
|
||||
stubFetch({ ok: true, body: { buildId: 42 } });
|
||||
expect(await fetchBuildId("/build-id.json")).toBeNull();
|
||||
|
||||
stubFetch({ ok: true, body: {} });
|
||||
expect(await fetchBuildId("/build-id.json")).toBeNull();
|
||||
|
||||
stubFetch({ ok: true, body: null });
|
||||
expect(await fetchBuildId("/build-id.json")).toBeNull();
|
||||
|
||||
stubFetch({ ok: true, body: { buildId: "" } });
|
||||
expect(await fetchBuildId("/build-id.json")).toBeNull();
|
||||
});
|
||||
});
|
||||
68
apps/fabro-web/app/lib/build-version.ts
Normal file
68
apps/fabro-web/app/lib/build-version.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
/** Where `scripts/build.ts` publishes the id of the build being served. */
|
||||
const BUILD_ID_URL = "/build-id.json";
|
||||
|
||||
/**
|
||||
* How often a visible tab re-checks. SWR does not poll while the document is
|
||||
* hidden (`refreshWhenHidden` defaults to false), so background tabs stay
|
||||
* silent without any extra gating, and a hidden tab revalidates on focus.
|
||||
*/
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
export const BUILD_ID_META_NAME = "fabro-build-id";
|
||||
|
||||
/**
|
||||
* The build this document loaded, from the meta tag `scripts/build.ts` writes
|
||||
* into `index.html`.
|
||||
*
|
||||
* The meta tag is the honest source for "what is this tab running": client-side
|
||||
* routing never re-fetches `index.html`, so it stays pinned to the build the
|
||||
* tab actually started with, however long the tab lives.
|
||||
*/
|
||||
export function documentBuildId(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const content = document
|
||||
.querySelector(`meta[name="${BUILD_ID_META_NAME}"]`)
|
||||
?.getAttribute("content")
|
||||
?.trim();
|
||||
return content ? content : null;
|
||||
}
|
||||
|
||||
export async function fetchBuildId(url: string): Promise<string | null> {
|
||||
// Served `no-cache` with an ETag, so the browser revalidates and normally
|
||||
// gets a 304 rather than a fresh body.
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const body: unknown = await response.json();
|
||||
const buildId = (body as { buildId?: unknown } | null)?.buildId;
|
||||
return typeof buildId === "string" && buildId ? buildId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the running document is provably behind what the server is
|
||||
* serving now.
|
||||
*
|
||||
* Unknown on either side means "claim nothing". A missing meta tag (a build
|
||||
* predating this feature, or a non-DOM test environment) or a failed fetch must
|
||||
* never produce a reload prompt — a false "new version" claim is worse than a
|
||||
* missed one, because it teaches people to ignore the real ones.
|
||||
*/
|
||||
export function isStaleBuild(
|
||||
loaded: string | null,
|
||||
latest: string | null,
|
||||
): boolean {
|
||||
if (!loaded || !latest) return false;
|
||||
return loaded !== latest;
|
||||
}
|
||||
|
||||
/** Synchronizes React with the build id the server is currently publishing. */
|
||||
export function useLatestBuildId(): string | null {
|
||||
const { data } = useSWR(BUILD_ID_URL, fetchBuildId, {
|
||||
refreshInterval: POLL_INTERVAL_MS,
|
||||
revalidateOnFocus: true,
|
||||
// A failed check is not worth retry storms; the next poll covers it.
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
return data ?? null;
|
||||
}
|
||||
132
apps/fabro-web/app/lib/import-chunk.test.ts
Normal file
132
apps/fabro-web/app/lib/import-chunk.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { importChunk } from "./import-chunk";
|
||||
|
||||
interface WindowStub {
|
||||
reloads: number;
|
||||
store: Map<string, string>;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* bun:test runs without a DOM, so install a `window` carrying just the surface
|
||||
* `importChunk` touches. Follows the descriptor save/restore pattern used by
|
||||
* stage-insights-sidebar.test.tsx so other test files can install their own.
|
||||
*/
|
||||
function installWindow({ throwOnStorage = false } = {}): WindowStub {
|
||||
const store = new Map<string, string>();
|
||||
const stub: WindowStub = {
|
||||
reloads: 0,
|
||||
store,
|
||||
restore: () => undefined,
|
||||
};
|
||||
|
||||
const windowStub = {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => {
|
||||
if (throwOnStorage) throw new Error("storage disabled");
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
if (throwOnStorage) throw new Error("storage disabled");
|
||||
store.set(key, value);
|
||||
},
|
||||
},
|
||||
location: {
|
||||
reload: () => {
|
||||
stub.reloads += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const had = "window" in globalThis;
|
||||
const prev = (globalThis as { window?: unknown }).window;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: windowStub,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
stub.restore = () => {
|
||||
if (had) {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: prev,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
let installed: WindowStub | null = null;
|
||||
afterEach(() => {
|
||||
installed?.restore();
|
||||
installed = null;
|
||||
});
|
||||
|
||||
describe("importChunk", () => {
|
||||
test("passes a successful import through untouched", async () => {
|
||||
installed = installWindow();
|
||||
await expect(importChunk(async () => "loaded")).resolves.toBe("loaded");
|
||||
expect(installed.reloads).toBe(0);
|
||||
});
|
||||
|
||||
test("reloads once and rethrows when a chunk fails to load", async () => {
|
||||
installed = installWindow();
|
||||
const failure = new Error("Failed to fetch dynamically imported module");
|
||||
|
||||
await expect(
|
||||
importChunk(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toThrow(failure);
|
||||
|
||||
expect(installed.reloads).toBe(1);
|
||||
});
|
||||
|
||||
// Without this, a chunk that fails for a reason a reload cannot fix would
|
||||
// reload forever.
|
||||
test("does not reload again for the same build", async () => {
|
||||
installed = installWindow();
|
||||
const load = async () => {
|
||||
throw new Error("Failed to fetch dynamically imported module");
|
||||
};
|
||||
|
||||
await expect(importChunk(load)).rejects.toThrow();
|
||||
await expect(importChunk(load)).rejects.toThrow();
|
||||
await expect(importChunk(load)).rejects.toThrow();
|
||||
|
||||
expect(installed.reloads).toBe(1);
|
||||
});
|
||||
|
||||
// The marker is keyed by build id, so a tab that recovers from one deploy
|
||||
// still has a reload available for the next.
|
||||
test("keys the once-only marker by build id", async () => {
|
||||
installed = installWindow();
|
||||
await expect(
|
||||
importChunk(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect([...installed.store.keys()]).toEqual([
|
||||
"fabro:chunk-reload:unknown",
|
||||
]);
|
||||
});
|
||||
|
||||
// No durable marker means no way to promise "only once", and a reload loop is
|
||||
// far worse than a surfaced error.
|
||||
test("does not reload when session storage is unavailable", async () => {
|
||||
installed = installWindow({ throwOnStorage: true });
|
||||
|
||||
await expect(
|
||||
importChunk(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(installed.reloads).toBe(0);
|
||||
});
|
||||
});
|
||||
54
apps/fabro-web/app/lib/import-chunk.ts
Normal file
54
apps/fabro-web/app/lib/import-chunk.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { documentBuildId } from "./build-version";
|
||||
|
||||
const RELOAD_MARKER_PREFIX = "fabro:chunk-reload:";
|
||||
|
||||
/**
|
||||
* Loads a lazily-imported chunk, reloading the page once if it cannot be
|
||||
* fetched.
|
||||
*
|
||||
* Each deploy replaces the served assets and the previous build's hashed
|
||||
* filenames stop existing, so a tab open across a deploy can request a chunk
|
||||
* that now 404s. Static route imports mean most of the graph is already in
|
||||
* memory, but the handful of genuinely lazy imports — the terminal, Graphviz
|
||||
* rendering, the file tree — are loaded on demand and can land in that window.
|
||||
*
|
||||
* A failed chunk means the feature is already broken, so reloading is recovery
|
||||
* rather than an interruption. This is the one place the app reloads without an
|
||||
* explicit click; the build-version toast never does.
|
||||
*/
|
||||
export function importChunk<T>(load: () => Promise<T>): Promise<T> {
|
||||
return load().catch((error: unknown) => {
|
||||
reloadOnceForStaleChunk();
|
||||
// Rethrow rather than returning a never-settling promise. The reload
|
||||
// normally replaces the document before this surfaces; if it doesn't, an
|
||||
// error boundary is a better outcome than a spinner that hangs forever.
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads at most once per build.
|
||||
*
|
||||
* Keyed by build id rather than a bare flag so a tab that recovers from one
|
||||
* deploy still has a reload available for the next one. Without the key, a
|
||||
* single chunk failure would disarm the backstop for the rest of the session.
|
||||
*
|
||||
* A module that loads fine but throws while evaluating is indistinguishable
|
||||
* here from a missing chunk, so it also spends the reload. The per-build key
|
||||
* bounds the cost at one wasted reload, after which the real error surfaces.
|
||||
*/
|
||||
function reloadOnceForStaleChunk(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const key = `${RELOAD_MARKER_PREFIX}${documentBuildId() ?? "unknown"}`;
|
||||
try {
|
||||
if (window.sessionStorage.getItem(key)) return;
|
||||
window.sessionStorage.setItem(key, "1");
|
||||
} catch {
|
||||
// Storage disabled or full. Without a durable marker we can't guarantee
|
||||
// "only once", and a reload loop is far worse than a surfaced error.
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type FileContents,
|
||||
} from "@pierre/diffs/react";
|
||||
import { useToast } from "../components/toast";
|
||||
import { importChunk } from "../lib/import-chunk";
|
||||
import type {
|
||||
FileDiff as ApiFileDiff,
|
||||
PaginatedRunFileList,
|
||||
|
|
@ -58,9 +59,11 @@ import { useTickingNow } from "../lib/time";
|
|||
export { extractRequestId };
|
||||
|
||||
const FileTreeSidebar = lazy(() =>
|
||||
import("./run-files/file-tree-sidebar").then((module) => ({
|
||||
default: module.FileTreeSidebar,
|
||||
})),
|
||||
importChunk(() =>
|
||||
import("./run-files/file-tree-sidebar").then((module) => ({
|
||||
default: module.FileTreeSidebar,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
export const handle = { wide: true, fullHeight: true };
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
{{styles}}
|
||||
{{buildMeta}}
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { test, expect } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { lstat, readdir, readlink } from "node:fs/promises";
|
||||
import { lstat, readFile, readdir, readlink } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
const root = Bun.fileURLToPath(new URL("..", import.meta.url));
|
||||
|
|
@ -64,6 +64,64 @@ test("dist is a symlink into .dist-builds and old builds are pruned", async () =
|
|||
expect(existsSync(join(distPath, "index.html"))).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
test("publishes a build id that index.html and build-id.json agree on", async () => {
|
||||
await runBuild();
|
||||
|
||||
const distPath = join(root, "dist");
|
||||
const published = JSON.parse(
|
||||
await readFile(join(distPath, "build-id.json"), "utf8"),
|
||||
) as { buildId: string };
|
||||
|
||||
expect(published.buildId).toMatch(/^[a-z0-9]{8}$/);
|
||||
|
||||
const html = await readFile(join(distPath, "index.html"), "utf8");
|
||||
expect(html).toContain(
|
||||
`<meta name="fabro-build-id" content="${published.buildId}" />`,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
// The id is derived from source inputs rather than emitted filenames precisely
|
||||
// so this holds: Bun's minified identifier naming is not deterministic, so the
|
||||
// entry bundle's content hash changes between builds of an unchanged tree
|
||||
// roughly one run in three. An id that moved with it would fire the client's
|
||||
// "new version" toast on redeploys of identical code.
|
||||
test("build id is stable across rebuilds of an unchanged tree", async () => {
|
||||
const distPath = join(root, "dist");
|
||||
const readBuildId = async () =>
|
||||
(
|
||||
JSON.parse(await readFile(join(distPath, "build-id.json"), "utf8")) as {
|
||||
buildId: string;
|
||||
}
|
||||
).buildId;
|
||||
|
||||
await runBuild();
|
||||
const first = await readBuildId();
|
||||
await runBuild();
|
||||
const second = await readBuildId();
|
||||
|
||||
expect(second).toBe(first);
|
||||
}, 120000);
|
||||
|
||||
// A stable-named stylesheet is served `no-cache`, letting a tab revalidate into
|
||||
// new CSS while running old JS; Tailwind purges per build, so classes the old
|
||||
// bundle still emits can vanish. The hash must match the `[a-z0-9]{8}` shape
|
||||
// `is_content_hashed` in static_files.rs keys on.
|
||||
test("stylesheet is content-hashed and referenced from index.html", async () => {
|
||||
await runBuild();
|
||||
|
||||
const distPath = join(root, "dist");
|
||||
const assets = await readdir(join(distPath, "assets"));
|
||||
const stylesheets = assets.filter((file) => /^app-.*\.css$/.test(file));
|
||||
|
||||
expect(stylesheets).toHaveLength(1);
|
||||
expect(stylesheets[0]).toMatch(/^app-[a-z0-9]{8}\.css$/);
|
||||
expect(assets).not.toContain("app.css");
|
||||
|
||||
const html = await readFile(join(distPath, "index.html"), "utf8");
|
||||
expect(html).toContain(`href="/assets/${stylesheets[0]}"`);
|
||||
expect(html).not.toContain('href="/assets/app.css"');
|
||||
}, 60000);
|
||||
|
||||
test("watch mode keeps running until interrupted", async () => {
|
||||
const process = Bun.spawn([
|
||||
"bun",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { watch as fsWatch } from "node:fs";
|
||||
import {
|
||||
cp,
|
||||
|
|
@ -34,13 +35,103 @@ const tailwindCliBin = join(
|
|||
JSON.parse(await readFile(tailwindCliPackageJsonPath, "utf8")).bin.tailwindcss,
|
||||
);
|
||||
|
||||
function newBuildId(): string {
|
||||
// Names the `.dist-builds/` staging directory only. Time-ordered so builds sort
|
||||
// chronologically on disk, and unique so concurrent builds never collide. This
|
||||
// is deliberately NOT the id published to browsers: see `publishedBuildId`.
|
||||
function newBuildDirName(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercase-alphanumeric 8-char digest, matching the `[a-z0-9]{8}` shape the
|
||||
* bundler uses for its own content hashes — and which the server's cache
|
||||
* classifier (`is_content_hashed` in `static_files.rs`) keys on to decide
|
||||
* between `immutable` and `no-cache`.
|
||||
*/
|
||||
function toShortId(hex: string): string {
|
||||
return BigInt(`0x${hex.slice(0, 32)}`)
|
||||
.toString(36)
|
||||
.padStart(8, "0")
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function contentHash8(content: string | Uint8Array): string {
|
||||
return toShortId(createHash("sha256").update(content).digest("hex"));
|
||||
}
|
||||
|
||||
// Everything that determines what the bundle contains. `bun.lock` lives at the
|
||||
// workspace root, so a dependency bump changes the id even though no file under
|
||||
// `app/` moved.
|
||||
const BUILD_INPUT_DIRS = ["app", "public"];
|
||||
const BUILD_INPUT_FILES = [
|
||||
"index.template.html",
|
||||
"package.json",
|
||||
"scripts/build.ts",
|
||||
"../../bun.lock",
|
||||
];
|
||||
|
||||
/**
|
||||
* The build id published to browsers, derived from the bundle's *source inputs*.
|
||||
*
|
||||
* The obvious implementation — hash the emitted asset filenames, which already
|
||||
* embed content hashes — does not work, because **Bun's minified identifier
|
||||
* naming is not deterministic**. Building this app twice from an unchanged tree
|
||||
* produces byte-different output roughly one run in three: same length, ~100k
|
||||
* differing bytes, all of it mangled names (`var Gr=C3((Pl5,qq)=>` in one run,
|
||||
* `var yr=C3((Uc5,Oq)=>` in the next). Output hashes therefore change without
|
||||
* any source change.
|
||||
*
|
||||
* That matters because the client shows a "new version" toast on mismatch. An id
|
||||
* that flips at random would fire the toast on redeploys of identical code and
|
||||
* train people to ignore it, which is worse than having no toast at all. Hashing
|
||||
* the inputs makes the id change if and only if something we actually control
|
||||
* changed.
|
||||
*
|
||||
* The tradeoff: when Bun emits a different permutation for the same source, the
|
||||
* asset filenames change while the build id does not, so an open tab isn't told
|
||||
* to reload. That is the correct call — the two builds are the same program —
|
||||
* and `importChunk` covers the case where such a tab later needs a chunk whose
|
||||
* name moved.
|
||||
*/
|
||||
async function publishedBuildId(): Promise<string> {
|
||||
const files: string[] = [];
|
||||
for (const dir of BUILD_INPUT_DIRS) {
|
||||
files.push(...(await collectFilesRecursively(join(rootPath, dir))));
|
||||
}
|
||||
for (const file of BUILD_INPUT_FILES) {
|
||||
files.push(join(rootPath, file));
|
||||
}
|
||||
|
||||
const digest = createHash("sha256");
|
||||
// The bundler itself is an input: a Bun upgrade can change output semantics.
|
||||
digest.update(`bun:${Bun.version}\n`);
|
||||
for (const file of files.sort()) {
|
||||
// Hash the repo-relative path, not the absolute one, so the id doesn't
|
||||
// depend on where the repo is checked out.
|
||||
digest.update(relative(rootPath, file));
|
||||
digest.update("\0");
|
||||
digest.update(createHash("sha256").update(await readFile(file)).digest());
|
||||
}
|
||||
return toShortId(digest.digest("hex"));
|
||||
}
|
||||
|
||||
async function collectFilesRecursively(dir: string): Promise<string[]> {
|
||||
const collected: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collected.push(...(await collectFilesRecursively(full)));
|
||||
} else if (entry.isFile()) {
|
||||
collected.push(full);
|
||||
}
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
async function buildOnce() {
|
||||
const buildId = newBuildId();
|
||||
const buildDir = join(buildsRootDir, buildId);
|
||||
const buildDirName = newBuildDirName();
|
||||
const buildDir = join(buildsRootDir, buildDirName);
|
||||
const buildAssetsDir = join(buildDir, "assets");
|
||||
await mkdir(buildAssetsDir, { recursive: true });
|
||||
|
||||
|
|
@ -75,18 +166,48 @@ async function buildOnce() {
|
|||
throw new Error("Tailwind build failed");
|
||||
}
|
||||
|
||||
const stylesheetPath = await hashStylesheet(buildDir, buildAssetsDir);
|
||||
|
||||
await cp(publicDir, buildDir, { recursive: true });
|
||||
await copyPierreWorkerAssets(join(buildAssetsDir, "pierre-diffs-worker"));
|
||||
await writeIndexHtml(
|
||||
buildDir,
|
||||
result.outputs.map((output: any) => ({
|
||||
kind: output.kind,
|
||||
path: relative(buildDir, output.path),
|
||||
})),
|
||||
|
||||
const outputs: IndexHtmlOutput[] = result.outputs.map((output: any) => ({
|
||||
kind: output.kind,
|
||||
path: relative(buildDir, output.path),
|
||||
}));
|
||||
const buildId = await publishedBuildId();
|
||||
|
||||
await writeIndexHtml(buildDir, outputs, stylesheetPath, buildId);
|
||||
// Served with `no-cache` + ETag (it doesn't match the server's content-hash
|
||||
// pattern), so a polling client revalidates it as a cheap 304.
|
||||
await writeFile(
|
||||
join(buildDir, "build-id.json"),
|
||||
`${JSON.stringify({ buildId }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await publishBuild(buildDir);
|
||||
await pruneOldBuilds(buildId);
|
||||
await pruneOldBuilds(buildDirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames Tailwind's stable-named `app.css` to `app-<hash>.css`.
|
||||
*
|
||||
* A stable name forces `no-cache`, which lets a tab revalidate into the new
|
||||
* stylesheet while still running the previous build's JavaScript. Tailwind
|
||||
* purges unused classes per build, so classes the old JS still emits can vanish
|
||||
* from the new CSS and elements silently render unstyled. Hashing pins the two
|
||||
* together and lets the server cache the stylesheet immutably.
|
||||
*/
|
||||
async function hashStylesheet(
|
||||
buildDir: string,
|
||||
buildAssetsDir: string,
|
||||
): Promise<string> {
|
||||
const source = join(buildAssetsDir, "app.css");
|
||||
const css = await readFile(source);
|
||||
const hashedName = `app-${contentHash8(css)}.css`;
|
||||
await rename(source, join(buildAssetsDir, hashedName));
|
||||
return relative(buildDir, join(buildAssetsDir, hashedName));
|
||||
}
|
||||
|
||||
async function copyPierreWorkerAssets(targetDir: string) {
|
||||
|
|
@ -110,7 +231,12 @@ type IndexHtmlOutput = {
|
|||
path: string;
|
||||
};
|
||||
|
||||
async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
|
||||
async function writeIndexHtml(
|
||||
buildDir: string,
|
||||
outputs: IndexHtmlOutput[],
|
||||
stylesheetPath: string,
|
||||
buildId: string,
|
||||
) {
|
||||
const template = await readFile(templatePath, "utf8");
|
||||
// Only entry points get <script> tags. Bun's `splitting: true` emits
|
||||
// hundreds of chunks reachable from the entry through static and dynamic
|
||||
|
|
@ -123,7 +249,7 @@ async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
|
|||
.map((output) => `<script type="module" src="/${output.path.replaceAll("\\\\", "/")}"></script>`)
|
||||
.join("\n ");
|
||||
const styles = [
|
||||
"/assets/app.css",
|
||||
`/${stylesheetPath.replaceAll("\\\\", "/")}`,
|
||||
...outputs
|
||||
.filter((output) => output.path.endsWith(".css"))
|
||||
.map((output) => `/${output.path.replaceAll("\\\\", "/")}`),
|
||||
|
|
@ -132,8 +258,15 @@ async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
|
|||
.map((path) => `<link rel="stylesheet" href="${path}" />`)
|
||||
.join("\n ");
|
||||
|
||||
// Records which build this document loaded. A tab reads it back at runtime
|
||||
// and compares against /build-id.json; the meta tag is the honest answer
|
||||
// because client-side routing never re-fetches index.html, so it stays
|
||||
// pinned to the build the tab actually started with.
|
||||
const buildMeta = `<meta name="fabro-build-id" content="${buildId}" />`;
|
||||
|
||||
const html = template
|
||||
.replace("{{styles}}", styles)
|
||||
.replace("{{buildMeta}}", buildMeta)
|
||||
.replace("{{scripts}}", scripts);
|
||||
|
||||
await writeFile(join(buildDir, "index.html"), html, "utf8");
|
||||
|
|
|
|||
|
|
@ -473,6 +473,10 @@ mod tests {
|
|||
"assets/entry-0sv53bs3.js",
|
||||
"assets/chunk-4tr91ktd.js",
|
||||
"assets/chunk-x912wb67.css",
|
||||
// Tailwind's stylesheet is hashed by the build so it moves with the
|
||||
// bundle; a stable name would let a tab revalidate into new CSS
|
||||
// while still running the previous build's JavaScript.
|
||||
"assets/app-381qtfxr.css",
|
||||
] {
|
||||
assert!(is_content_hashed(path), "{path} should be content-hashed");
|
||||
}
|
||||
|
|
@ -485,6 +489,9 @@ mod tests {
|
|||
// pinned stale in browsers for a year if marked immutable.
|
||||
for path in [
|
||||
"index.html",
|
||||
// Clients poll this to learn whether their tab is running a stale
|
||||
// build, so it must revalidate rather than be pinned for a year.
|
||||
"build-id.json",
|
||||
"assets/app.css",
|
||||
"assets/pierre-diffs-worker/worker-portable.js",
|
||||
"images/apple-touch-icon.png",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue