From 649e3d9127766c9d7398d05832431af61152276e Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 3 Aug 2026 00:38:23 +0530 Subject: [PATCH] fix: constrain PWA caching and extend font CSP --- packages/pwa/src/index.ts | 3 ++- packages/pwa/test/pwa.test.ts | 7 +++++++ packages/styles/src/config.ts | 8 ++++++-- packages/styles/test/config.test.ts | 32 +++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/pwa/src/index.ts b/packages/pwa/src/index.ts index da7ea2d3..f383c45f 100644 --- a/packages/pwa/src/index.ts +++ b/packages/pwa/src/index.ts @@ -53,7 +53,8 @@ export function generateServiceWorker(options: ServiceWorkerOptions = {}): strin const rules = options.runtimeCaching ?? [ { pattern: "^https?://", strategy: "network-first" as const, methods: ["GET"] }, ]; - 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)};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=>{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||"/"))});`; + 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; diff --git a/packages/pwa/test/pwa.test.ts b/packages/pwa/test/pwa.test.ts index 15740e50..493928d5 100644 --- a/packages/pwa/test/pwa.test.ts +++ b/packages/pwa/test/pwa.test.ts @@ -17,6 +17,13 @@ describe("PWA platform", () => { const source = generateServiceWorker({ offlineUrl: "/offline", cacheUrls: ["/app.css"] }); expect(source).toContain("wrnexus:background-sync"); expect(source).toContain("notificationclick"); + expect(source).toContain("const SAME_ORIGIN_ONLY=true"); + expect(source).toContain("new URL(event.request.url).origin!==self.location.origin"); + expect( + generateServiceWorker({ + runtimeCaching: [{ pattern: "^https://cdn.example.com/", strategy: "cache-first" }], + }), + ).toContain("const SAME_ORIGIN_ONLY=false"); expect(pwaClientRuntime()).toContain("wrnexus:pwa-installable"); }); test("queues, retries and removes successful mutations", async () => { diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index 5ad0f4f1..f7765f12 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -514,7 +514,8 @@ export async function loadAppConfig(appRoot: string, profile?: string): Promise< /** * Auto-extend the CSP so configured Google Fonts load under the default policy - * (their CSS host into `style-src`, the static host into `font-src`). No-op when + * (their CSS host into `style-src`, the static host into `font-src`, and the + * static host into `connect-src` for service-worker fetch interception). No-op when * the app disabled CSP (`security.contentSecurityPolicy: false`) or uses no * Google Fonts. Local (self-hosted) fonts are served from `'self'` and need nothing. */ @@ -532,7 +533,10 @@ function applyFontCsp(config: AppConfig): void { dirs[name] = Array.from(new Set([...cur, ...adds])); }; if (add.style.length) extend("style-src", ["'self'", "'unsafe-inline'"], add.style); - if (add.font.length) extend("font-src", ["'self'", "data:"], add.font); + if (add.font.length) { + extend("font-src", ["'self'", "data:"], add.font); + extend("connect-src", ["'self'", "ws:", "wss:"], add.font); + } } /** diff --git a/packages/styles/test/config.test.ts b/packages/styles/test/config.test.ts index 618714ee..b19e9537 100644 --- a/packages/styles/test/config.test.ts +++ b/packages/styles/test/config.test.ts @@ -196,6 +196,38 @@ test("loadAppConfig loads env before evaluating the config module", async () => } }); +test("Google Fonts CSP supports direct fonts and service-worker fetches", async () => { + const dir = mkdtempSync(join(tmpdir(), "wrnexus-font-csp-")); + try { + writeFileSync( + join(dir, "wrnexus.config.ts"), + `export default { + fonts: { google: [{ family: "Inter" }] }, + security: { + contentSecurityPolicy: { + directives: { "connect-src": ["'self'", "https://api.example.com"] } + } + } + };\n`, + ); + + const config = await loadAppConfig(dir, "production"); + const directives = + config.security?.contentSecurityPolicy === false + ? undefined + : config.security?.contentSecurityPolicy?.directives; + expect(directives?.["style-src"]).toContain("https://fonts.googleapis.com"); + expect(directives?.["font-src"]).toContain("https://fonts.gstatic.com"); + expect(directives?.["connect-src"]).toEqual([ + "'self'", + "https://api.example.com", + "https://fonts.gstatic.com", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("package-aware styles add relative imports and Tailwind scan sources", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-")); try {