From 270230b0ca8512f08fd8432f6f3b4f858575c84c Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Tue, 4 Feb 2025 01:52:48 +0100 Subject: [PATCH] fix(Service Worker): Refactor and clean up the cache logic in the Service Worker to improve maintainability and handle Safari-specific cache behavior. --- changelog.md | 8 + ts/00_commitinfo_data.ts | 2 +- ts_web_serviceworker/classes.cachemanager.ts | 267 ++++++++++--------- 3 files changed, 151 insertions(+), 126 deletions(-) diff --git a/changelog.md b/changelog.md index 253551a..281bdf5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-02-04 - 3.0.62 - fix(Service Worker) +Refactor and clean up the cache logic in the Service Worker to improve maintainability and handle Safari-specific cache behavior. + +- Refactored logic for determining cached domains, enhancing the readability and maintainability of the code. +- Improved handling of CORS settings in caching requests, notably bypassing caching for soft cached domains in Safari to avoid CORS issues. +- Enhanced error response creation for failed resource fetching, maintaining clarity on why and how certain resources were not fetched or cached. +- Revised the structure of the caching logic to ensure consistent behavior across all supported browsers. + ## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager) Fixed caching mechanism to better support Safari's handling of soft-cached domains. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index b09f9fe..3aeda50 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@api.global/typedserver', - version: '3.0.61', + version: '3.0.62', description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' } diff --git a/ts_web_serviceworker/classes.cachemanager.ts b/ts_web_serviceworker/classes.cachemanager.ts index f9ae3b6..c98c83d 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -11,14 +11,14 @@ export class CacheManager { }; /** - * Hard cached domains are always attempted to be cached, regardless of browser. + * Hard cached domains are always attempted to be cached. * For example, your internal origin, fonts, CDNs, etc. */ public hardCachedDomains: string[]; /** - * Soft cached domains will be cached normally except on Safari. - * This is useful for domains where Safari’s handling of cached CORS responses is problematic. + * Soft cached domains will be cached normally on non‑Safari browsers, + * but on Safari caching is bypassed to avoid CORS issues. */ public softCachedDomains: string[]; @@ -42,87 +42,90 @@ export class CacheManager { } /** - * Returns true if the given URL is in one of the hard cached domains. + * Returns true if the given URL matches a hard cached domain. */ private isHardCached(url: string): boolean { return this.hardCachedDomains.some(domain => url.includes(domain)); } /** - * Returns true if the given URL is in one of the soft cached domains. + * Returns true if the given URL matches a soft cached domain. */ private isSoftCached(url: string): boolean { return this.softCachedDomains.some(domain => url.includes(domain)); } /** - * Returns true if the given URL is cacheable (i.e. belongs to either hard or soft domains). + * Returns true if the given URL should be cached (hard or soft). */ private isCacheable(url: string): boolean { return this.isHardCached(url) || this.isSoftCached(url); } + /** + * Creates a new Request with appropriate CORS settings. + */ + private createMatchRequest(requestArg: Request): Request { + let matchRequest: Request; + // For internal requests, use the original request. + if (requestArg.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) { + matchRequest = requestArg; + } else { + // For external requests, choose the request mode based on whether the URL is soft-cached. + const isSoft = this.isSoftCached(requestArg.url); + // For soft cached domains we use 'no-cors' to avoid CORS errors (at the expense of an opaque response). + const mode: RequestMode = isSoft ? 'no-cors' : 'cors'; + matchRequest = new Request(requestArg.url, { + method: requestArg.method, + headers: requestArg.headers, + mode, + credentials: 'same-origin', + redirect: 'follow' + }); + } + return matchRequest; + } + + /** + * Creates a 500 error response. + */ + private async create500Response(requestArg: Request, responseArg: Response): Promise { + return new Response( + ` + + + + + +
+ Service worker running, but status 500
+
+ Service worker is unable to fetch this request.
+ Request URL: ${requestArg.url}
+ Response Type: ${responseArg.type}
+ Response Body: ${await responseArg.clone().text()}
+ + + `, + { + headers: { "Content-Type": "text/html" }, + status: 500 + } + ); + } + /** * Sets up the fetch event listener and caching logic. */ private _setupCache = () => { - const createMatchRequest = (requestArg: Request): Request => { - let matchRequest: Request; - // For internal requests, use the original request. - if (requestArg.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) { - matchRequest = requestArg; - } else { - // For external requests, create a new Request with appropriate CORS settings. - // For soft cached domains, we use 'no-cors' to keep the response opaque. - const isSoft = this.isSoftCached(requestArg.url); - const mode: RequestMode = isSoft ? 'no-cors' : 'cors'; - matchRequest = new Request(requestArg.url, { - method: requestArg.method, - headers: requestArg.headers, - mode, - credentials: 'same-origin', - redirect: 'follow' - }); - } - return matchRequest; - }; - - /** - * Creates a 500 error response. - */ - const create500Response = async (requestArg: Request, responseArg: Response): Promise => { - return new Response( - ` - - - - - -
- Service worker running, but status 500
-
- Service worker is unable to fetch this request.
- Request URL: ${requestArg.url}
- Response Type: ${responseArg.type}
- Response Body: ${await responseArg.clone().text()}
- - - `, - { - headers: { "Content-Type": "text/html" }, - status: 500 - } - ); - }; - this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => { const originalRequest: Request = fetchEventArg.request; const parsedUrl = new URL(originalRequest.url); @@ -143,24 +146,24 @@ export class CacheManager { const done = plugins.smartpromise.defer(); fetchEventArg.respondWith(done.promise); - // Determine if the request should be cached. + // Only handle GET requests for caching. if (originalRequest.method === 'GET' && this.isCacheable(originalRequest.url)) { - // Check if running on Safari. + // Determine if the browser is Safari. const userAgent = (self.navigator && self.navigator.userAgent) || ""; const isSafari = /Safari/.test(userAgent) && !/Chrome/.test(userAgent); - // For soft cached domains on Safari, bypass caching. + // For soft cached domains on Safari, bypass caching entirely. if (this.isSoftCached(originalRequest.url) && isSafari) { logger.log('info', `Safari detected – bypass caching for soft cached domain: ${originalRequest.url}`); const networkResponse = await fetch(originalRequest).catch(async err => { - return await create500Response(originalRequest, new Response(err.message)); + return await this.create500Response(originalRequest, new Response(err.message)); }); done.resolve(networkResponse); return; } - // For other cases, try serving from cache. - const matchRequest = createMatchRequest(originalRequest); + // Try to serve from cache. + const matchRequest = this.createMatchRequest(originalRequest); const cachedResponse = await caches.match(matchRequest); if (cachedResponse) { logger.log('ok', `CACHED: found cached response for ${matchRequest.url}`); @@ -170,68 +173,82 @@ export class CacheManager { logger.log('info', `NOTYETCACHED: fetching and caching ${matchRequest.url}`); const newResponse: Response = await fetch(matchRequest).catch(async err => { - return await create500Response(matchRequest, new Response(err.message)); + return await this.create500Response(matchRequest, new Response(err.message)); }); - // Do not cache responses that are not successful or are opaque (when not expected). - if (newResponse.status > 299 || newResponse.type === 'opaque') { - logger.log( - 'error', - `NOTCACHED: cannot cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})` - ); - done.resolve(await create500Response(matchRequest, newResponse)); + // If the response status indicates an error, don't cache. + if (newResponse.status > 299) { + logger.log('error', `NOTCACHED: response for ${matchRequest.url} has status ${newResponse.status}`); + done.resolve(await this.create500Response(matchRequest, newResponse)); return; - } else { - // Open the runtime cache. - const cache = await caches.open(this.usedCacheNames.runtimeCacheName); - const responseToCache = newResponse.clone(); - - // Build new headers preserving all except caching-related headers. - const headers = new Headers(); - responseToCache.headers.forEach((value, key) => { - if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) { - headers.set(key, value); - } - }); - - // Ensure necessary CORS headers are present. - if (!headers.has('Access-Control-Allow-Origin')) { - headers.set('Access-Control-Allow-Origin', '*'); - } - if (!headers.has('Access-Control-Allow-Methods')) { - headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - } - if (!headers.has('Access-Control-Allow-Headers')) { - headers.set('Access-Control-Allow-Headers', 'Content-Type'); - } - // Set caching headers to prevent browser caching. - headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - headers.set('Pragma', 'no-cache'); - headers.set('Expires', '0'); - headers.set('Surrogate-Control', 'no-store'); - - // Read the response body as a blob (helps prevent issues with locked streams on Safari). - let newCachedResponse: Response; - try { - const bodyBlob = await responseToCache.blob(); - newCachedResponse = new Response(bodyBlob, { - status: responseToCache.status, - statusText: responseToCache.statusText, - headers - }); - } catch (err) { - newCachedResponse = newResponse; - } - - await cache.put(matchRequest, newCachedResponse); - logger.log('ok', `NOWCACHED: response for ${matchRequest.url} cached successfully`); - done.resolve(newResponse); } + + // Handle opaque responses separately. + if (newResponse.type === 'opaque') { + // For soft-cached domains we expect opaque responses (from no-cors fetches). + if (this.isSoftCached(matchRequest.url)) { + const cache = await caches.open(this.usedCacheNames.runtimeCacheName); + // Cache the opaque response as-is. + await cache.put(matchRequest, newResponse.clone()); + logger.log('ok', `NOWCACHED: cached opaque response for ${matchRequest.url}`); + done.resolve(newResponse); + return; + } else { + // If an opaque response comes from a non-soft domain, treat it as an error. + logger.log('error', `NOTCACHED: opaque response received for non-soft domain ${matchRequest.url}`); + done.resolve(await this.create500Response(matchRequest, newResponse)); + return; + } + } + + // For non-opaque responses, adjust headers and cache. + const cache = await caches.open(this.usedCacheNames.runtimeCacheName); + const responseToCache = newResponse.clone(); + const headers = new Headers(); + responseToCache.headers.forEach((value, key) => { + // Preserve all headers except caching-related ones. + if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) { + headers.set(key, value); + } + }); + + // Ensure necessary CORS headers. + if (!headers.has('Access-Control-Allow-Origin')) { + headers.set('Access-Control-Allow-Origin', '*'); + } + if (!headers.has('Access-Control-Allow-Methods')) { + headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + } + if (!headers.has('Access-Control-Allow-Headers')) { + headers.set('Access-Control-Allow-Headers', 'Content-Type'); + } + // Prevent browser caching while allowing service worker caching. + headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('Surrogate-Control', 'no-store'); + + // Read the response body as a blob so we can create a new Response with modified headers. + let newCachedResponse: Response; + try { + const bodyBlob = await responseToCache.blob(); + newCachedResponse = new Response(bodyBlob, { + status: responseToCache.status, + statusText: responseToCache.statusText, + headers + }); + } catch (err) { + newCachedResponse = newResponse; + } + + await cache.put(matchRequest, newCachedResponse); + logger.log('ok', `NOWCACHED: cached response for ${matchRequest.url}`); + done.resolve(newResponse); } else { // For non-cacheable requests, fetch directly from the network. logger.log('ok', `NOTCACHED: fetching ${originalRequest.url} from origin (non-cacheable)`); const networkResponse = await fetch(originalRequest).catch(async err => { - return await create500Response(originalRequest, new Response(err.message)); + return await this.create500Response(originalRequest, new Response(err.message)); }); done.resolve(networkResponse); } @@ -240,7 +257,7 @@ export class CacheManager { /** * Cleans all caches. - * Should only be run when a new service worker is activated. + * Should be run when a new service worker is activated. * @param reasonArg A reason for the cache cleanup. */ public cleanCaches = async (reasonArg = 'no reason given'): Promise => { @@ -272,4 +289,4 @@ export class CacheManager { } } } -} \ No newline at end of file +}