192 lines
9.1 KiB
TypeScript
192 lines
9.1 KiB
TypeScript
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<T = unknown> {
|
|
id: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
endpoint: string;
|
|
method: string;
|
|
payload: T;
|
|
attempts: number;
|
|
}
|
|
export interface OfflineQueueStore {
|
|
list(): Promise<OfflineMutation[]>;
|
|
put(item: OfflineMutation): Promise<void>;
|
|
remove(id: string): Promise<void>;
|
|
}
|
|
export function memoryOfflineQueueStore(): OfflineQueueStore {
|
|
const values = new Map<string, OfflineMutation>();
|
|
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<T> = { action: "client" | "server" | "merge"; value: T };
|
|
export function resolveOfflineConflict<T extends object>(
|
|
client: T,
|
|
server: T,
|
|
strategy:
|
|
| "client-wins"
|
|
| "server-wins"
|
|
| "last-write-wins"
|
|
| ((client: T, server: T) => T) = "last-write-wins",
|
|
): ConflictResolution<T> {
|
|
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<Response>;
|
|
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<T>(
|
|
input: Omit<OfflineMutation<T>, "id" | "createdAt" | "updatedAt" | "attempts">,
|
|
) {
|
|
if ((await store.list()).length >= maxItems) throw new Error("WRN-PWA-OFFLINE-CAPACITY");
|
|
const timestamp = now();
|
|
const item: OfflineMutation<T> = {
|
|
...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<PushSubscription> {
|
|
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";
|