Merge pull request #645 from fabro-sh/feat/spa-build-version-detection

Tell open tabs when a new build ships
This commit is contained in:
Bryan Helmkamp 2026-07-25 15:19:36 -04:00 • committed by GitHub
commit e88cabbb75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1056 additions and 100 deletions

View file

@ -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

View file

@ -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);

View file

@ -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 <ToastProvider> mount points. In a browser the real
* <Toaster /> 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.
* <Toaster /> 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") {

View file

@ -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 (
<>
<RouterProvider router={router} />
<FabroToaster />
</>
);
}
createRoot(rootElement).render(
<StrictMode>
<SWRConfig
@ -30,7 +43,7 @@ createRoot(rootElement).render(
shouldRetryOnError: false,
}}
>
<RouterProvider router={router} />
<AppRuntime />
</SWRConfig>
</StrictMode>,
);

View file

@ -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(<DiagramHost />);
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<TerminalConnectionError | null>(null);
const [status, setStatus] = useState<ConnectionStatus>("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(<TerminalHost />);
expect(terminalStatus).toBe("error");
expect(terminalError).toEqual({
message: "Terminal initialization failed: chunk unavailable",
recoverable: true,
});
});

View file

@ -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(<GuardHost revision={revision} />);
} else {
renderer = TestRenderer.create(<GuardHost revision={revision} />);
}
});
}
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);
});

View file

@ -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<string | null>(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]);
}

View file

@ -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<TIdentity>({
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<TIdentity>({
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");
}
}
}

View file

@ -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]);
}

View file

@ -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() {
)}
<ShellMain fullHeight={fullHeight} maxWidth={maxWidth} />
</div>
{typeof document !== "undefined" && (
<FabroToaster />
)}
</TooltipProvider>
</DemoModeProvider>
);

View file

@ -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<typeof BUILD_ID_FIELD, string> {
return { [BUILD_ID_FIELD]: buildId };
}

View file

@ -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("<!doctype html>"));
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();
});
});

View file

@ -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<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().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;
}

View file

@ -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<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({
buildId = "aaaaaaaa",
throwOnStorage = false,
}: {
buildId?: string | null;
throwOnStorage?: boolean;
} = {}): WindowStub {
const store = new Map<string, string>();
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);
});
});

View 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();
}

View file

@ -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 };

View file

@ -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 } }) {
<div className="h-screen w-screen overflow-hidden">
<TerminalView runId={params.id} chromeless />
</div>
{typeof document !== "undefined" && <FabroToaster />}
</ToastProvider>
);
}

View file

@ -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>

View file

@ -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(
`<meta name="${BUILD_ID_META_NAME}" content="${firstBuildId}" />`,
);
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([

View file

@ -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<string>): Promise<string> {
const files = new Set<string>();
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<string[]> {
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>): 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-<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(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 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 <script> tags. Bun's `splitting: true` emits
// hundreds of chunks reachable from the entry through static and dynamic
@ -120,20 +300,24 @@ async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
// import() chunks load on demand.
const scripts = outputs
.filter((output) => output.kind === "entry-point" && output.path.endsWith(".js"))
.map((output) => `<script type="module" src="/${output.path.replaceAll("\\\\", "/")}"></script>`)
.map((output) => `<script type="module" src="${assetHref(output.path)}"></script>`)
.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) => `<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="${BUILD_ID_META_NAME}" content="${buildId}" />`;
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);
});
}

View file

@ -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",