fix(Service Worker): Refactor and clean up the cache logic in the Service Worker to improve maintainability and handle Safari-specific cache behavior.
This commit is contained in:
		| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -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.' | ||||
| } | ||||
|   | ||||
| @@ -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<Response> { | ||||
|     return new Response( | ||||
|       ` | ||||
|       <html> | ||||
|         <head> | ||||
|           <style> | ||||
|             .note { | ||||
|               padding: 10px; | ||||
|               color: #fff; | ||||
|               background: #000; | ||||
|               border-bottom: 1px solid #e4002b; | ||||
|               margin-bottom: 20px; | ||||
|             } | ||||
|           </style> | ||||
|         </head> | ||||
|         <body> | ||||
|           <div class="note"> | ||||
|             <strong>Service worker running, but status 500</strong><br> | ||||
|           </div> | ||||
|           Service worker is unable to fetch this request.<br> | ||||
|           Request URL: ${requestArg.url}<br> | ||||
|           Response Type: ${responseArg.type}<br> | ||||
|           Response Body: ${await responseArg.clone().text()}<br> | ||||
|         </body> | ||||
|       </html> | ||||
|       `, | ||||
|       { | ||||
|         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<Response> => { | ||||
|       return new Response( | ||||
|         ` | ||||
|         <html> | ||||
|           <head> | ||||
|             <style> | ||||
|               .note { | ||||
|                 padding: 10px; | ||||
|                 color: #fff; | ||||
|                 background: #000; | ||||
|                 border-bottom: 1px solid #e4002b; | ||||
|                 margin-bottom: 20px; | ||||
|               } | ||||
|             </style> | ||||
|           </head> | ||||
|           <body> | ||||
|             <div class="note"> | ||||
|               <strong>Service worker running, but status 500</strong><br> | ||||
|             </div> | ||||
|             Service worker is unable to fetch this request.<br> | ||||
|             Request URL: ${requestArg.url}<br> | ||||
|             Response Type: ${responseArg.type}<br> | ||||
|             Response Body: ${await responseArg.clone().text()}<br> | ||||
|           </body> | ||||
|         </html> | ||||
|       `, | ||||
|         { | ||||
|           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<Response>(); | ||||
|       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<void> => { | ||||
| @@ -272,4 +289,4 @@ export class CacheManager { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user