Refresh UI components and showcase; release updated framework packages
This commit is contained in:
1 parent
d800228585
commit
c77f5e1cec
293 files changed
+22544
-35778
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pwa",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.12",
|
||||
"type": "module",
|
||||
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -48,13 +48,64 @@ export function createWebManifest(options: WebManifestOptions) {
|
||||
};
|
||||
}
|
||||
export function generateServiceWorker(options: ServiceWorkerOptions = {}): string {
|
||||
const offline = options.offlineUrl ?? options.startUrl ?? "/";
|
||||
const urls = [...new Set([offline, ...(options.cacheUrls ?? [])])];
|
||||
// Only explicitly supplied offline documents are safe to precache. The start
|
||||
// URL may redirect to sign-in or contain personalized account information.
|
||||
const offline = options.offlineUrl ?? null;
|
||||
const urls = [...new Set([...(offline ? [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||"/"))});`;
|
||||
return `const CACHE=${JSON.stringify((options.cacheName ?? "wrnexus-pwa") + "-v2")};
|
||||
const OFFLINE=${JSON.stringify(offline)};
|
||||
const PRECACHE=${JSON.stringify(urls)};
|
||||
const RULES=${JSON.stringify(rules)};
|
||||
const SAME_ORIGIN_ONLY=${JSON.stringify(sameOriginOnly)};
|
||||
const sensitive=request=>{
|
||||
const url=new URL(request.url,self.location.origin);
|
||||
return request.method!=="GET"||request.headers.has("authorization")||
|
||||
request.cache==="no-store"||["sso_code","code","token","access_token"].some(key=>url.searchParams.has(key))||
|
||||
/^\\/(?:api|realtime)(?:\\/|$)/.test(url.pathname)||
|
||||
/^\\/(?:login|logout|callback)(?:\\/|$)/.test(url.pathname);
|
||||
};
|
||||
const cacheable=response=>response.ok&&!response.redirected&&response.type!=="opaque"&&
|
||||
!/(?:no-store|private|no-cache)/i.test(response.headers.get("cache-control")||"")&&
|
||||
!response.headers.has("set-cookie");
|
||||
const store=async(request,response)=>{if(cacheable(response)){try{await(await caches.open(CACHE)).put(request,response.clone())}catch{}}};
|
||||
self.addEventListener("install",event=>{
|
||||
event.waitUntil(Promise.all(PRECACHE.map(async url=>{
|
||||
const request=new Request(new URL(url,self.location.origin),{credentials:"omit"});
|
||||
if(sensitive(request))return;
|
||||
try{await store(request,await fetch(request))}catch{}
|
||||
})));
|
||||
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 hit=async request=>{try{return await(await caches.open(CACHE)).match(request)}catch{return undefined}};
|
||||
const networkFirst=async request=>{
|
||||
try{
|
||||
const response=await fetch(request);
|
||||
// Never save navigations: cookies are not readable from a service worker.
|
||||
if(request.mode!=="navigate")await store(request,response);
|
||||
return response;
|
||||
}catch{
|
||||
const cached=request.mode==="navigate"?(OFFLINE?await hit(OFFLINE):undefined):await hit(request);
|
||||
return cached||new Response("You are offline. Please reconnect and try again.",{status:503,headers:{"content-type":"text/plain","cache-control":"no-store"}});
|
||||
}
|
||||
};
|
||||
const cacheFirst=async request=>(await hit(request))||networkFirst(request);
|
||||
const stale=async(request,event)=>{const cached=await hit(request);const update=networkFirst(request);event.waitUntil(update.then(()=>{}));return cached||(await update)};
|
||||
self.addEventListener("fetch",event=>{
|
||||
if(SAME_ORIGIN_ONLY&&new URL(event.request.url).origin!==self.location.origin)return;
|
||||
if(sensitive(event.request))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(event.request.mode==="navigate"?networkFirst(event.request):rule.strategy==="cache-first"?cacheFirst(event.request):rule.strategy==="stale-while-revalidate"?stale(event.request,event):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;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generateServiceWorker } from "../src/index.ts";
|
||||
function worker(fetcher: (r:any)=>Promise<Response>, failStore = false) {
|
||||
const listeners: Record<string,Function> = {};
|
||||
const saved: string[] = [];
|
||||
const cache = {match:async()=>undefined,put:async(r:any)=>{if(failStore)throw Error("quota");saved.push(r.url)}};
|
||||
const self = {location:{origin:"https://account.example.test"},addEventListener:(name:string,fn:Function)=>listeners[name]=fn,skipWaiting(){},clients:{claim:async()=>{}}};
|
||||
new Function("self","caches","fetch",generateServiceWorker())(self,{open:async()=>cache,keys:async()=>[],delete:async()=>true},fetcher);
|
||||
return {saved, async fetch(path:string, mode="cors") {
|
||||
let result:Promise<Response>|undefined;
|
||||
const req = new Request(new URL(path,self.location.origin));
|
||||
Object.defineProperty(req,"mode",{value:mode});
|
||||
listeners.fetch({request:req,respondWith:(r:Promise<Response>)=>result=r,waitUntil:()=>{}});
|
||||
return result;
|
||||
}};
|
||||
}
|
||||
test("handoffs and API calls bypass service worker interception", async()=>{
|
||||
const w=worker(async()=>{throw Error("must not intercept")});
|
||||
expect(await w.fetch("/organization-add?sso_code=one-time")).toBeUndefined();
|
||||
expect(await w.fetch("/api/account")).toBeUndefined();
|
||||
});
|
||||
test("private responses and navigations never enter cache", async()=>{
|
||||
const w=worker(async()=>new Response("private",{headers:{"cache-control":"private, no-store"}}));
|
||||
expect(await (await w.fetch("/profile"))?.text()).toBe("private");
|
||||
expect(w.saved).toEqual([]);
|
||||
const nav=worker(async()=>new Response("personal page"));
|
||||
await nav.fetch("/profile","navigate"); expect(nav.saved).toEqual([]);
|
||||
});
|
||||
test("cache quota errors do not turn successful requests into network errors", async()=>{
|
||||
const w=worker(async()=>new Response("asset"),true);
|
||||
expect(await (await w.fetch("/app.js"))?.text()).toBe("asset");
|
||||
});
|
||||
test("offline without a cached fallback always returns a Response", async()=>{
|
||||
const w=worker(async()=>{throw Error("offline")});
|
||||
expect((await w.fetch("/profile","navigate"))?.status).toBe(503);
|
||||
});
|
||||
test("public static assets remain cacheable", async()=>{
|
||||
const w=worker(async()=>new Response("asset",{headers:{"cache-control":"public, max-age=3600"}}));
|
||||
await w.fetch("/app.js"); expect(w.saved).toHaveLength(1);
|
||||
});
|
||||
Reference in new issue
Block a user