export type RuntimeCacheStrategy = "network-first" | "cache-first" | "stale-while-revalidate"; export interface RuntimeCacheRule { pattern: string; strategy: RuntimeCacheStrategy; cacheName?: string; methods?: string[]; } export interface ServiceWorkerOptions { cacheName?: string; offlineUrl?: string; startUrl?: string; cacheUrls?: string[]; runtimeCaching?: RuntimeCacheRule[]; backgroundSyncTag?: string; } export interface WebManifestOptions { name: string; shortName?: string; description?: string; id?: string; startUrl?: string; scope?: string; display?: "standalone" | "fullscreen" | "minimal-ui" | "browser"; themeColor?: string; backgroundColor?: string; icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>; shortcuts?: unknown[]; screenshots?: unknown[]; categories?: string[]; lang?: string; } export function createWebManifest(options: WebManifestOptions) { return { id: options.id ?? options.startUrl ?? "/", name: options.name, short_name: options.shortName ?? options.name, description: options.description, start_url: options.startUrl ?? "/", scope: options.scope ?? "/", display: options.display ?? "standalone", theme_color: options.themeColor ?? "#0f172a", background_color: options.backgroundColor ?? "#0f172a", icons: options.icons ?? [], shortcuts: options.shortcuts ?? [], screenshots: options.screenshots ?? [], categories: options.categories ?? [], lang: options.lang ?? "en", }; } export function generateServiceWorker(options: ServiceWorkerOptions = {}): string { const offline = options.offlineUrl ?? options.startUrl ?? "/"; const urls = [...new Set([offline, ...(options.cacheUrls ?? [])])]; const rules = options.runtimeCaching ?? [ { pattern: "^https?://", strategy: "network-first" as const, methods: ["GET"] }, ]; const sameOriginOnly = options.runtimeCaching === undefined; return `const CACHE=${JSON.stringify(options.cacheName ?? "wrnexus-pwa-v1")};const OFFLINE=${JSON.stringify(offline)};const PRECACHE=${JSON.stringify(urls)};const RULES=${JSON.stringify(rules)};const SAME_ORIGIN_ONLY=${JSON.stringify(sameOriginOnly)};self.addEventListener("install",event=>{event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(PRECACHE)));self.skipWaiting()});self.addEventListener("activate",event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith("wrnexus-pwa-")&&key!==CACHE).map(key=>caches.delete(key)))).then(()=>self.clients.claim())));const networkFirst=async request=>{try{const response=await fetch(request);if(response.ok){const cache=await caches.open(CACHE);await cache.put(request,response.clone())}return response}catch{return(await caches.match(request))||(request.mode==="navigate"?await caches.match(OFFLINE):Response.error())}};const cacheFirst=async request=>(await caches.match(request))||networkFirst(request);const stale=async request=>{const hit=await caches.match(request);const update=networkFirst(request);return hit||(await update)};self.addEventListener("fetch",event=>{if(SAME_ORIGIN_ONLY&&new URL(event.request.url).origin!==self.location.origin)return;const rule=RULES.find(item=>(item.methods||["GET"]).includes(event.request.method)&&new RegExp(item.pattern).test(event.request.url));if(!rule)return;event.respondWith(rule.strategy==="cache-first"?cacheFirst(event.request):rule.strategy==="stale-while-revalidate"?stale(event.request):networkFirst(event.request))});self.addEventListener("sync",event=>{if(event.tag===${JSON.stringify(options.backgroundSyncTag ?? "wrnexus-offline-sync")})event.waitUntil(self.clients.matchAll().then(clients=>clients.forEach(client=>client.postMessage({type:"wrnexus:background-sync"}))))});self.addEventListener("push",event=>{const data=event.data?.json?.()||{};event.waitUntil(self.registration.showNotification(data.title||"Notification",{body:data.body,icon:data.icon,data:{url:data.url||"/"}}))});self.addEventListener("notificationclick",event=>{event.notification.close();event.waitUntil(clients.openWindow(event.notification.data?.url||"/"))});`; } export interface OfflineMutation { id: string; createdAt: number; updatedAt: number; endpoint: string; method: string; payload: T; attempts: number; } export interface OfflineQueueStore { list(): Promise; put(item: OfflineMutation): Promise; remove(id: string): Promise; } export function memoryOfflineQueueStore(): OfflineQueueStore { const values = new Map(); return { async list() { return [...values.values()] .sort((a, b) => a.createdAt - b.createdAt) .map((value) => structuredClone(value)); }, async put(value) { values.set(value.id, structuredClone(value)); }, async remove(id) { values.delete(id); }, }; } export type ConflictResolution = { action: "client" | "server" | "merge"; value: T }; export function resolveOfflineConflict( client: T, server: T, strategy: | "client-wins" | "server-wins" | "last-write-wins" | ((client: T, server: T) => T) = "last-write-wins", ): ConflictResolution { if (typeof strategy === "function") return { action: "merge", value: strategy(client, server) }; if (strategy === "client-wins") return { action: "client", value: client }; if (strategy === "server-wins") return { action: "server", value: server }; const clientUpdatedAt = Number((client as { updatedAt?: number }).updatedAt ?? 0); const serverUpdatedAt = Number((server as { updatedAt?: number }).updatedAt ?? 0); return clientUpdatedAt >= serverUpdatedAt ? { action: "client", value: client } : { action: "server", value: server }; } export function createOfflineQueue( options: { store?: OfflineQueueStore; fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; maxItems?: number; now?: () => number; } = {}, ) { const store = options.store ?? memoryOfflineQueueStore(); const request = options.fetch ?? globalThis.fetch; const now = options.now ?? Date.now; const maxItems = options.maxItems ?? 1000; return { async enqueue( input: Omit, "id" | "createdAt" | "updatedAt" | "attempts">, ) { if ((await store.list()).length >= maxItems) throw new Error("WRN-PWA-OFFLINE-CAPACITY"); const timestamp = now(); const item: OfflineMutation = { ...input, id: crypto.randomUUID(), createdAt: timestamp, updatedAt: timestamp, attempts: 0, }; await store.put(item); return item; }, list: () => store.list(), async sync() { const results: Array<{ id: string; ok: boolean; status?: number }> = []; for (const item of await store.list()) { item.attempts += 1; item.updatedAt = now(); try { const response = await request(item.endpoint, { method: item.method, headers: { "content-type": "application/json", "x-wrnexus-offline-id": item.id }, body: JSON.stringify(item.payload), }); if (response.ok) await store.remove(item.id); else await store.put(item); results.push({ id: item.id, ok: response.ok, status: response.status }); } catch { await store.put(item); results.push({ id: item.id, ok: false }); } } return results; }, remove: (id: string) => store.remove(id), }; } export function pwaClientRuntime(serviceWorkerUrl = "/sw.js"): string { return `if("serviceWorker"in navigator){addEventListener("load",async()=>{const registration=await navigator.serviceWorker.register(${JSON.stringify(serviceWorkerUrl)});if(registration.waiting)dispatchEvent(new CustomEvent("wrnexus:pwa-update",{detail:{registration}}));registration.addEventListener("updatefound",()=>dispatchEvent(new CustomEvent("wrnexus:pwa-update-found",{detail:{registration}})))})}let wrnexusInstallPrompt;addEventListener("beforeinstallprompt",event=>{event.preventDefault();wrnexusInstallPrompt=event;dispatchEvent(new CustomEvent("wrnexus:pwa-installable"))});window.WrNexusPwa={install:async()=>{if(!wrnexusInstallPrompt)return false;await wrnexusInstallPrompt.prompt();const result=await wrnexusInstallPrompt.userChoice;wrnexusInstallPrompt=null;return result.outcome==="accepted"}};`; } export async function subscribeToPush( registration: ServiceWorkerRegistration, publicKey: Uint8Array, ): Promise { const permission = await Notification.requestPermission(); if (permission !== "granted") throw new Error("WRN-PWA-PUSH-DENIED"); return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: Uint8Array.from(publicKey).buffer, }); } export { openPwaDatabase, indexedDbOfflineQueueStore, offlineQueueMigration, memoryPushSubscriptionStore, postgresPushSubscriptionStore, POSTGRES_PUSH_SUBSCRIPTION_SCHEMA, createPushSubscriptionService, renderOfflineQueueReview, PWA_REVIEW_RUNTIME, } from "./advanced.ts"; export type { IndexedDbMigration, StoredPushSubscription, PushSubscriptionStore, PushSqlClient, } from "./advanced.ts";