hivecast-sdk/packages/browser-host/src/BundleFreshness.ts

113 lines
3.1 KiB
TypeScript
Raw Normal View History

export interface BundleFreshnessMonitorOptions {
readonly appName: string;
readonly currentModuleUrl: string;
readonly enabled?: boolean;
readonly intervalMs?: number;
readonly documentUrl?: string;
readonly fetchHtml?: (url: string) => Promise<string>;
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 = /<script\b[^>]*\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<string> {
const response = await fetch(url, {
cache: 'no-store',
credentials: 'same-origin',
headers: {
accept: 'text/html',
},
});
return response.text();
}