export interface BundleFreshnessMonitorOptions { readonly appName: string; readonly currentModuleUrl: string; readonly enabled?: boolean; readonly intervalMs?: number; readonly documentUrl?: string; readonly fetchHtml?: (url: string) => Promise; readonly reload?: () => void; } const DEFAULT_BUNDLE_FRESHNESS_INTERVAL_MS = 30_000; const MIN_BUNDLE_FRESHNESS_INTERVAL_MS = 5_000; export function htmlReferencesBundle( html: string, currentModuleUrl: string, documentUrl: string, ): boolean { const currentPath = normalizeUrlPath(currentModuleUrl, documentUrl); const scriptPattern = /]*\bsrc=(["'])(.*?)\1/gi; for (const match of html.matchAll(scriptPattern)) { const rawSrc = match[2]?.trim(); if (!rawSrc) { continue; } if (normalizeUrlPath(rawSrc, documentUrl) === currentPath) { return true; } } const inlineImportPattern = /\bimport\s+(?:[^'"]+\s+from\s+)?(["'])(.*?)\1/gi; for (const match of html.matchAll(inlineImportPattern)) { const rawImport = match[2]?.trim(); if (!rawImport) { continue; } if (normalizeUrlPath(rawImport, documentUrl) === currentPath) { return true; } } return false; } export function installBundleFreshnessMonitor( options: BundleFreshnessMonitorOptions, ): () => void { if (options.enabled === false || typeof window === 'undefined' || typeof document === 'undefined') { return () => {}; } const documentUrl = options.documentUrl ?? window.location.href; const fetchHtml = options.fetchHtml ?? fetchDocumentHtml; const reload = options.reload ?? (() => window.location.reload()); const intervalMs = Math.max( options.intervalMs ?? DEFAULT_BUNDLE_FRESHNESS_INTERVAL_MS, MIN_BUNDLE_FRESHNESS_INTERVAL_MS, ); let checking = false; let disposed = false; const check = async () => { if (checking || disposed) { return; } checking = true; try { const html = await fetchHtml(documentUrl); if (!htmlReferencesBundle(html, options.currentModuleUrl, documentUrl)) { console.warn( `[${options.appName}] Loaded bundle is no longer the served entrypoint; reloading.`, ); reload(); } } catch { // Keep the running app active if the entrypoint probe itself fails. } finally { checking = false; } }; const onVisible = () => { if (document.visibilityState === 'visible') { void check(); } }; const timer = window.setInterval(() => { void check(); }, intervalMs); window.addEventListener('focus', check); document.addEventListener('visibilitychange', onVisible); return () => { disposed = true; window.clearInterval(timer); window.removeEventListener('focus', check); document.removeEventListener('visibilitychange', onVisible); }; } function normalizeUrlPath(value: string, baseUrl: string): string { return new URL(value, baseUrl).pathname; } async function fetchDocumentHtml(url: string): Promise { const response = await fetch(url, { cache: 'no-store', credentials: 'same-origin', headers: { accept: 'text/html', }, }); return response.text(); }