diff --git a/AGENTS.md b/AGENTS.md
index bdc9ab53f..59712966b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -123,7 +123,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **fabro-util** — Shared utilities (redaction, terminal formatting)
### TypeScript (`apps/` and `lib/packages/`)
-- **apps/fabro-web** — React 19 + React Router + Vite + Tailwind CSS frontend
+- **apps/fabro-web** — React 19 + React Router + Tailwind CSS frontend, bundled by a custom Bun script (`apps/fabro-web/scripts/build.ts`), not Vite
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
### Key design patterns
diff --git a/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts b/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts
index 688f27d8b..d11b7bb67 100644
--- a/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts
+++ b/apps/fabro-web/app/components/playground/canvas/use-canvas-render.ts
@@ -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);
diff --git a/apps/fabro-web/app/components/toast.tsx b/apps/fabro-web/app/components/toast.tsx
index afa57fa90..02bfe9294 100644
--- a/apps/fabro-web/app/components/toast.tsx
+++ b/apps/fabro-web/app/components/toast.tsx
@@ -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,7 @@ function push(toast: ToastInput): string {
const id = `toast-${nextToastId++}`;
const options = {
id,
+ ...(toast.action ? { action: toast.action } : {}),
...(toast.tone === "error"
? { duration: Infinity }
: toast.autoDismissMs != null
@@ -55,9 +63,9 @@ const toastApi: ToastContextValue = {
/**
* No-op wrapper retained so existing test harnesses and the standalone terminal
* route can keep their mount points. In a browser the real
- * is mounted globally in AppShell; in non-DOM test environments we
- * render an aria-live fallback that subscribes to the Sonner store so test
- * assertions can read the toast text.
+ * is mounted globally at the entry point; in non-DOM test
+ * environments we render an aria-live fallback that subscribes to the Sonner
+ * store so test assertions can read the toast text.
*/
export function ToastProvider({ children }: { children: ReactNode }) {
if (typeof document !== "undefined") {
diff --git a/apps/fabro-web/app/entry.tsx b/apps/fabro-web/app/entry.tsx
index ff9d681f9..a3488b680 100644
--- a/apps/fabro-web/app/entry.tsx
+++ b/apps/fabro-web/app/entry.tsx
@@ -2,6 +2,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { SWRConfig } from "swr";
+import { FabroToaster } from "./components/toast";
+import { useBuildVersionGuard } from "./hooks/use-build-version-guard";
import { installRoutes } from "./install-router";
import { resolveFabroMode } from "./mode";
import { routes } from "./router";
@@ -21,6 +23,17 @@ if (!rootElement) {
throw new Error("Missing #root element");
}
+function AppRuntime() {
+ useBuildVersionGuard();
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
createRoot(rootElement).render(
-
+
,
);
diff --git a/apps/fabro-web/app/hooks/dynamic-import-errors.test.tsx b/apps/fabro-web/app/hooks/dynamic-import-errors.test.tsx
new file mode 100644
index 000000000..1add46d1b
--- /dev/null
+++ b/apps/fabro-web/app/hooks/dynamic-import-errors.test.tsx
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, expect, mock, test } from "bun:test";
+import { useState } from "react";
+import TestRenderer, { act } from "react-test-renderer";
+
+import { setupReactTestEnv } from "../lib/test-utils";
+import type {
+ ConnectionStatus,
+ TerminalConnectionError,
+} from "./use-terminal-session";
+
+const importFailure = new Error("chunk unavailable");
+
+mock.module("../lib/import-chunk", () => ({
+ importChunk: async () => {
+ throw importFailure;
+ },
+}));
+
+const [{ useRenderedVizDiagram }, { useTerminalSession }] = await Promise.all([
+ import("./use-rendered-viz-diagram"),
+ import("./use-terminal-session"),
+]);
+
+let renderer: TestRenderer.ReactTestRenderer | null = null;
+let restoreReactTestEnv = () => {};
+
+beforeEach(() => {
+ restoreReactTestEnv = setupReactTestEnv();
+});
+
+afterEach(() => {
+ act(() => {
+ renderer?.unmount();
+ });
+ renderer = null;
+ restoreReactTestEnv();
+});
+
+async function renderAndFlush(element: React.ReactElement) {
+ await act(async () => {
+ renderer = TestRenderer.create(element);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+const diagramContainerRef = { current: null };
+const diagramSvgRef = { current: null };
+const buildDot = () => "digraph {}";
+let diagramError: string | null = null;
+
+function DiagramHost() {
+ diagramError = useRenderedVizDiagram({
+ buildDot,
+ innerRef: diagramContainerRef,
+ identity: "diagram",
+ svgRef: diagramSvgRef,
+ });
+ return null;
+}
+
+test("diagram import failures populate the hook error instead of rejecting", async () => {
+ await renderAndFlush();
+ expect(diagramError).toBe("chunk unavailable");
+});
+
+const terminalElementRef = {
+ current: {} as HTMLDivElement,
+};
+let terminalError: TerminalConnectionError | null = null;
+let terminalStatus: ConnectionStatus = "closed";
+
+function TerminalHost() {
+ const [error, setError] = useState(null);
+ const [status, setStatus] = useState("closed");
+ terminalError = error;
+ terminalStatus = status;
+ useTerminalSession({
+ connectionKey: 0,
+ runId: "run_1",
+ setError,
+ setStatus,
+ terminalEl: terminalElementRef,
+ });
+ return null;
+}
+
+test("terminal import failures leave a recoverable error state", async () => {
+ await renderAndFlush();
+ expect(terminalStatus).toBe("error");
+ expect(terminalError).toEqual({
+ message: "Terminal initialization failed: chunk unavailable",
+ recoverable: true,
+ });
+});
diff --git a/apps/fabro-web/app/hooks/use-build-version-guard.test.tsx b/apps/fabro-web/app/hooks/use-build-version-guard.test.tsx
new file mode 100644
index 000000000..bec6a7d36
--- /dev/null
+++ b/apps/fabro-web/app/hooks/use-build-version-guard.test.tsx
@@ -0,0 +1,132 @@
+import { afterEach, beforeEach, expect, mock, test } from "bun:test";
+import TestRenderer, { act } from "react-test-renderer";
+import { toast as sonnerToast } from "sonner";
+
+import { setupReactTestEnv } from "../lib/test-utils";
+
+const loadedBuildId = "aaaaaaaa";
+let latestBuildId: string | null = loadedBuildId;
+
+mock.module("../lib/build-version", () => ({
+ documentBuildId: () => loadedBuildId,
+ isStaleBuild: (loaded: string | null, latest: string | null) =>
+ loaded != null && latest != null && loaded !== latest,
+ useLatestBuildId: () => latestBuildId,
+}));
+
+const { useBuildVersionGuard } = await import("./use-build-version-guard");
+
+let renderer: TestRenderer.ReactTestRenderer | null = null;
+let restoreReactTestEnv = () => {};
+let reloads = 0;
+let previousWindow: unknown;
+let hadWindow = false;
+let previousRequestAnimationFrame: typeof requestAnimationFrame | undefined;
+
+function GuardHost({ revision }: { revision: number }) {
+ void revision;
+ useBuildVersionGuard();
+ return null;
+}
+
+function activeToasts() {
+ return sonnerToast.getToasts().filter((toast) => !toast.delete);
+}
+
+function renderRevision(revision: number) {
+ act(() => {
+ if (renderer) {
+ renderer.update();
+ } else {
+ renderer = TestRenderer.create();
+ }
+ });
+}
+
+beforeEach(() => {
+ restoreReactTestEnv = setupReactTestEnv();
+ latestBuildId = loadedBuildId;
+ reloads = 0;
+ hadWindow = "window" in globalThis;
+ previousWindow = (globalThis as { window?: unknown }).window;
+ previousRequestAnimationFrame = globalThis.requestAnimationFrame;
+ globalThis.requestAnimationFrame = (callback) =>
+ setTimeout(callback, 0) as unknown as number;
+ Object.defineProperty(globalThis, "window", {
+ value: {
+ location: {
+ reload: () => {
+ reloads += 1;
+ },
+ },
+ },
+ writable: true,
+ configurable: true,
+ });
+});
+
+afterEach(() => {
+ act(() => {
+ renderer?.unmount();
+ });
+ renderer = null;
+ sonnerToast.dismiss();
+ if (hadWindow) {
+ Object.defineProperty(globalThis, "window", {
+ value: previousWindow,
+ writable: true,
+ configurable: true,
+ });
+ } else {
+ delete (globalThis as { window?: unknown }).window;
+ }
+ if (previousRequestAnimationFrame) {
+ globalThis.requestAnimationFrame = previousRequestAnimationFrame;
+ } else {
+ delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame })
+ .requestAnimationFrame;
+ }
+ restoreReactTestEnv();
+});
+
+test("keeps one actionable prompt synchronized with the latest stale build", () => {
+ renderRevision(0);
+ expect(activeToasts()).toHaveLength(0);
+
+ latestBuildId = "bbbbbbbb";
+ renderRevision(1);
+ const firstPrompt = activeToasts();
+ expect(firstPrompt).toHaveLength(1);
+ expect(firstPrompt[0]).toMatchObject({
+ duration: Infinity,
+ title: "A new version of Fabro is available.",
+ });
+
+ const action = firstPrompt[0]?.action;
+ expect(action).toMatchObject({ label: "Reload" });
+ if (action && typeof action === "object" && "onClick" in action) {
+ action.onClick({} as never);
+ }
+ expect(reloads).toBe(1);
+
+ // Re-rendering the same poll result neither re-nags nor replaces the toast.
+ renderRevision(2);
+ expect(activeToasts()).toHaveLength(1);
+ expect(activeToasts()[0]?.id).toBe(firstPrompt[0]?.id);
+
+ // Later deploys replace the active prompt instead of accumulating forever.
+ latestBuildId = "cccccccc";
+ renderRevision(3);
+ expect(activeToasts()).toHaveLength(1);
+ expect(activeToasts()[0]?.id).not.toBe(firstPrompt[0]?.id);
+
+ latestBuildId = "bbbbbbbb";
+ renderRevision(4);
+ expect(activeToasts()).toHaveLength(1);
+
+ // If the server rolls back to the document's build, the claim is no longer
+ // true and the prompt disappears.
+ latestBuildId = loadedBuildId;
+ renderRevision(5);
+ expect(activeToasts()).toHaveLength(0);
+});
diff --git a/apps/fabro-web/app/hooks/use-build-version-guard.ts b/apps/fabro-web/app/hooks/use-build-version-guard.ts
new file mode 100644
index 000000000..31367c164
--- /dev/null
+++ b/apps/fabro-web/app/hooks/use-build-version-guard.ts
@@ -0,0 +1,57 @@
+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 { dismiss, 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(documentBuildId);
+ const latestBuildId = useLatestBuildId();
+ const promptRef = useRef<{ buildId: string; toastId: string } | null>(null);
+
+ const stale = isStaleBuild(loadedBuildId, latestBuildId);
+
+ useEffect(() => {
+ if (!latestBuildId) return;
+ if (!stale) {
+ // A rollback to the document's own build makes an existing prompt false.
+ if (promptRef.current) {
+ dismiss(promptRef.current.toastId);
+ promptRef.current = null;
+ }
+ return;
+ }
+ if (promptRef.current?.buildId === latestBuildId) return;
+ if (promptRef.current) {
+ dismiss(promptRef.current.toastId);
+ }
+
+ const toastId = 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(),
+ },
+ });
+ promptRef.current = { buildId: latestBuildId, toastId };
+ }, [dismiss, latestBuildId, push, stale]);
+}
diff --git a/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts b/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts
index 9733f335e..b9627b712 100644
--- a/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts
+++ b/apps/fabro-web/app/hooks/use-rendered-viz-diagram.ts
@@ -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.
@@ -26,12 +28,13 @@ export function useRenderedVizDiagram({
async function render() {
setError(null);
- onRenderStart?.();
- const { instance } = await import("@viz-js/viz");
- const viz = await instance();
- if (cancelled) return;
try {
+ onRenderStart?.();
+ const { instance } = await importChunk(() => import("@viz-js/viz"));
+ const viz = await instance();
+ if (cancelled) return;
+
const svg = viz.renderSVGElement(buildDot(identity));
prepareSvg?.(svg);
@@ -40,7 +43,9 @@ export function useRenderedVizDiagram({
innerRef.current.replaceChildren(svg);
}
} catch (e) {
- setError(e instanceof Error ? e.message : "Failed to render diagram");
+ if (!cancelled) {
+ setError(e instanceof Error ? e.message : "Failed to render diagram");
+ }
}
}
diff --git a/apps/fabro-web/app/hooks/use-terminal-session.ts b/apps/fabro-web/app/hooks/use-terminal-session.ts
index c8476f4e8..963f00a0d 100644
--- a/apps/fabro-web/app/hooks/use-terminal-session.ts
+++ b/apps/fabro-web/app/hooks/use-terminal-session.ts
@@ -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";
@@ -88,13 +89,32 @@ export function useTerminalSession({
const textEncoder = new TextEncoder();
const disposables: Array<{ dispose: () => void }> = [];
+ function disposeResources() {
+ resizeObserver?.disconnect();
+ resizeObserver = null;
+ for (const disposable of disposables.splice(0)) disposable.dispose();
+
+ const socket = socketRef.current;
+ if (socket) {
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: "close" }));
+ }
+ socket.close();
+ socketRef.current = null;
+ }
+
+ terminalRef.current?.dispose();
+ terminalRef.current = null;
+ fitRef.current = null;
+ }
+
async function connect() {
setStatus("connecting");
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;
@@ -108,12 +128,12 @@ export function useTerminalSession({
theme: TERMINAL_THEME,
});
const fitAddon = new FitAddon();
+ terminalRef.current = terminal;
+ fitRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.open(terminalEl.current);
fitAddon.fit();
terminal.focus();
- terminalRef.current = terminal;
- fitRef.current = fitAddon;
const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId));
socket.binaryType = "arraybuffer";
@@ -190,18 +210,21 @@ export function useTerminalSession({
}
}
- void connect();
+ void connect().catch((error: unknown) => {
+ if (disposed) return;
+ disposeResources();
+ setStatus("error");
+ setError({
+ message: error instanceof Error
+ ? `Terminal initialization failed: ${error.message}`
+ : "Terminal initialization failed.",
+ recoverable: true,
+ });
+ });
return () => {
disposed = true;
- resizeObserver?.disconnect();
- for (const disposable of disposables) disposable.dispose();
- socketRef.current?.send(JSON.stringify({ type: "close" }));
- socketRef.current?.close();
- socketRef.current = null;
- terminalRef.current?.dispose();
- terminalRef.current = null;
- fitRef.current = null;
+ disposeResources();
};
}, [connectionKey, runId, setError, setStatus, terminalEl]);
}
diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx
index c8a758018..6b610f941 100644
--- a/apps/fabro-web/app/layouts/app-shell.tsx
+++ b/apps/fabro-web/app/layouts/app-shell.tsx
@@ -12,7 +12,6 @@ import {
XMarkIcon,
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches } from "react-router";
-import { FabroToaster } from "../components/toast";
import { ErrorState } from "../components/state";
import { TooltipProvider } from "../components/ui";
import { DemoModeProvider } from "../lib/demo-mode";
@@ -248,9 +247,6 @@ export default function AppShell() {
)}
- {typeof document !== "undefined" && (
-
- )}
);
diff --git a/apps/fabro-web/app/lib/build-version-contract.ts b/apps/fabro-web/app/lib/build-version-contract.ts
new file mode 100644
index 000000000..f9fb07059
--- /dev/null
+++ b/apps/fabro-web/app/lib/build-version-contract.ts
@@ -0,0 +1,22 @@
+import { getString } from "./unknown";
+
+export const BUILD_ID_FILE_NAME = "build-id.json";
+export const BUILD_ID_URL = `/${BUILD_ID_FILE_NAME}`;
+export const BUILD_ID_META_NAME = "fabro-build-id";
+export const BUILD_ID_FIELD = "buildId";
+
+const BUILD_ID_PATTERN = /^[a-z0-9]{8}$/;
+
+export function parseBuildId(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const normalized = value.trim();
+ return BUILD_ID_PATTERN.test(normalized) ? normalized : null;
+}
+
+export function parseBuildIdDocument(value: unknown): string | null {
+ return parseBuildId(getString(value, BUILD_ID_FIELD));
+}
+
+export function buildIdDocument(buildId: string): Record {
+ return { [BUILD_ID_FIELD]: buildId };
+}
diff --git a/apps/fabro-web/app/lib/build-version.test.ts b/apps/fabro-web/app/lib/build-version.test.ts
new file mode 100644
index 000000000..59f10f9de
--- /dev/null
+++ b/apps/fabro-web/app/lib/build-version.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, test } from "bun:test";
+
+import { fetchBuildId, isStaleBuild } from "./build-version";
+import { parseBuildId } from "./build-version-contract";
+
+describe("isStaleBuild", () => {
+ test("reports stale only when both ids are known and differ", () => {
+ expect(isStaleBuild("aaaaaaaa", "bbbbbbbb")).toBe(true);
+ expect(isStaleBuild("aaaaaaaa", "aaaaaaaa")).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, "bbbbbbbb")).toBe(false);
+ expect(isStaleBuild("aaaaaaaa", null)).toBe(false);
+ expect(isStaleBuild(null, null)).toBe(false);
+ expect(isStaleBuild("", "bbbbbbbb")).toBe(false);
+ });
+});
+
+describe("fetchBuildId", () => {
+ const realFetch = globalThis.fetch;
+ afterEach(() => {
+ globalThis.fetch = realFetch;
+ });
+
+ function stubFetch(response: Response) {
+ globalThis.fetch = async () => response;
+ }
+
+ test("returns the published build id", async () => {
+ stubFetch(Response.json({ buildId: "8f2yqj8q" }));
+ expect(await fetchBuildId("/build-id.json")).toBe("8f2yqj8q");
+ });
+
+ test("returns null for a non-ok response", async () => {
+ stubFetch(new Response(null, { status: 503 }));
+ 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(Response.json({ buildId: 42 }));
+ expect(await fetchBuildId("/build-id.json")).toBeNull();
+
+ stubFetch(Response.json({}));
+ expect(await fetchBuildId("/build-id.json")).toBeNull();
+
+ stubFetch(Response.json(null));
+ expect(await fetchBuildId("/build-id.json")).toBeNull();
+
+ stubFetch(Response.json({ buildId: "" }));
+ expect(await fetchBuildId("/build-id.json")).toBeNull();
+
+ stubFetch(Response.json({ buildId: "not-a-build-id" }));
+ expect(await fetchBuildId("/build-id.json")).toBeNull();
+
+ stubFetch(new Response(""));
+ expect(await fetchBuildId("/build-id.json")).toBeNull();
+ });
+});
+
+describe("parseBuildId", () => {
+ test("normalizes valid ids and rejects values outside the wire format", () => {
+ expect(parseBuildId(" 8f2yqj8q ")).toBe("8f2yqj8q");
+ expect(parseBuildId(" ")).toBeNull();
+ expect(parseBuildId("8F2YQJ8Q")).toBeNull();
+ expect(parseBuildId("abc123")).toBeNull();
+ expect(parseBuildId(null)).toBeNull();
+ });
+});
diff --git a/apps/fabro-web/app/lib/build-version.ts b/apps/fabro-web/app/lib/build-version.ts
new file mode 100644
index 000000000..36cf54fc5
--- /dev/null
+++ b/apps/fabro-web/app/lib/build-version.ts
@@ -0,0 +1,68 @@
+import useSWR from "swr";
+
+import {
+ BUILD_ID_META_NAME,
+ BUILD_ID_URL,
+ parseBuildId,
+ parseBuildIdDocument,
+} from "./build-version-contract";
+
+/**
+ * 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;
+
+/**
+ * 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");
+ return parseBuildId(content);
+}
+
+export async function fetchBuildId(url: string): Promise {
+ // 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().catch(() => null);
+ return parseBuildIdDocument(body);
+}
+
+/**
+ * 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;
+}
diff --git a/apps/fabro-web/app/lib/import-chunk.test.ts b/apps/fabro-web/app/lib/import-chunk.test.ts
new file mode 100644
index 000000000..06a24ee5e
--- /dev/null
+++ b/apps/fabro-web/app/lib/import-chunk.test.ts
@@ -0,0 +1,170 @@
+import { afterEach, describe, expect, test } from "bun:test";
+
+import { importChunk } from "./import-chunk";
+
+interface WindowStub {
+ buildId: string | null;
+ reloads: number;
+ store: Map;
+ 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({
+ buildId = "aaaaaaaa",
+ throwOnStorage = false,
+}: {
+ buildId?: string | null;
+ throwOnStorage?: boolean;
+} = {}): WindowStub {
+ const store = new Map();
+ const stub: WindowStub = {
+ buildId,
+ 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;
+ const hadDocument = "document" in globalThis;
+ const previousDocument = (globalThis as { document?: unknown }).document;
+ Object.defineProperty(globalThis, "window", {
+ value: windowStub,
+ writable: true,
+ configurable: true,
+ });
+ Object.defineProperty(globalThis, "document", {
+ value: {
+ querySelector: () => stub.buildId == null
+ ? null
+ : {
+ getAttribute: () => stub.buildId,
+ },
+ },
+ 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;
+ }
+ if (hadDocument) {
+ Object.defineProperty(globalThis, "document", {
+ value: previousDocument,
+ writable: true,
+ configurable: true,
+ });
+ } else {
+ delete (globalThis as { document?: unknown }).document;
+ }
+ };
+ 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("allows one recovery reload for each loaded build id", async () => {
+ installed = installWindow();
+ const load = async () => {
+ throw new Error("boom");
+ };
+
+ await expect(importChunk(load)).rejects.toThrow();
+ await expect(importChunk(load)).rejects.toThrow();
+ expect(installed.reloads).toBe(1);
+
+ installed.buildId = "bbbbbbbb";
+ await expect(importChunk(load)).rejects.toThrow();
+ await expect(importChunk(load)).rejects.toThrow();
+
+ expect(installed.reloads).toBe(2);
+ expect([...installed.store.keys()]).toEqual([
+ "fabro:chunk-reload:aaaaaaaa",
+ "fabro:chunk-reload:bbbbbbbb",
+ ]);
+ });
+
+ // 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);
+ });
+});
diff --git a/apps/fabro-web/app/lib/import-chunk.ts b/apps/fabro-web/app/lib/import-chunk.ts
new file mode 100644
index 000000000..fe56eaaf9
--- /dev/null
+++ b/apps/fabro-web/app/lib/import-chunk.ts
@@ -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(load: () => Promise): Promise {
+ 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();
+}
diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx
index a2f3a1a25..aba185f1f 100644
--- a/apps/fabro-web/app/routes/run-files.tsx
+++ b/apps/fabro-web/app/routes/run-files.tsx
@@ -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 };
diff --git a/apps/fabro-web/app/routes/run-terminal.tsx b/apps/fabro-web/app/routes/run-terminal.tsx
index 6e4bddd93..9c6327ddc 100644
--- a/apps/fabro-web/app/routes/run-terminal.tsx
+++ b/apps/fabro-web/app/routes/run-terminal.tsx
@@ -1,5 +1,5 @@
import TerminalView from "../components/terminal-view";
-import { FabroToaster, ToastProvider } from "../components/toast";
+import { ToastProvider } from "../components/toast";
import { useDocumentTitle } from "../hooks/effects";
export default function RunTerminal({ params }: { params: { id: string } }) {
@@ -10,7 +10,6 @@ export default function RunTerminal({ params }: { params: { id: string } }) {
- {typeof document !== "undefined" && }
);
}
diff --git a/apps/fabro-web/index.template.html b/apps/fabro-web/index.template.html
index 5ebdb65b9..eab794756 100644
--- a/apps/fabro-web/index.template.html
+++ b/apps/fabro-web/index.template.html
@@ -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}}
diff --git a/apps/fabro-web/scripts/build.test.ts b/apps/fabro-web/scripts/build.test.ts
index e3e852a71..1a2b9cd44 100644
--- a/apps/fabro-web/scripts/build.test.ts
+++ b/apps/fabro-web/scripts/build.test.ts
@@ -1,10 +1,30 @@
import { test, expect } from "bun:test";
import { existsSync } from "node:fs";
-import { lstat, readdir, readlink } from "node:fs/promises";
-import { basename, join } from "node:path";
+import { lstat, readFile, readdir, readlink } from "node:fs/promises";
+import { basename, join, relative, sep } from "node:path";
+
+import {
+ BUILD_ID_FILE_NAME,
+ BUILD_ID_META_NAME,
+ parseBuildIdDocument,
+} from "../app/lib/build-version-contract";
+import { localBundlerInputPaths } from "./build";
const root = Bun.fileURLToPath(new URL("..", import.meta.url));
+test("resolved build inputs retain workspace sources and omit installed packages", () => {
+ const inputs = localBundlerInputPaths([
+ "app/entry.tsx",
+ "../../lib/packages/fabro-api-client/src/index.ts",
+ "../../node_modules/example/index.js",
+ ]).map((path) => relative(root, path).split(sep).join("/"));
+
+ expect(inputs).toEqual([
+ "app/entry.tsx",
+ "../../lib/packages/fabro-api-client/src/index.ts",
+ ]);
+});
+
async function runBuild() {
const process = Bun.spawn(["bun", "run", "scripts/build.ts"], {
cwd: root,
@@ -22,10 +42,15 @@ async function runBuild() {
}
}
-test("production build copies Pierre worker assets", async () => {
+test("production builds publish a stable asset set and prune the previous build", async () => {
await runBuild();
- const workerDist = join(root, "dist", "assets", "pierre-diffs-worker");
+ const distPath = join(root, "dist");
+ const firstTarget = await readlink(distPath);
+ expect((await lstat(distPath)).isSymbolicLink()).toBe(true);
+ expect(firstTarget.startsWith(".dist-builds/")).toBe(true);
+
+ const workerDist = join(distPath, "assets", "pierre-diffs-worker");
expect(existsSync(join(workerDist, "worker-portable.js"))).toBe(true);
const upstreamWorkerDir = join(
@@ -43,26 +68,43 @@ test("production build copies Pierre worker assets", async () => {
for (const wasmFile of wasmFiles) {
expect(existsSync(join(workerDist, wasmFile))).toBe(true);
}
-}, 60000);
-test("dist is a symlink into .dist-builds and old builds are pruned", async () => {
- await runBuild();
+ const published = JSON.parse(
+ await readFile(join(distPath, BUILD_ID_FILE_NAME), "utf8"),
+ );
+ const firstBuildId = parseBuildIdDocument(published);
+ expect(firstBuildId).not.toBeNull();
+
+ const html = await readFile(join(distPath, "index.html"), "utf8");
+ expect(html).toContain(
+ ``,
+ );
+
+ 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");
+ expect(html).toContain(`href="/assets/${stylesheets[0]}"`);
+ expect(html).not.toContain('href="/assets/app.css"');
+
+ // The id is derived from source inputs rather than emitted filenames. Bun's
+ // minified identifiers are nondeterministic, so output hashes can move even
+ // when the source graph is unchanged.
await runBuild();
- const distPath = join(root, "dist");
- const stat = await lstat(distPath);
- expect(stat.isSymbolicLink()).toBe(true);
-
- const target = await readlink(distPath);
- expect(target.startsWith(".dist-builds/")).toBe(true);
-
- const buildId = target.slice(".dist-builds/".length);
- const buildsRoot = join(root, ".dist-builds");
- const remaining = await readdir(buildsRoot);
- expect(remaining).toEqual([buildId]);
+ const secondTarget = await readlink(distPath);
+ expect(secondTarget.startsWith(".dist-builds/")).toBe(true);
+ expect(secondTarget).not.toBe(firstTarget);
+ const secondBuildId = parseBuildIdDocument(
+ JSON.parse(await readFile(join(distPath, BUILD_ID_FILE_NAME), "utf8")),
+ );
+ expect(secondBuildId).toBe(firstBuildId);
+ const currentDirName = secondTarget.slice(".dist-builds/".length);
+ expect(await readdir(join(root, ".dist-builds"))).toEqual([currentDirName]);
expect(existsSync(join(distPath, "index.html"))).toBe(true);
-}, 60000);
+}, 120000);
test("watch mode keeps running until interrupted", async () => {
const process = Bun.spawn([
diff --git a/apps/fabro-web/scripts/build.ts b/apps/fabro-web/scripts/build.ts
index 78e520d29..360726fae 100644
--- a/apps/fabro-web/scripts/build.ts
+++ b/apps/fabro-web/scripts/build.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import { watch as fsWatch } from "node:fs";
import {
cp,
@@ -10,7 +11,13 @@ import {
symlink,
writeFile,
} from "node:fs/promises";
-import { dirname, join, relative } from "node:path";
+import { dirname, join, relative, resolve, sep } from "node:path";
+
+import {
+ BUILD_ID_FILE_NAME,
+ BUILD_ID_META_NAME,
+ buildIdDocument,
+} from "../app/lib/build-version-contract";
declare const Bun: any;
@@ -34,59 +41,228 @@ 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"));
+}
+
+// Inputs outside Bun's JavaScript module graph. Tailwind scans `app/` for class
+// names, `public/` is copied verbatim, and the remaining files control template
+// rendering, module resolution, dependency versions, or the build itself.
+const BUILD_INPUT_DIRS = ["app", "public"];
+const BUILD_INPUT_FILES = [
+ "index.template.html",
+ "package.json",
+ "scripts/build.ts",
+ "tsconfig.json",
+ "../../bun.lock",
+];
+const FILE_HASH_BATCH_SIZE = 32;
+
+/**
+ * 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(bundlerInputs: Iterable): Promise {
+ const files = new Set();
+ for (const dir of BUILD_INPUT_DIRS) {
+ for (const file of await collectFiles(join(rootPath, dir))) {
+ files.add(file);
+ }
+ }
+ for (const file of BUILD_INPUT_FILES) {
+ files.add(resolve(rootPath, file));
+ }
+ // Bun's metafile is the source of truth for resolved production modules. In
+ // particular, it captures workspace sources reached through tsconfig path
+ // aliases, which a hand-maintained app-local file list would miss.
+ for (const file of localBundlerInputPaths(bundlerInputs)) {
+ files.add(file);
+ }
+
+ const digest = createHash("sha256");
+ // The bundler itself is an input: a Bun upgrade can change output semantics.
+ digest.update(`bun:${Bun.version}\n`);
+ const inputs = [...files]
+ .map((file) => ({
+ file,
+ name: toUrlPath(relative(rootPath, file)),
+ }))
+ .sort((left, right) =>
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0
+ );
+
+ // Bound parallel reads so hashing stays off the rebuild critical path without
+ // exhausting low per-process file-descriptor limits on macOS.
+ for (let start = 0; start < inputs.length; start += FILE_HASH_BATCH_SIZE) {
+ const batch = inputs.slice(start, start + FILE_HASH_BATCH_SIZE);
+ const hashes = await Promise.all(
+ batch.map(async ({ file, name }) => ({
+ name,
+ hash: createHash("sha256").update(await readFile(file)).digest(),
+ })),
+ );
+ for (const { name, hash } of hashes) {
+ // Hash the repo-relative path, not the absolute one, so the id doesn't
+ // depend on where the repo is checked out.
+ digest.update(name);
+ digest.update("\0");
+ digest.update(hash);
+ }
+ }
+ return toShortId(digest.digest("hex"));
+}
+
+async function collectFiles(dir: string): Promise {
+ const glob = new Bun.Glob("**/*");
+ const files: string[] = [];
+ for await (const file of glob.scan({ cwd: dir, dot: true, onlyFiles: true })) {
+ files.push(join(dir, file));
+ }
+ return files;
+}
+
+export function localBundlerInputPaths(inputs: Iterable): string[] {
+ return [...inputs]
+ .map((input) => resolve(rootPath, input))
+ .filter((path) => !isInstalledDependency(path));
+}
+
+function isInstalledDependency(path: string): boolean {
+ return toUrlPath(relative(rootPath, path))
+ .split("/")
+ .includes("node_modules");
+}
+
+function toUrlPath(path: string): string {
+ return path.split(sep).join("/");
+}
+
+function assetHref(path: string): string {
+ return `/${toUrlPath(path)}`;
+}
+
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 });
- const result = await Bun.build({
- entrypoints: [join(rootPath, "app", "entry.tsx")],
- outdir: buildAssetsDir,
- naming: "[name]-[hash].[ext]",
- minify: true,
- splitting: true,
- target: "browser",
- });
-
- if (!result.success) {
- throw new Error(result.logs.map((log: any) => log.message).join("\n"));
- }
-
- const cssResult = await Bun.spawn([
- process.execPath,
- tailwindCliBin,
- "-i",
- "app/app.css",
- "-o",
- relative(rootPath, join(buildAssetsDir, "app.css")),
- "--minify",
- ], {
- cwd: rootPath,
- stdout: "inherit",
- stderr: "inherit",
- }).exited;
+ const [result, cssResult] = await Promise.all([
+ Bun.build({
+ entrypoints: [join(rootPath, "app", "entry.tsx")],
+ outdir: buildAssetsDir,
+ naming: "[name]-[hash].[ext]",
+ minify: true,
+ splitting: true,
+ target: "browser",
+ metafile: true,
+ root: rootPath,
+ }),
+ Bun.spawn([
+ process.execPath,
+ tailwindCliBin,
+ "-i",
+ "app/app.css",
+ "-o",
+ relative(rootPath, join(buildAssetsDir, "app.css")),
+ "--minify",
+ ], {
+ cwd: rootPath,
+ stdout: "inherit",
+ stderr: "inherit",
+ }).exited,
+ ]);
if (cssResult !== 0) {
throw new Error("Tailwind build failed");
}
- await cp(publicDir, buildDir, { recursive: true });
- await copyPierreWorkerAssets(join(buildAssetsDir, "pierre-diffs-worker"));
- await writeIndexHtml(
- buildDir,
- result.outputs.map((output: any) => ({
+ if (!result.success) {
+ throw new Error(result.logs.map((log: any) => log.message).join("\n"));
+ }
+
+ const [stylesheetPath, buildId] = await Promise.all([
+ hashStylesheet(buildAssetsDir),
+ publishedBuildId(Object.keys(result.metafile.inputs)),
+ cp(publicDir, buildDir, { recursive: true }),
+ copyPierreWorkerAssets(join(buildAssetsDir, "pierre-diffs-worker")),
+ ]);
+
+ const outputs: IndexHtmlOutput[] = [
+ { kind: "asset", path: stylesheetPath },
+ ...result.outputs.map((output: any) => ({
kind: output.kind,
path: relative(buildDir, output.path),
})),
+ ];
+
+ await writeIndexHtml(buildDir, outputs, 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_FILE_NAME),
+ `${JSON.stringify(buildIdDocument(buildId), null, 2)}\n`,
+ "utf8",
);
await publishBuild(buildDir);
- await pruneOldBuilds(buildId);
+ await pruneOldBuilds(buildDirName);
+}
+
+/**
+ * Renames Tailwind's stable-named `app.css` to `app-.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(buildAssetsDir: string): Promise {
+ const source = join(buildAssetsDir, "app.css");
+ const css = await readFile(source);
+ const hashedName = `app-${contentHash8(css)}.css`;
+ await rename(source, join(buildAssetsDir, hashedName));
+ return join("assets", hashedName);
}
async function copyPierreWorkerAssets(targetDir: string) {
@@ -110,7 +286,11 @@ type IndexHtmlOutput = {
path: string;
};
-async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
+async function writeIndexHtml(
+ buildDir: string,
+ outputs: IndexHtmlOutput[],
+ buildId: string,
+) {
const template = await readFile(templatePath, "utf8");
// Only entry points get `)
+ .map((output) => ``)
.join("\n ");
- const styles = [
- "/assets/app.css",
- ...outputs
- .filter((output) => output.path.endsWith(".css"))
- .map((output) => `/${output.path.replaceAll("\\\\", "/")}`),
- ]
+ const styles = outputs
+ .filter((output) => output.path.endsWith(".css"))
+ .map((output) => assetHref(output.path))
.filter((value, index, array) => array.indexOf(value) === index)
.map((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 = ``;
+
const html = template
.replace("{{styles}}", styles)
+ .replace("{{buildMeta}}", buildMeta)
.replace("{{scripts}}", scripts);
await writeFile(join(buildDir, "index.html"), html, "utf8");
@@ -264,7 +448,9 @@ async function main() {
});
}
-main().catch((error) => {
- console.error(error);
- process.exit(1);
-});
+if (import.meta.main) {
+ main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/lib/apps/fabro-server/src/static_files.rs b/lib/apps/fabro-server/src/static_files.rs
index b0a9e623e..67db55a4e 100644
--- a/lib/apps/fabro-server/src/static_files.rs
+++ b/lib/apps/fabro-server/src/static_files.rs
@@ -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",