UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

192 lines (191 loc) 6.98 kB
const DEFAULT_BACKOFF_MS = 1000; const MAX_BACKOFF_MS = 30000; const FALLBACK_TIMEOUT_MS = 30000; const FALLBACK_TIMEOUT_JITTER_MS = 10000; const STARTUP_JITTER_MS = 1000; let configuredOptions = {}; let activeController = null; function debugLog(...args) { if (typeof window !== "undefined" && (window.__RWSDK_DEBUG__ || window.__RWSDK_DEBUG_RECOVERY__)) { console.log("[rwsdk:recovery]", ...args); } } function getBackoffMs(attempt) { const base = Math.min(DEFAULT_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS); const jittered = base * (0.75 + Math.random() * 0.5); return Math.round(Math.min(jittered, MAX_BACKOFF_MS)); } function getJitteredFallbackTimeoutMs() { return (FALLBACK_TIMEOUT_MS + Math.round(Math.random() * FALLBACK_TIMEOUT_JITTER_MS)); } function normalizeRecoveryUrl(url) { const parsed = new URL(url); // A trailing dot on the hostname (e.g. example.com.) is a DNS root // indicator that some Cloudflare Workers routes treat differently. Strip // it so the health check hits the same origin the app uses normally. parsed.hostname = parsed.hostname.replace(/\.$/, ""); // Drop query string and hash: we only care whether the document route // itself is ready. return `${parsed.origin}${parsed.pathname}`; } function getCurrentHydrateRootId() { if (typeof document === "undefined") { return null; } const root = document.getElementById("hydrate-root"); return root?.tagName.toLowerCase() ?? null; } async function checkUrl(url, signal) { try { const response = await fetch(url, { method: "GET", cache: "no-store", headers: { Accept: "text/html" }, signal, }); if (response.status !== 200) { debugLog("checked", url, "status", response.status, "ok", false); return false; } const contentType = response.headers.get("content-type") ?? ""; if (!contentType.includes("text/html")) { debugLog("checked", url, "content-type", contentType, "ok", false); return false; } const text = await response.text(); const isHtml = /<!doctype html|<html/i.test(text); if (!isHtml) { debugLog("checked", url, "not html", "ok", false); return false; } const currentRootId = getCurrentHydrateRootId(); if (currentRootId) { // Match the current hydrate root element. We look for id="hydrate-root" // or id='hydrate-root' followed by the same tag name. const rootPattern = new RegExp(`id=["']hydrate-root["'][^>]*>\\s*<${currentRootId}\\b`, "i"); const rootMatches = rootPattern.test(text); debugLog("checked", url, "status", 200, "rootId", currentRootId, "rootMatches", rootMatches, "ok", rootMatches); return rootMatches; } debugLog("checked", url, "status", 200, "html", true, "ok", true); return true; } catch (error) { debugLog("check failed", url, error); return false; } } function createController() { let state = "idle"; let attempts = 0; const startedAt = Date.now(); let wakeResolver = null; let abortController = new AbortController(); const controller = { get state() { return state; }, get attempts() { return attempts; }, get elapsedMs() { return Date.now() - startedAt; }, _setState(next) { state = next; }, _incAttempts() { attempts++; }, retry() { wakeResolver?.(); }, reload() { abortController.abort(); state = "reload"; wakeResolver?.(); if (typeof window !== "undefined") { window.location.reload(); } }, _wait(ms) { return new Promise((resolve) => { const timeout = setTimeout(resolve, ms); wakeResolver = () => { clearTimeout(timeout); resolve(); }; }); }, }; return controller; } export function configureRecovery(options) { configuredOptions = options; } export function isRecoveryConfigured() { return configuredOptions.onModuleNotFound != null; } export function startRecovery(reason) { if (typeof window === "undefined") { return; } debugLog("start", reason); if (activeController) { debugLog("already recovering, ignoring duplicate"); return; } const handler = configuredOptions.onModuleNotFound; const controller = createController(); activeController = controller; const run = async () => { controller._setState("waiting"); debugLog("waiting, handler", typeof handler === "function" ? "custom" : handler); if (typeof handler === "function") { try { await handler(controller); } catch (error) { console.error("[rwsdk] recovery callback threw", error); } } const currentUrl = normalizeRecoveryUrl(window.location.href); const fallbackTimeoutMs = getJitteredFallbackTimeoutMs(); const startupJitter = Math.round(Math.random() * STARTUP_JITTER_MS); debugLog("startup jitter", startupJitter, "ms"); await controller._wait(startupJitter); while (activeController === controller) { controller._setState("checking"); controller._incAttempts(); debugLog("checking", currentUrl, "attempt", controller.attempts, "elapsed", controller.elapsedMs); const ok = await checkUrl(currentUrl, new AbortController().signal); if (ok) { debugLog("current route ready, reloading"); controller.reload(); return; } if (controller.elapsedMs >= fallbackTimeoutMs) { debugLog("fallback timeout reached, checking /"); const indexUrl = `${new URL(window.location.href).origin}/`; const indexOk = await checkUrl(indexUrl, new AbortController().signal); if (indexOk) { debugLog("/ ready, navigating to /"); window.location.href = "/"; return; } } const backoff = getBackoffMs(controller.attempts); debugLog("not ready, backing off", backoff, "ms"); controller._setState("waiting"); await controller._wait(backoff); } }; void run(); } export function isDynamicImportFailure(error) { return (error instanceof TypeError && typeof error.message === "string" && error.message.includes("dynamically imported module")); }