import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import StorageProvider from '../local-storage-shim'; import { isNullOrEmpty } from './is-null-or-empty'; import TemporalUtils from './temporal-utils'; /** * Delay before recovery attempt N (indexed by the number of attempts already made * inside {@link ATTEMPT_WINDOW_MS}), measured from the PREVIOUS attempt. Attempt #1 * fires immediately (the common "stale tab after a finished deploy" case heals on * the first reload); later attempts back off so a still-inconsistent deploy window * (k8s mixed fleet mid-rollout, VPS in-place rewrite) gets time to converge instead * of the recovery dead-ending after one try. */ const RETRY_LADDER_MS = [ 0, 2000, 5000, 10000, ]; /** Attempts older than this no longer count against the ladder — a fresh failure long after the last recovery starts a fresh ladder. */ const ATTEMPT_WINDOW_MS = 90000; const RELOAD_HISTORY_KEY = 'errReloadHistory'; /** * `vite:preloadError` fires synchronously from Vite's preload helper, typically * before the router's navigation promise rejects into `onError`. The router * handler is the better healer because it knows the intended `to` path (so the * user lands where they were headed, not merely reloaded in place). So the preload * handler defers briefly, letting `router.onError` claim the attempt slot with a * target path first; the deferred trigger then no-ops on the shared pending flag / * attempt history. */ const PRELOAD_DEFER_MS = 250; /** Flag persisted on the shared window object so the preload + rejection listeners bind exactly once. */ const PRELOAD_GUARD_FLAG = '__viteErrHandlerBound'; /** * Pending-retry flag on the shared window object (NOT a module static — the module * can be duplicated across chunks, and every copy must see the same pending state). */ const PENDING_RETRY_FLAG = '__gpChunkRetryPending'; /** Minimal structural view of a vue-router instance — avoids coupling powerduck to vue-router. */ interface GuardableRouter { onError: (handler: (error: unknown, to?: { fullPath?: string | null; path?: string | null } | null) => void) => void; } /** * Self-heals the "stale deployment" chunk-load failure: after a deploy purges the * old fingerprinted assets, a still-open SPA that lazy-loads a route/chunk requests * a URL that no longer exists and the navigation blows up. A forced full reload * pulls the fresh asset manifest and recovers. * * Under the previous webpack + Vue 2 build a failed lazy chunk surfaced as an Error * whose `name` is 'ChunkLoadError'. Vite never sets that name — a failed dynamic * import rejects with a plain TypeError whose message differs per browser (and a * MIME-type / SyntaxError variant when the SPA history-fallback serves index.html * for a purged asset URL). Matching only on the webpack name meant the self-heal * never fired on Vite; this recognises the Vite/browser signatures instead. * * Three independent failure surfaces are covered: * - `router.onError` (via {@link bindGuard}) — a failed lazy route/chunk import * during navigation; the router knows the intended `to` path. * - the `vite:preloadError` window event (via {@link bindPreloadGuard}, wired once * from PowerduckInitializer) — Vite's own signal that a module/CSS preload failed, * which fires even when the failure never reaches the router. * - `unhandledrejection` (same once-guard) — a failed dynamic `import()` OUTSIDE * router navigation (modal opened on click, wizard step, map bundle); previously * these just died in the console and the click "did nothing". * * Recovery is a bounded retry ladder, not a one-shot: during a deploy the asset * inconsistency can outlive the first reload (k8s serves HTML and chunks from a * mixed old/new fleet for the whole rollout; a VPS rewrites the build dir in * place), and the previous single-attempt debounce silently swallowed the second * failure — leaving a half-rendered page that never healed even after the fleet * converged. Now each further failure schedules the next reload per * {@link RETRY_LADDER_MS} until the ladder is exhausted; the attempt history is * storage-backed (shared across tabs and module copies), so concurrent surfaces * firing for one underlying failure cannot double-reload or trap the user in a * reload loop. */ export default class ChunkLoadRecovery { private static readonly MESSAGE_SIGNATURES = [ 'error loading dynamically imported module', // Firefox 'failed to fetch dynamically imported module', // Chromium / Edge 'failed to load module script', // MIME mismatch (index.html served for a purged chunk) 'importing a module script failed', // Safari 'unable to preload css', // Vite CSS preload helper ]; static isChunkLoadError(err: unknown): boolean { if (!(err instanceof Error)) { return false; } if (err.name == 'ChunkLoadError') { return true; // legacy webpack name — kept so nothing regresses } const message = err.message.toLowerCase(); return ChunkLoadRecovery.MESSAGE_SIGNATURES.some(signature => message.includes(signature)); } /** * Pure ladder policy — exposed for unit tests. Given the pruned-to-window list of * previous attempt timestamps and `now`, returns how long to wait before the next * reload, or null when the ladder is exhausted (a chunk that is genuinely gone, * not merely mid-deploy — stop touching the page). */ static computeNextAttemptDelay(history: number[], now: number): number | null { const recent = history.filter(ts => now - ts <= ATTEMPT_WINDOW_MS); if (recent.length >= RETRY_LADDER_MS.length) { return null; } if (recent.length === 0) { return 0; } const lastAttempt = Math.max(...recent); return Math.max(0, lastAttempt + RETRY_LADDER_MS[recent.length] - now); } /** * Wires the router self-heal onto the given router: `router.onError` reloads to the * intended `to` path on a failed lazy navigation. Also ensures the `vite:preloadError` * + `unhandledrejection` guards are bound (idempotent) so a router-less caller still * gets preload recovery. */ static bindGuard(router: GuardableRouter): void { router.onError((err, to) => { if (ChunkLoadRecovery.isChunkLoadError(err)) { // fullPath keeps query + hash across the healing reload; path is the // structural fallback for router mocks that don't carry it. ChunkLoadRecovery.triggerReload(to?.fullPath || to?.path); } }); ChunkLoadRecovery.bindPreloadGuard(); } /** * Binds the `vite:preloadError` + `unhandledrejection` window listeners exactly * once — the guard flag lives on the shared `globalState` (window) object, so it * holds even across separate module instances. Called from PowerduckInitializer * at framework init (before any router exists); safe to call again from * {@link bindGuard}. */ static bindPreloadGuard(): void { if (typeof window === 'undefined' || globalState[PRELOAD_GUARD_FLAG] == true) { return; } globalState[PRELOAD_GUARD_FLAG] = true; // `vite:preloadError` is by definition a chunk/CSS preload failure, so it always // warrants the self-heal. Defer the reload by PRELOAD_DEFER_MS so `router.onError` // — which knows the intended `to` path — can claim the attempt first and // navigate there; otherwise fall back to reloading the current location in place. globalState.addEventListener('vite:preloadError', () => { globalState.setTimeout(() => ChunkLoadRecovery.triggerReload(), PRELOAD_DEFER_MS); }); // Dynamic imports OUTSIDE router navigation (on-click modals, wizard steps) // reject into unhandledrejection — nothing else catches them. Same defer: // when the same failure also reaches the router, the router's targeted // reload wins the slot. globalState.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { if (ChunkLoadRecovery.isChunkLoadError(event?.reason)) { globalState.setTimeout(() => ChunkLoadRecovery.triggerReload(), PRELOAD_DEFER_MS); } }); } /** * Schedules (or immediately performs) the healing reload per the retry ladder. * The attempt history is claimed in storage *before* navigating, so another * surface firing for the same failure (e.g. `vite:preloadError` after the * router already caught it) sees the just-written attempt and backs off — no * double reload, no race. A scheduled retry is deduped via a window-shared * pending flag; whichever surface schedules first wins. */ private static triggerReload(targetPath?: string | null): void { if (typeof window === 'undefined' || globalState[PENDING_RETRY_FLAG] == true) { return; } const now = TemporalUtils.dateNowMs(); const delay = ChunkLoadRecovery.computeNextAttemptDelay(ChunkLoadRecovery.readHistory(), now); if (delay == null) { // Ladder exhausted inside the window: the asset is genuinely gone (or the // deploy is badly stuck) — stop reloading so the user isn't trapped in a // loop. A failure after the window expires starts a fresh ladder. console.error('[ChunkLoadRecovery] retry ladder exhausted — not reloading again'); return; } if (delay === 0) { ChunkLoadRecovery.performReload(targetPath); return; } globalState[PENDING_RETRY_FLAG] = true; globalState.setTimeout(() => { globalState[PENDING_RETRY_FLAG] = false; ChunkLoadRecovery.performReload(targetPath); }, delay); } private static performReload(targetPath?: string | null): void { const history = ChunkLoadRecovery.readHistory(); const now = TemporalUtils.dateNowMs(); history.push(now); ChunkLoadRecovery.writeHistory(history.filter(ts => now - ts <= ATTEMPT_WINDOW_MS)); if (!isNullOrEmpty(targetPath)) { globalState.location.href = targetPath; } else { globalState.location.reload(); } } /** * Attempt history in storage — the storage key is app-prefixed so co-hosted * powerduck apps don't share a ladder. Storage (not memory) so the count * survives the reloads it schedules and is shared across tabs, preventing a * multi-tab reload storm during one deploy. */ private static readHistory(): number[] { try { const raw = StorageProvider.getString(ChunkLoadRecovery.historyKey()); const parsed = JSON.parse(raw || '[]'); return Array.isArray(parsed) ? parsed.filter(ts => typeof ts === 'number') : []; } catch { return []; } } private static writeHistory(history: number[]): void { StorageProvider.setString(ChunkLoadRecovery.historyKey(), JSON.stringify(history)); } private static historyKey(): string { return `${PowerduckState.getAppPrefix()}${RELOAD_HISTORY_KEY}`; } }