mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
commit
e88cabbb75
21 changed files with 1056 additions and 100 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
import { importChunk } from "../../../lib/import-chunk";
|
||||
|
||||
/**
|
||||
* Synchronizes a DOM container with a Graphviz-rendered SVG. Pipes the
|
||||
* supplied DOT string through `@viz-js/viz` (the same layout engine Fabro
|
||||
|
|
@ -28,7 +30,7 @@ export function useCanvasRender(
|
|||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { instance } = await import("@viz-js/viz");
|
||||
const { instance } = await importChunk(() => import("@viz-js/viz"));
|
||||
const viz = await instance();
|
||||
if (cancelled) return;
|
||||
const svg = viz.renderSVGElement(dot);
|
||||
|
|
|
|||
|
|
@ -9,10 +9,17 @@ import { Toaster as SonnerToaster, toast as sonnerToast, useSonner } from "sonne
|
|||
|
||||
export type ToastTone = "info" | "error";
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface ToastInput {
|
||||
message: string;
|
||||
tone?: ToastTone;
|
||||
/** Pass `Infinity` for a toast that stays until dismissed or acted on. */
|
||||
autoDismissMs?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
|
|
@ -27,6 +34,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") {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
95
apps/fabro-web/app/hooks/dynamic-import-errors.test.tsx
Normal file
95
apps/fabro-web/app/hooks/dynamic-import-errors.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
132
apps/fabro-web/app/hooks/use-build-version-guard.test.tsx
Normal file
132
apps/fabro-web/app/hooks/use-build-version-guard.test.tsx
Normal 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);
|
||||
});
|
||||
57
apps/fabro-web/app/hooks/use-build-version-guard.ts
Normal file
57
apps/fabro-web/app/hooks/use-build-version-guard.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
22
apps/fabro-web/app/lib/build-version-contract.ts
Normal file
22
apps/fabro-web/app/lib/build-version-contract.ts
Normal 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 };
|
||||
}
|
||||
73
apps/fabro-web/app/lib/build-version.test.ts
Normal file
73
apps/fabro-web/app/lib/build-version.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
68
apps/fabro-web/app/lib/build-version.ts
Normal file
68
apps/fabro-web/app/lib/build-version.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import useSWR from "swr";
|
||||
|
||||
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;
|
||||
}
|
||||
170
apps/fabro-web/app/lib/import-chunk.test.ts
Normal file
170
apps/fabro-web/app/lib/import-chunk.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
apps/fabro-web/app/lib/import-chunk.ts
Normal file
54
apps/fabro-web/app/lib/import-chunk.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { documentBuildId } from "./build-version";
|
||||
|
||||
const RELOAD_MARKER_PREFIX = "fabro:chunk-reload:";
|
||||
|
||||
/**
|
||||
* Loads a lazily-imported chunk, reloading the page once if it cannot be
|
||||
* fetched.
|
||||
*
|
||||
* Each deploy replaces the served assets and the previous build's hashed
|
||||
* filenames stop existing, so a tab open across a deploy can request a chunk
|
||||
* that now 404s. Static route imports mean most of the graph is already in
|
||||
* memory, but the handful of genuinely lazy imports — the terminal, Graphviz
|
||||
* rendering, the file tree — are loaded on demand and can land in that window.
|
||||
*
|
||||
* A failed chunk means the feature is already broken, so reloading is recovery
|
||||
* rather than an interruption. This is the one place the app reloads without an
|
||||
* explicit click; the build-version toast never does.
|
||||
*/
|
||||
export function importChunk<T>(load: () => Promise<T>): Promise<T> {
|
||||
return load().catch((error: unknown) => {
|
||||
reloadOnceForStaleChunk();
|
||||
// Rethrow rather than returning a never-settling promise. The reload
|
||||
// normally replaces the document before this surfaces; if it doesn't, an
|
||||
// error boundary is a better outcome than a spinner that hangs forever.
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads at most once per build.
|
||||
*
|
||||
* Keyed by build id rather than a bare flag so a tab that recovers from one
|
||||
* deploy still has a reload available for the next one. Without the key, a
|
||||
* single chunk failure would disarm the backstop for the rest of the session.
|
||||
*
|
||||
* A module that loads fine but throws while evaluating is indistinguishable
|
||||
* here from a missing chunk, so it also spends the reload. The per-build key
|
||||
* bounds the cost at one wasted reload, after which the real error surfaces.
|
||||
*/
|
||||
function reloadOnceForStaleChunk(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const key = `${RELOAD_MARKER_PREFIX}${documentBuildId() ?? "unknown"}`;
|
||||
try {
|
||||
if (window.sessionStorage.getItem(key)) return;
|
||||
window.sessionStorage.setItem(key, "1");
|
||||
} catch {
|
||||
// Storage disabled or full. Without a durable marker we can't guarantee
|
||||
// "only once", and a reload loop is far worse than a surfaced error.
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type FileContents,
|
||||
} from "@pierre/diffs/react";
|
||||
import { useToast } from "../components/toast";
|
||||
import { importChunk } from "../lib/import-chunk";
|
||||
import type {
|
||||
FileDiff as ApiFileDiff,
|
||||
PaginatedRunFileList,
|
||||
|
|
@ -58,9 +59,11 @@ import { useTickingNow } from "../lib/time";
|
|||
export { extractRequestId };
|
||||
|
||||
const FileTreeSidebar = lazy(() =>
|
||||
import("./run-files/file-tree-sidebar").then((module) => ({
|
||||
default: module.FileTreeSidebar,
|
||||
})),
|
||||
importChunk(() =>
|
||||
import("./run-files/file-tree-sidebar").then((module) => ({
|
||||
default: module.FileTreeSidebar,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
export const handle = { wide: true, fullHeight: true };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
{{styles}}
|
||||
{{buildMeta}}
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -473,6 +473,10 @@ mod tests {
|
|||
"assets/entry-0sv53bs3.js",
|
||||
"assets/chunk-4tr91ktd.js",
|
||||
"assets/chunk-x912wb67.css",
|
||||
// Tailwind's stylesheet is hashed by the build so it moves with the
|
||||
// bundle; a stable name would let a tab revalidate into new CSS
|
||||
// while still running the previous build's JavaScript.
|
||||
"assets/app-381qtfxr.css",
|
||||
] {
|
||||
assert!(is_content_hashed(path), "{path} should be content-hashed");
|
||||
}
|
||||
|
|
@ -485,6 +489,9 @@ mod tests {
|
|||
// pinned stale in browsers for a year if marked immutable.
|
||||
for path in [
|
||||
"index.html",
|
||||
// Clients poll this to learn whether their tab is running a stale
|
||||
// build, so it must revalidate rather than be pinned for a year.
|
||||
"build-id.json",
|
||||
"assets/app.css",
|
||||
"assets/pierre-diffs-worker/worker-portable.js",
|
||||
"images/apple-touch-icon.png",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue