fix: constrain PWA caching and extend font CSP
This commit is contained in:
@@ -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<T = unknown> {
|
||||
id: string;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user