From ab4c302ceabf287811e0520f9452137f7ddd936d Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Tue, 4 Feb 2025 01:58:48 +0100 Subject: [PATCH] fix(core): Refactored caching strategy for service worker to improve compatibility and performance. --- changelog.md | 7 + ts/00_commitinfo_data.ts | 2 +- ts_web_serviceworker/classes.cachemanager.ts | 354 ++++++++----------- 3 files changed, 152 insertions(+), 211 deletions(-) diff --git a/changelog.md b/changelog.md index 281bdf5..7aa04de 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3aeda50..ce204d4 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.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.' } diff --git a/ts_web_serviceworker/classes.cachemanager.ts b/ts_web_serviceworker/classes.cachemanager.ts index c98c83d..a767513 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -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 { - 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 => { + // 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 => { + return new Response( + ` + + + + + +
+ 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()}
+ + + `, + { + 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(); 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 => { + 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 { + 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); } } } -} +} \ No newline at end of file