fix(core): Refactored caching strategy for service worker to improve compatibility and performance.
This commit is contained in:
		| @@ -1,5 +1,12 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-02-04 - 3.0.63 - fix(core) | ||||
| Refactored caching strategy for service worker to improve compatibility and performance. | ||||
|  | ||||
| - Removed hard and soft caching distinctions. | ||||
| - Simplified cache setup process. | ||||
| - Improved browser caching control headers. | ||||
|  | ||||
| ## 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. | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@api.global/typedserver', | ||||
|   version: '3.0.62', | ||||
|   version: '3.0.63', | ||||
|   description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' | ||||
| } | ||||
|   | ||||
| @@ -10,127 +10,76 @@ export class CacheManager { | ||||
|     runtimeCacheName: 'runtime' | ||||
|   }; | ||||
|  | ||||
|   /** | ||||
|    * 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 on non‑Safari browsers, | ||||
|    * but on Safari caching is bypassed to avoid CORS issues. | ||||
|    */ | ||||
|   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 matches a hard cached domain. | ||||
|    */ | ||||
|   private isHardCached(url: string): boolean { | ||||
|     return this.hardCachedDomains.some(domain => url.includes(domain)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * 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 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 => { | ||||
|       // Create a matchRequest based on whether the request is internal or external. | ||||
|       let matchRequest: Request; | ||||
|       if (requestArg.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) { | ||||
|         // Internal request; use the original. | ||||
|         matchRequest = requestArg; | ||||
|       } else { | ||||
|         // External request; create a new Request with appropriate CORS settings. | ||||
|         matchRequest = new Request(requestArg.url, { | ||||
|           method: requestArg.method, | ||||
|           headers: requestArg.headers, | ||||
|           mode: 'cors', | ||||
|           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>serviceworker running, but status 500</strong><br> | ||||
|             </div> | ||||
|             serviceworker is unable to fetch this request<br> | ||||
|             Here is some info about the request/response pair:<br> | ||||
|             <br> | ||||
|             requestUrl: ${requestArg.url}<br> | ||||
|             responseType: ${responseArg.type}<br> | ||||
|             responseBody: ${await responseArg.clone().text()}<br> | ||||
|           </body> | ||||
|         </html> | ||||
|       `, | ||||
|         { | ||||
|           headers: { | ||||
|             "Content-Type": "text/html" | ||||
|           }, | ||||
|           status: 500 | ||||
|         } | ||||
|       ); | ||||
|     }; | ||||
|  | ||||
|     // Listen for fetch events. | ||||
|     this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => { | ||||
|       // Block specific scopes. | ||||
|       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') || | ||||
| @@ -138,7 +87,7 @@ export class CacheManager { | ||||
|         parsedUrl.pathname.startsWith('/socket.io') || | ||||
|         originalRequest.url.startsWith('https://umami.') | ||||
|       ) { | ||||
|         logger.log('note', `Service worker not active for ${parsedUrl.toString()}`); | ||||
|         logger.log('note', `serviceworker not active for ${parsedUrl.toString()}`); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
| @@ -146,24 +95,21 @@ export class CacheManager { | ||||
|       const done = plugins.smartpromise.defer<Response>(); | ||||
|       fetchEventArg.respondWith(done.promise); | ||||
|  | ||||
|       // Only handle GET requests for caching. | ||||
|       if (originalRequest.method === 'GET' && this.isCacheable(originalRequest.url)) { | ||||
|         // 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 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 this.create500Response(originalRequest, new Response(err.message)); | ||||
|           }); | ||||
|           done.resolve(networkResponse); | ||||
|           return; | ||||
|         } | ||||
|       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); | ||||
|  | ||||
|         // Try to serve from cache. | ||||
|         const matchRequest = this.createMatchRequest(originalRequest); | ||||
|         const matchRequest = createMatchRequest(originalRequest); | ||||
|         const cachedResponse = await caches.match(matchRequest); | ||||
|         if (cachedResponse) { | ||||
|           logger.log('ok', `CACHED: found cached response for ${matchRequest.url}`); | ||||
| @@ -171,96 +117,83 @@ export class CacheManager { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         logger.log('info', `NOTYETCACHED: fetching and caching ${matchRequest.url}`); | ||||
|         // In case there is no cached response, fetch from the network. | ||||
|         logger.log('info', `NOTYETCACHED: trying to cache ${matchRequest.url}`); | ||||
|         const newResponse: Response = await fetch(matchRequest).catch(async err => { | ||||
|           return await this.create500Response(matchRequest, new Response(err.message)); | ||||
|           return await create500Response(matchRequest, new Response(err.message)); | ||||
|         }); | ||||
|  | ||||
|         // 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)); | ||||
|         // If the response status is an error or the response is opaque, do not cache it. | ||||
|         if (newResponse.status > 299 || newResponse.type === 'opaque') { | ||||
|           logger.log( | ||||
|             'error', | ||||
|             `NOTCACHED: not caching response for ${matchRequest.url} due to status ${newResponse.status} and type ${newResponse.type}` | ||||
|           ); | ||||
|           // Simply return the network response without caching. | ||||
|           done.resolve(newResponse); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // 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 | ||||
|         } else { | ||||
|           // Cache the response. | ||||
|           const cache = await caches.open(this.usedCacheNames.runtimeCacheName); | ||||
|           const responseToPutToCache = newResponse.clone(); | ||||
|           const headers = new Headers(); | ||||
|           responseToPutToCache.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); | ||||
|             } | ||||
|           }); | ||||
|         } catch (err) { | ||||
|           newCachedResponse = newResponse; | ||||
|         } | ||||
|  | ||||
|         await cache.put(matchRequest, newCachedResponse); | ||||
|         logger.log('ok', `NOWCACHED: cached response for ${matchRequest.url}`); | ||||
|         done.resolve(newResponse); | ||||
|           // Ensure 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'); | ||||
|           } | ||||
|           // 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'); | ||||
|  | ||||
|           await cache.put(matchRequest, new Response(responseToPutToCache.body, { | ||||
|             ...responseToPutToCache, | ||||
|             headers | ||||
|           })); | ||||
|           logger.log('ok', `NOWCACHED: cached response for ${matchRequest.url} for subsequent requests!`); | ||||
|           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 this.create500Response(originalRequest, new Response(err.message)); | ||||
|         }); | ||||
|         done.resolve(networkResponse); | ||||
|         // For remote requests not intended for caching, fetch directly from the origin. | ||||
|         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)); | ||||
|           }) | ||||
|         ); | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Cleans all caches. | ||||
|    * Should be run when a new service worker is activated. | ||||
|    * @param reasonArg A reason for the cache cleanup. | ||||
|    * Should only be run when a new service worker is activated. | ||||
|    */ | ||||
|   public cleanCaches = async (reasonArg = 'no reason given'): Promise<void> => { | ||||
|   public cleanCaches = async (reasonArg = 'no reason given') => { | ||||
|     logger.log('info', `MAJOR CACHEEVENT: cleaning caches now! Reason: ${reasonArg}`); | ||||
|     const cacheNames = await caches.keys(); | ||||
|  | ||||
| @@ -272,21 +205,22 @@ export class CacheManager { | ||||
|       return deletePromise; | ||||
|     }); | ||||
|     await Promise.all(deletePromises); | ||||
|   }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Revalidates the runtime cache. | ||||
|    */ | ||||
|   public async revalidateCache(): Promise<void> { | ||||
|   public async revalidateCache() { | ||||
|     const runtimeCache = await caches.open(this.usedCacheNames.runtimeCacheName); | ||||
|     const cacheKeys = await runtimeCache.keys(); | ||||
|     for (const requestArg of cacheKeys) { | ||||
|       // Fetch a new response for comparison. | ||||
|       const clonedRequest = requestArg.clone(); | ||||
|       const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000); | ||||
|       const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000); // Increased timeout for better mobile compatibility | ||||
|       if (response && response.status >= 200 && response.status < 300) { | ||||
|         await runtimeCache.delete(requestArg); | ||||
|         await runtimeCache.put(requestArg, response); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user