Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 }),
});
Expand All @@ -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({
Expand Down
107 changes: 84 additions & 23 deletions apps/server/src/services/ui-source/recovery-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand Down Expand Up @@ -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();
}
};
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -90,6 +119,7 @@ const RECOVERY_SHIM_JS = String.raw`
bar.appendChild(label);
bar.appendChild(button);
document.body.appendChild(bar);
watchForMount();
}

function appMounted() {
Expand All @@ -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 {
Expand All @@ -123,18 +167,35 @@ const RECOVERY_SHIM_JS = String.raw`
})();
`;

const RECOVERY_SHIM_TAG = `<script data-bb-recovery-shim>${RECOVERY_SHIM_JS}</script>`;
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 `<script data-bb-recovery-shim data-bb-ui-source-recovery="${
recoverEnabled ? "enabled" : "disabled"
}">${js}</script>`;
}

/**
* 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("</head>")) {
return html.replace("</head>", `${RECOVERY_SHIM_TAG}</head>`);
return html.replace("</head>", `${tag}</head>`);
}
return `${RECOVERY_SHIM_TAG}${html}`;
return `${tag}${html}`;
}
Loading
Loading