diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 8a73c1f31..5973fdb44 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -384,6 +384,7 @@ export function createApp(
const root = uiSource
? uiSource.resolveActiveRoot(shippedRoot)
: shippedRoot;
+ const uiSourceRecoveryEnabled = root !== shippedRoot;
const urlPath =
context.req.path === "/" ? "/index.html" : context.req.path;
const filePath = join(root, urlPath);
@@ -396,9 +397,12 @@ export function createApp(
const contentType =
MIME[extname(filePath)] ?? "application/octet-stream";
// Inject the recovery shim into every served HTML document so the
- // reload/escape-hatch survives any UI-source breakage.
+ // reload wiring is always present. The UI-source escape hatch is only
+ // valid when this response is serving an active fork.
if (contentType === "text/html") {
- const html = injectRecoveryShim(await readFile(filePath, "utf8"));
+ const html = injectRecoveryShim(await readFile(filePath, "utf8"), {
+ recoverEnabled: uiSourceRecoveryEnabled,
+ });
return new Response(html, {
headers: createStaticResponseHeaders({ contentType, urlPath }),
});
@@ -413,6 +417,7 @@ export function createApp(
}
const indexHtml = injectRecoveryShim(
await readFile(join(root, "index.html"), "utf8"),
+ { recoverEnabled: uiSourceRecoveryEnabled },
);
return new Response(indexHtml, {
headers: createStaticResponseHeaders({
diff --git a/apps/server/src/services/ui-source/recovery-shim.ts b/apps/server/src/services/ui-source/recovery-shim.ts
index cb9b69e48..08cdca7f0 100644
--- a/apps/server/src/services/ui-source/recovery-shim.ts
+++ b/apps/server/src/services/ui-source/recovery-shim.ts
@@ -6,14 +6,16 @@
* Responsibilities:
* - Own the live reload: subscribe to the `system` realtime channel and reload
* the page when the server broadcasts `ui-reloaded` after a build promote.
- * - Self-heal: if the app root never mounts (a build that compiled but crashes
- * at runtime), auto-revert to the shipped UI once, then fall back to a manual
- * "Revert to stable" bar. A session-scoped guard prevents reload loops.
+ * - Provide manual recovery, when serving the active UI source: if the app
+ * root never mounts (slow load, missing bundle, or runtime crash), show a
+ * "Revert to stable" bar without interrupting an eventually successful load.
*/
const RECOVERY_SHIM_JS = String.raw`
(function () {
var MOUNT_TIMEOUT_MS = 10000;
- var AUTO_RECOVER_KEY = "bb.ui.autoRecovered";
+ var FAILURE_HINT_TIMEOUT_MS = 3000;
+ var RECOVERY_ENABLED = __BB_UI_SOURCE_RECOVERY_ENABLED__;
+ var recoveryObserver = null;
function wsUrl() {
var proto = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -48,9 +50,6 @@ const RECOVERY_SHIM_JS = String.raw`
Array.isArray(msg.changes) &&
msg.changes.indexOf("ui-reloaded") !== -1
) {
- // The build that triggered this reload is known-good (build-gated), so
- // clear the auto-recover guard for the fresh load.
- try { sessionStorage.removeItem(AUTO_RECOVER_KEY); } catch (e) {}
window.location.reload();
}
};
@@ -59,9 +58,38 @@ const RECOVERY_SHIM_JS = String.raw`
};
}
- // --- Self-heal watchdog ----------------------------------------------------
+ // --- Manual recovery -------------------------------------------------------
+ function hideRecoveryBar() {
+ var bar = document.getElementById("bb-ui-recovery-bar");
+ if (bar && typeof bar.remove === "function") {
+ bar.remove();
+ }
+ if (recoveryObserver) {
+ recoveryObserver.disconnect();
+ recoveryObserver = null;
+ }
+ }
+
+ function watchForMount() {
+ if (recoveryObserver || typeof MutationObserver === "undefined") return;
+ var root = document.getElementById("root");
+ var target = root || document.body;
+ if (!target) return;
+ recoveryObserver = new MutationObserver(function () {
+ if (appMounted()) {
+ hideRecoveryBar();
+ }
+ });
+ recoveryObserver.observe(target, { childList: true, subtree: !root });
+ }
+
function showRecoveryBar() {
+ if (appMounted()) {
+ hideRecoveryBar();
+ return;
+ }
if (document.getElementById("bb-ui-recovery-bar")) return;
+ if (!document.body) return;
var bar = document.createElement("div");
bar.id = "bb-ui-recovery-bar";
bar.setAttribute(
@@ -72,7 +100,8 @@ const RECOVERY_SHIM_JS = String.raw`
"background:#1a1a1a;color:#fff;border-top:1px solid #333;"
);
var label = document.createElement("span");
- label.textContent = "This UI did not load. Your edits are safe in the UI source.";
+ label.textContent =
+ "This UI is taking a while to load. Your edits are safe in the UI source.";
var button = document.createElement("button");
button.textContent = "Revert to stable";
button.setAttribute(
@@ -90,6 +119,7 @@ const RECOVERY_SHIM_JS = String.raw`
bar.appendChild(label);
bar.appendChild(button);
document.body.appendChild(bar);
+ watchForMount();
}
function appMounted() {
@@ -98,23 +128,37 @@ const RECOVERY_SHIM_JS = String.raw`
}
function startWatchdog() {
+ if (!RECOVERY_ENABLED) return;
setTimeout(function () {
- if (appMounted()) return;
- var alreadyRecovered;
- try { alreadyRecovered = sessionStorage.getItem(AUTO_RECOVER_KEY); } catch (e) {}
- if (alreadyRecovered) {
- // Auto-revert already tried this session — show the manual escape hatch.
+ showRecoveryBar();
+ }, MOUNT_TIMEOUT_MS);
+ }
+
+ function watchLoadFailures() {
+ if (!RECOVERY_ENABLED) return;
+ function scheduleFailureHint() {
+ setTimeout(function () {
showRecoveryBar();
+ }, FAILURE_HINT_TIMEOUT_MS);
+ }
+ window.addEventListener("error", function (event) {
+ if (appMounted()) return;
+ var target = event && event.target;
+ var tagName = target && target.tagName;
+ if (target && target !== window && String(tagName).toUpperCase() !== "SCRIPT") {
return;
}
- try { sessionStorage.setItem(AUTO_RECOVER_KEY, "1"); } catch (e) {}
- fetch("/api/v1/ui/prod", { method: "POST" })
- .then(function () { window.location.reload(); })
- .catch(function () { showRecoveryBar(); });
- }, MOUNT_TIMEOUT_MS);
+ scheduleFailureHint();
+ }, true);
+ window.addEventListener("unhandledrejection", function () {
+ if (!appMounted()) {
+ scheduleFailureHint();
+ }
+ });
}
connectReload();
+ watchLoadFailures();
if (document.readyState === "complete" || document.readyState === "interactive") {
startWatchdog();
} else {
@@ -123,18 +167,35 @@ const RECOVERY_SHIM_JS = String.raw`
})();
`;
-const RECOVERY_SHIM_TAG = ``;
+interface InjectRecoveryShimOptions {
+ recoverEnabled?: boolean;
+}
+
+function recoveryShimTag(options: InjectRecoveryShimOptions): string {
+ const recoverEnabled = options.recoverEnabled ?? false;
+ const js = RECOVERY_SHIM_JS.replace(
+ "__BB_UI_SOURCE_RECOVERY_ENABLED__",
+ recoverEnabled ? "true" : "false",
+ );
+ return ``;
+}
/**
* Insert the recovery shim into an index.html document. Idempotent: if the shim
* is already present (re-served) it is not duplicated.
*/
-export function injectRecoveryShim(html: string): string {
+export function injectRecoveryShim(
+ html: string,
+ options: InjectRecoveryShimOptions = {},
+): string {
if (html.includes("data-bb-recovery-shim")) {
return html;
}
+ const tag = recoveryShimTag(options);
if (html.includes("")) {
- return html.replace("", `${RECOVERY_SHIM_TAG}`);
+ return html.replace("", `${tag}`);
}
- return `${RECOVERY_SHIM_TAG}${html}`;
+ return `${tag}${html}`;
}
diff --git a/apps/server/src/services/ui-source/ui-source.test.ts b/apps/server/src/services/ui-source/ui-source.test.ts
index d2ed75019..063749b3c 100644
--- a/apps/server/src/services/ui-source/ui-source.test.ts
+++ b/apps/server/src/services/ui-source/ui-source.test.ts
@@ -7,6 +7,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { runInNewContext } from "node:vm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { injectRecoveryShim } from "./recovery-shim.js";
import {
@@ -15,12 +16,247 @@ import {
} from "./ui-source.js";
describe("injectRecoveryShim", () => {
+ class FakeElement {
+ attrs: Record = {};
+ childElementCount = 0;
+ children: FakeElement[] = [];
+ disabled = false;
+ id = "";
+ onclick: (() => void) | null = null;
+ parentNode: FakeElement | null = null;
+ textContent = "";
+
+ constructor(readonly tagName: string) {}
+
+ appendChild(child: FakeElement): void {
+ this.children.push(child);
+ child.parentNode = this;
+ this.childElementCount = this.children.length;
+ }
+
+ remove(): void {
+ if (!this.parentNode) return;
+ this.parentNode.children = this.parentNode.children.filter(
+ (child) => child !== this,
+ );
+ this.parentNode.childElementCount = this.parentNode.children.length;
+ this.parentNode = null;
+ }
+
+ setAttribute(name: string, value: string): void {
+ this.attrs[name] = value;
+ }
+ }
+
+ function findElement(root: FakeElement, id: string): FakeElement | null {
+ if (root.id === id) return root;
+ for (const child of root.children) {
+ const found = findElement(child, id);
+ if (found) return found;
+ }
+ return null;
+ }
+
+ function extractShimJs(html: string): string {
+ const scriptStart = html.indexOf("", jsStart);
+ return html.slice(jsStart, jsEnd);
+ }
+
+ function runInjectedShim(html: string): {
+ body: FakeElement;
+ dispatchWindowEvent(type: string, event: { target?: FakeElement }): void;
+ fetch: ReturnType;
+ getElementById(id: string): FakeElement | null;
+ mutationCallbacks: Array<() => void>;
+ reload: ReturnType;
+ root: FakeElement;
+ runTimers(delayMs: number): void;
+ webSockets: Array<{
+ onclose: (() => void) | null;
+ onmessage: ((event: { data: string }) => void) | null;
+ onopen: (() => void) | null;
+ send: () => void;
+ }>;
+ } {
+ const root = new FakeElement("DIV");
+ root.id = "root";
+ const body = new FakeElement("BODY");
+ const timers: Array<{ callback: () => void; delayMs: number }> = [];
+ const mutationCallbacks: Array<() => void> = [];
+ const windowListeners: Record<
+ string,
+ Array<(event: { target?: FakeElement }) => void>
+ > = {};
+ const fetch = vi.fn(() => Promise.resolve(new Response("{}")));
+ const reload = vi.fn();
+ const webSockets: Array<{
+ onclose: (() => void) | null;
+ onmessage: ((event: { data: string }) => void) | null;
+ onopen: (() => void) | null;
+ send: () => void;
+ }> = [];
+ const getElementById = (id: string): FakeElement | null => {
+ if (id === "root") return root;
+ return findElement(body, id);
+ };
+ const context = {
+ document: {
+ body,
+ createElement: (tagName: string) => new FakeElement(tagName.toUpperCase()),
+ getElementById,
+ readyState: "interactive",
+ },
+ fetch,
+ MutationObserver: class {
+ constructor(callback: () => void) {
+ mutationCallbacks.push(callback);
+ }
+ disconnect(): void {}
+ observe(): void {}
+ },
+ setTimeout: (callback: () => void, delayMs: number) => {
+ timers.push({ callback, delayMs });
+ return timers.length;
+ },
+ WebSocket: class {
+ onclose: (() => void) | null = null;
+ onmessage: ((event: { data: string }) => void) | null = null;
+ onopen: (() => void) | null = null;
+ constructor() {
+ webSockets.push(this);
+ }
+ send(): void {}
+ },
+ window: {
+ addEventListener(
+ type: string,
+ listener: (event: { target?: FakeElement }) => void,
+ ): void {
+ windowListeners[type] ??= [];
+ windowListeners[type].push(listener);
+ },
+ location: {
+ host: "bb.example.test",
+ protocol: "https:",
+ reload,
+ },
+ },
+ };
+ runInNewContext(extractShimJs(html), context);
+ return {
+ body,
+ dispatchWindowEvent(type: string, event: { target?: FakeElement }): void {
+ for (const listener of windowListeners[type] ?? []) {
+ listener(event);
+ }
+ },
+ fetch,
+ getElementById,
+ mutationCallbacks,
+ reload,
+ root,
+ runTimers(delayMs: number): void {
+ for (const timer of timers.filter((timer) => timer.delayMs === delayMs)) {
+ timer.callback();
+ }
+ },
+ webSockets,
+ };
+ }
+
it("inserts the shim before ", () => {
const out = injectRecoveryShim("x");
expect(out).toContain("data-bb-recovery-shim");
+ expect(out).toContain('data-bb-ui-source-recovery="disabled"');
+ expect(out).toContain("var RECOVERY_ENABLED = false;");
expect(out.indexOf("data-bb-recovery-shim")).toBeLessThan(out.indexOf(""));
});
+ it("can enable UI-source recovery for active fork HTML", () => {
+ const out = injectRecoveryShim("", { recoverEnabled: true });
+ expect(out).toContain('data-bb-ui-source-recovery="enabled"');
+ expect(out).toContain("var RECOVERY_ENABLED = true;");
+ });
+
+ it("keeps shipped/default recovery watchdog disabled", () => {
+ const html = injectRecoveryShim("");
+ const shim = runInjectedShim(html);
+
+ shim.runTimers(10_000);
+ expect(shim.getElementById("bb-ui-recovery-bar")).toBeNull();
+
+ shim.dispatchWindowEvent("error", { target: new FakeElement("SCRIPT") });
+ shim.runTimers(3_000);
+
+ expect(shim.getElementById("bb-ui-recovery-bar")).toBeNull();
+ expect(shim.fetch).not.toHaveBeenCalled();
+ });
+
+ it("keeps live reload active for shipped and UI-source HTML", () => {
+ for (const recoverEnabled of [false, true]) {
+ const html = injectRecoveryShim("", { recoverEnabled });
+ const shim = runInjectedShim(html);
+
+ shim.webSockets[0]?.onmessage?.({
+ data: JSON.stringify({
+ type: "changed",
+ entity: "system",
+ changes: ["ui-reloaded"],
+ }),
+ });
+
+ expect(shim.reload).toHaveBeenCalledOnce();
+ }
+ });
+
+ it("shows active-fork recovery manually without auto-reverting", async () => {
+ const html = injectRecoveryShim("", { recoverEnabled: true });
+ const shim = runInjectedShim(html);
+
+ shim.runTimers(10_000);
+
+ const bar = shim.getElementById("bb-ui-recovery-bar");
+ expect(bar).not.toBeNull();
+ expect(shim.fetch).not.toHaveBeenCalled();
+
+ const button = bar?.children.find((child) => child.tagName === "BUTTON");
+ button?.onclick?.();
+ expect(shim.fetch).toHaveBeenCalledWith("/api/v1/ui/prod", {
+ method: "POST",
+ });
+ await Promise.resolve();
+ expect(shim.reload).toHaveBeenCalledOnce();
+ });
+
+ it("shows manual recovery sooner after an active-fork script load failure", () => {
+ const html = injectRecoveryShim("", { recoverEnabled: true });
+ const shim = runInjectedShim(html);
+ const script = new FakeElement("SCRIPT");
+
+ shim.dispatchWindowEvent("error", { target: script });
+ shim.runTimers(3_000);
+
+ expect(shim.getElementById("bb-ui-recovery-bar")).not.toBeNull();
+ expect(shim.fetch).not.toHaveBeenCalled();
+ });
+
+ it("removes active-fork recovery if the app eventually mounts", () => {
+ const html = injectRecoveryShim("", { recoverEnabled: true });
+ const shim = runInjectedShim(html);
+ shim.runTimers(10_000);
+ expect(shim.getElementById("bb-ui-recovery-bar")).not.toBeNull();
+
+ shim.root.childElementCount = 1;
+ for (const callback of shim.mutationCallbacks) {
+ callback();
+ }
+
+ expect(shim.getElementById("bb-ui-recovery-bar")).toBeNull();
+ expect(shim.fetch).not.toHaveBeenCalled();
+ });
+
it("is idempotent (does not double-inject)", () => {
const once = injectRecoveryShim("");
const twice = injectRecoveryShim(once);
diff --git a/apps/server/test/app/static-cache.test.ts b/apps/server/test/app/static-cache.test.ts
index ed13996e5..d6a4a81c5 100644
--- a/apps/server/test/app/static-cache.test.ts
+++ b/apps/server/test/app/static-cache.test.ts
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { setExperiments } from "@bb/db";
import { describe, expect, it } from "vitest";
import { createApp } from "../../src/server.js";
import { createTestAppHarness } from "../helpers/test-app.js";
@@ -52,4 +53,74 @@ describe("production static cache headers", () => {
await rm(staticDir, { force: true, recursive: true });
}
});
+
+ it("does not enable UI-source recovery for shipped HTML", async () => {
+ const staticDir = await mkdtemp(join(tmpdir(), "bb-server-static-"));
+ await writeFile(
+ join(staticDir, "index.html"),
+ '',
+ );
+
+ const harness = await createTestAppHarness();
+ const serverApp = createApp(harness.deps, {
+ staticDir,
+ appDir: staticDir,
+ });
+ try {
+ const response = await serverApp.app.request("/");
+ const body = await response.text();
+
+ expect(body).toContain("data-bb-recovery-shim");
+ expect(body).toContain('data-bb-ui-source-recovery="disabled"');
+ expect(body).toContain("var RECOVERY_ENABLED = false;");
+ } finally {
+ await serverApp.closeWebSockets();
+ await harness.cleanup();
+ await rm(staticDir, { force: true, recursive: true });
+ }
+ });
+
+ it("enables UI-source recovery for active fork HTML", async () => {
+ const staticDir = await mkdtemp(join(tmpdir(), "bb-server-static-"));
+ await writeFile(
+ join(staticDir, "index.html"),
+ 'shipped',
+ );
+
+ const harness = await createTestAppHarness();
+ setExperiments(harness.db, {
+ claudeCodeMockCliTraffic: false,
+ popoutChat: false,
+ popoutChatHotkey: "Alt+Space",
+ uiForking: true,
+ });
+ const uiDir = join(harness.config.dataDir, "ui");
+ await mkdir(join(uiDir, "dist"), { recursive: true });
+ await writeFile(
+ join(harness.config.dataDir, "ui-state.json"),
+ JSON.stringify({ active: "fork", status: "ready" }),
+ "utf8",
+ );
+ await writeFile(
+ join(uiDir, "dist", "index.html"),
+ 'fork',
+ );
+
+ const serverApp = createApp(harness.deps, {
+ staticDir,
+ appDir: staticDir,
+ });
+ try {
+ const response = await serverApp.app.request("/");
+ const body = await response.text();
+
+ expect(body).toContain("fork");
+ expect(body).toContain('data-bb-ui-source-recovery="enabled"');
+ expect(body).toContain("var RECOVERY_ENABLED = true;");
+ } finally {
+ await serverApp.closeWebSockets();
+ await harness.cleanup();
+ await rm(staticDir, { force: true, recursive: true });
+ }
+ });
});