113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
|
|
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();
|
||
|
|
}
|