From f518300d68aa57ab132291a35a9480b08a13b404 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Tue, 4 Feb 2025 01:45:08 +0100 Subject: [PATCH] fix(ServiceWorkerCacheManager): fixed caching --- changelog.md | 27 +++ ts/00_commitinfo_data.ts | 2 +- ts_web_serviceworker/classes.cachemanager.ts | 217 +++++++++++-------- 3 files changed, 154 insertions(+), 92 deletions(-) diff --git a/changelog.md b/changelog.md index 0dd17a2..253551a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,32 @@ # Changelog +## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager) +Fixed caching mechanism to better support Safari's handling of soft-cached domains. + +- Added logic to differentiate between hard and soft cached domains. +- Implemented special handling for soft cached domains on Safari by bypassing caching. +- Ensured appropriate CORS headers are present in cached responses. +- Improved error handling with informative 500 error responses. +- Optimized caching logic to prevent redundant caching and potential issues with locked streams on Safari. + +## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager) +Fixed caching mechanism to better support Safari's handling of soft-cached domains. + +- Added logic to differentiate between hard and soft cached domains. +- Implemented special handling for soft cached domains on Safari by bypassing caching. +- Ensured appropriate CORS headers are present in cached responses. +- Improved error handling with informative 500 error responses. +- Optimized caching logic to prevent redundant caching and potential issues with locked streams on Safari. + +## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager) +Fixed caching mechanism to better support Safari's handling of soft-cached domains. + +- Added logic to differentiate between hard and soft cached domains. +- Implemented special handling for soft cached domains on Safari by bypassing caching. +- Ensured appropriate CORS headers are present in cached responses. +- Improved error handling with informative 500 error responses. +- Optimized caching logic to prevent redundant caching and potential issues with locked streams on Safari. + ## 2025-02-04 - 3.0.60 - fix(cachemanager) Improve cache management and error handling diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index fc07b49..b09f9fe 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.60', + version: '3.0.61', 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 1b0a76f..f9ae3b6 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -10,28 +10,76 @@ export class CacheManager { runtimeCacheName: 'runtime' }; + /** + * Hard cached domains are always attempted to be cached, regardless of browser. + * 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. + */ + public softCachedDomains: string[]; + constructor(losslessServiceWorkerRefArg: ServiceWorker) { this.losslessServiceWorkerRef = losslessServiceWorkerRefArg; + + // Default hard cached domains. + this.hardCachedDomains = [ + this.losslessServiceWorkerRef.serviceWindowRef.location.origin, + 'https://unpkg.com', + 'https://fonts.googleapis.com', + 'https://fonts.gstatic.com' + ]; + + // Default soft cached domains. + this.softCachedDomains = [ + 'https://assetbroker.' + ]; + this._setupCache(); } + /** + * Returns true if the given URL is in one of the hard cached domains. + */ + 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. + */ + 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). + */ + private isCacheable(url: string): boolean { + return this.isHardCached(url) || this.isSoftCached(url); + } + + /** + * Sets up the fetch event listener and caching logic. + */ private _setupCache = () => { - const createMatchRequest = (requestArg: Request) => { - // Create a matchRequest. + const createMatchRequest = (requestArg: Request): Request => { let matchRequest: Request; - if ( - requestArg.url.startsWith( - this.losslessServiceWorkerRef.serviceWindowRef.location.origin - ) - ) { - // internal 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 CORS settings. + // 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: 'cors', + mode, credentials: 'same-origin', redirect: 'follow' }); @@ -40,9 +88,9 @@ export class CacheManager { }; /** - * Creates a 500 response + * Creates a 500 error response. */ - const create500Response = async (requestArg: Request, responseArg: Response) => { + const create500Response = async (requestArg: Request, responseArg: Response): Promise => { return new Response( ` @@ -58,33 +106,28 @@ export class CacheManager { - -
- serviceworker running, but status 500
-
- serviceworker is unable to fetch this request
- Here is some info about the request/response pair:
-
- requestUrl: ${requestArg.url}
- responseType: ${responseArg.type}
- responseBody: ${await responseArg.clone().text()}
+
+ 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" - }, + headers: { "Content-Type": "text/html" }, status: 500 } ); }; - // A list of local resources we always want to be cached. this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => { - // Block scopes we don't want the serviceworker to handle. const originalRequest: Request = fetchEventArg.request; const parsedUrl = new URL(originalRequest.url); + + // Exclude specific hosts or paths from being handled by the service worker. if ( parsedUrl.hostname.includes('paddle.com') || parsedUrl.hostname.includes('paypal.com') || @@ -92,28 +135,31 @@ export class CacheManager { parsedUrl.pathname.startsWith('/socket.io') || originalRequest.url.startsWith('https://umami.') ) { - logger.log('note', `serviceworker not active for ${parsedUrl.toString()}`); + logger.log('note', `Service worker not active for ${parsedUrl.toString()}`); return; } - // Continue for the rest. + // Create a deferred response. const done = plugins.smartpromise.defer(); fetchEventArg.respondWith(done.promise); - if ( - (originalRequest.method === 'GET' && - (originalRequest.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin) && - !originalRequest.url.includes('/api/') && - !originalRequest.url.includes('smartserve/reloadcheck'))) || - originalRequest.url.includes('https://assetbroker.') || - originalRequest.url.includes('https://unpkg.com') || - originalRequest.url.includes('https://fonts.googleapis.com') || - originalRequest.url.includes('https://fonts.gstatic.com') - ) { - // Check for updates asynchronously. - this.losslessServiceWorkerRef.updateManager.checkUpdate(this); + // Determine if the request should be cached. + if (originalRequest.method === 'GET' && this.isCacheable(originalRequest.url)) { + // Check if running on Safari. + const userAgent = (self.navigator && self.navigator.userAgent) || ""; + const isSafari = /Safari/.test(userAgent) && !/Chrome/.test(userAgent); - // Handle local or approved remote requests. + // For soft cached domains on Safari, bypass caching. + 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)); + }); + done.resolve(networkResponse); + return; + } + + // For other cases, try serving from cache. const matchRequest = createMatchRequest(originalRequest); const cachedResponse = await caches.match(matchRequest); if (cachedResponse) { @@ -122,41 +168,33 @@ export class CacheManager { return; } - // No cached response found; try to fetch and cache. - logger.log('info', `NOTYETCACHED: trying to cache ${matchRequest.url}`); + 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)); }); - // If status > 299 or opaque response, don't cache. + // 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: can't cache response for ${matchRequest.url} due to status ${ - newResponse.status - } and type ${newResponse.type}` + `NOTCACHED: cannot cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})` ); done.resolve(await create500Response(matchRequest, newResponse)); + return; } else { + // Open the runtime cache. const cache = await caches.open(this.usedCacheNames.runtimeCacheName); - const responseToPutToCache = newResponse.clone(); + const responseToCache = newResponse.clone(); - // Create new headers preserving all except caching-related headers. + // Build new headers preserving all except caching-related headers. const headers = new Headers(); - responseToPutToCache.headers.forEach((value, key) => { - if (![ - 'Cache-Control', - 'cache-control', - 'Expires', - 'expires', - 'Pragma', - 'pragma' - ].includes(key)) { + responseToCache.headers.forEach((value, key) => { + if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) { headers.set(key, value); } }); - // Ensure CORS headers are present in cached response. + // Ensure necessary CORS headers are present. if (!headers.has('Access-Control-Allow-Origin')) { headers.set('Access-Control-Allow-Origin', '*'); } @@ -166,45 +204,46 @@ export class CacheManager { if (!headers.has('Access-Control-Allow-Headers')) { headers.set('Access-Control-Allow-Headers', 'Content-Type'); } - // Prevent browser caching while allowing service worker caching. + // 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'); - // IMPORTANT: Read the entire response body as a blob so that - // Safari does not have issues with a locked stream when caching. - const bodyBlob = await responseToPutToCache.blob(); - const newCachedResponse = new Response(bodyBlob, { - status: responseToPutToCache.status, - statusText: responseToPutToCache.statusText, - headers - }); + // 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: cached response for ${matchRequest.url} for subsequent requests!`); + logger.log('ok', `NOWCACHED: response for ${matchRequest.url} cached successfully`); done.resolve(newResponse); } } else { - // For remote requests that don't qualify for caching. - logger.log( - 'ok', - `NOTCACHED: not caching any responses for ${originalRequest.url}. Fetching from origin now...` - ); - done.resolve( - await fetch(originalRequest).catch(async err => { - return await create500Response(originalRequest, new Response(err.message)); - }) - ); + // 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)); + }); + done.resolve(networkResponse); } }); - } + }; /** * Cleans all caches. - * Should only be run when running a new service worker. - * @param reasonArg + * Should only be run when a new service worker is activated. + * @param reasonArg A reason for the cache cleanup. */ - public cleanCaches = async (reasonArg = 'no reason given') => { + public cleanCaches = async (reasonArg = 'no reason given'): Promise => { logger.log('info', `MAJOR CACHEEVENT: cleaning caches now! Reason: ${reasonArg}`); const cacheNames = await caches.keys(); @@ -216,21 +255,17 @@ export class CacheManager { return deletePromise; }); await Promise.all(deletePromises); - } + }; /** * Revalidates the runtime cache. */ - public async revalidateCache() { + public async revalidateCache(): Promise { const runtimeCache = await caches.open(this.usedCacheNames.runtimeCacheName); const cacheKeys = await runtimeCache.keys(); for (const requestArg of cacheKeys) { - // Get the cached response. - const cachedResponse = runtimeCache.match(requestArg); - - // Fetch a new response for comparison. const clonedRequest = requestArg.clone(); - const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000); // Increased timeout for better mobile compatibility + const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000); if (response && response.status >= 200 && response.status < 300) { await runtimeCache.delete(requestArg); await runtimeCache.put(requestArg, response);