/** * Service Worker detection — inspects SW registrations, cache storage, background * sync queues, and PWA installability for a crawled page. * Surfaces offline-first contracts and push-capable surfaces that HTTP-only * crawlers cannot observe. */ import type { Page } from 'playwright'; export interface SwRegistration { scope: string; scriptURL: string | null; state: 'active' | 'installing' | 'waiting' | 'none'; updateViaCache: string; } export interface SwCacheEntry { cacheName: string; entryCount: number; sampleUrls: string[]; } export interface BackgroundSyncInfo { scope: string; pendingTags: string[]; } export interface PwaInfo { isInstallable: boolean; manifestUrl: string | null; hasPushPermission: boolean; } export interface ServiceWorkerResult { supported: boolean; active: boolean; registrations: SwRegistration[]; cacheStorages: SwCacheEntry[]; backgroundSync: BackgroundSyncInfo[]; pwa: PwaInfo; totalCachedEntries: number; } const EMPTY_RESULT: ServiceWorkerResult = { supported: false, active: false, registrations: [], cacheStorages: [], backgroundSync: [], pwa: { isInstallable: false, manifestUrl: null, hasPushPermission: false }, totalCachedEntries: 0, }; async function detectServiceWorkers( page: any, ): Promise<{ supported: boolean; active: boolean; registrations: SwRegistration[] }> { const result = await page.evaluate(async () => { if (!('serviceWorker' in navigator)) return { supported: false, active: false, registrations: [] }; try { const controller = navigator.serviceWorker.controller; const regs = await navigator.serviceWorker.getRegistrations(); return { supported: true, active: !!controller, registrations: regs.map(r => ({ scope: r.scope, scriptURL: r.active?.scriptURL || r.installing?.scriptURL || r.waiting?.scriptURL || null, state: r.active ? 'active' : r.installing ? 'installing' : r.waiting ? 'waiting' : 'none', updateViaCache: r.updateViaCache, })), }; } catch { return { supported: true, active: false, registrations: [] }; } }); return result as { supported: boolean; active: boolean; registrations: SwRegistration[] }; } async function enumerateCacheStorage(page: any): Promise { const caches = await page.evaluate(async () => { if (!('caches' in window)) return []; try { const names = await window.caches.keys(); const result = []; for (const name of names) { try { const cache = await window.caches.open(name); const keys = await cache.keys(); result.push({ cacheName: name, entryCount: keys.length, sampleUrls: keys.slice(0, 15).map(k => { try { return new URL(k.url).pathname; } catch { return k.url.slice(0, 80); } }), }); } catch { /* skip inaccessible cache */ } } return result; } catch { return []; } }); return caches as SwCacheEntry[]; } async function detectBackgroundSync(page: any): Promise { const syncInfo = await page.evaluate(async () => { if (!('serviceWorker' in navigator)) return []; try { const regs = await navigator.serviceWorker.getRegistrations(); const result = []; for (const reg of regs) { if ('sync' in reg) { try { const tags = await (reg as any).sync.getTags(); result.push({ scope: reg.scope, pendingTags: tags || [] }); } catch { result.push({ scope: reg.scope, pendingTags: [] }); } } } return result; } catch { return []; } }); return syncInfo as BackgroundSyncInfo[]; } async function detectPwa(page: any): Promise { const pwa = await page.evaluate(() => { const manifestEl = document.querySelector('link[rel="manifest"]'); const manifestUrl = manifestEl?.getAttribute('href') || null; const hasSW = 'serviceWorker' in navigator && !!navigator.serviceWorker.controller; let hasPushPermission = false; try { hasPushPermission = Notification.permission === 'granted'; } catch { /* ignore */ } return { isInstallable: !!(manifestUrl && hasSW), manifestUrl, hasPushPermission, }; }); return pwa as PwaInfo; } export async function inspectServiceWorker(page: any): Promise { try { // Allow SW on HTTPS or localhost (http://localhost and http://127.0.0.1 support SW) const url = page.url(); const isSecureContext = url.startsWith('https://') || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/.test(url); if (!isSecureContext) return { ...EMPTY_RESULT }; const [swResult, cacheResult, syncResult, pwaResult] = await Promise.allSettled([ detectServiceWorkers(page), enumerateCacheStorage(page), detectBackgroundSync(page), detectPwa(page), ]); const sw = swResult.status === 'fulfilled' ? swResult.value : { supported: false, active: false, registrations: [] }; const cacheStorages = cacheResult.status === 'fulfilled' ? cacheResult.value : []; const backgroundSync = syncResult.status === 'fulfilled' ? syncResult.value : []; const pwa = pwaResult.status === 'fulfilled' ? pwaResult.value : EMPTY_RESULT.pwa; const totalCachedEntries = cacheStorages.reduce((sum, c) => sum + c.entryCount, 0); return { supported: sw.supported, active: sw.active, registrations: sw.registrations, cacheStorages, backgroundSync, pwa, totalCachedEntries, }; } catch { return { ...EMPTY_RESULT }; } }