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 ?? [
|
const rules = options.runtimeCaching ?? [
|
||||||
{ pattern: "^https?://", strategy: "network-first" as const, methods: ["GET"] },
|
{ 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> {
|
export interface OfflineMutation<T = unknown> {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ describe("PWA platform", () => {
|
|||||||
const source = generateServiceWorker({ offlineUrl: "/offline", cacheUrls: ["/app.css"] });
|
const source = generateServiceWorker({ offlineUrl: "/offline", cacheUrls: ["/app.css"] });
|
||||||
expect(source).toContain("wrnexus:background-sync");
|
expect(source).toContain("wrnexus:background-sync");
|
||||||
expect(source).toContain("notificationclick");
|
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");
|
expect(pwaClientRuntime()).toContain("wrnexus:pwa-installable");
|
||||||
});
|
});
|
||||||
test("queues, retries and removes successful mutations", async () => {
|
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
|
* 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
|
* the app disabled CSP (`security.contentSecurityPolicy: false`) or uses no
|
||||||
* Google Fonts. Local (self-hosted) fonts are served from `'self'` and need nothing.
|
* 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]));
|
dirs[name] = Array.from(new Set([...cur, ...adds]));
|
||||||
};
|
};
|
||||||
if (add.style.length) extend("style-src", ["'self'", "'unsafe-inline'"], add.style);
|
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 () => {
|
test("package-aware styles add relative imports and Tailwind scan sources", async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-"));
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-styles-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user