From 75ce27a4bf3b7dc38d049832693f9da76a09d563 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Thu, 6 Feb 2025 02:54:37 +0100 Subject: [PATCH] fix(serviceworker): Improve error handling and logging in cache manager and update manager. --- changelog.md | 7 + ts/00_commitinfo_data.ts | 2 +- ts_web_serviceworker/classes.cachemanager.ts | 382 ++++++++++-------- ts_web_serviceworker/classes.updatemanager.ts | 11 +- 4 files changed, 216 insertions(+), 186 deletions(-) diff --git a/changelog.md b/changelog.md index df0ddc1..2d15c84 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2025-02-06 - 3.0.66 - fix(serviceworker) +Improve error handling and logging in cache manager and update manager. + +- Enhanced error handling and logging in cache management functions. +- Corrected network request handling in update manager. +- Added missing error handling for fetch events. + ## 2025-02-04 - 3.0.65 - fix(readme) Update documentation with advanced usage and examples diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ce54874..34de8f4 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.65', + version: '3.0.66', 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 9c2b8de..ee2b4b1 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -15,22 +15,34 @@ export class CacheManager { this._setupCache(); } + /** + * Sets up the service worker's fetch event to intercept and cache responses. + */ private _setupCache = () => { + // Create a matching request. For internal requests, reuse the original; for external requests, create one with CORS settings. 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' - }); + try { + if ( + requestArg.url.startsWith( + this.losslessServiceWorkerRef.serviceWindowRef.location.origin + ) + ) { + // Internal request + 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' + }); + } + } catch (err) { + logger.log('error', `Error creating match request for ${requestArg.url}: ${err}`); + throw err; } return matchRequest; }; @@ -39,192 +51,208 @@ export class CacheManager { * 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 - } - ); + try { + const responseText = await responseArg.clone().text(); + return new Response( + ` + + + + + +
+ ServiceWorker error 500
+
+ ServiceWorker is unable to fetch this request.
+
+ Request URL: ${requestArg.url}
+ Response Type: ${responseArg.type}
+ Response Body: ${responseText}
+ + + `, + { + headers: { 'Content-Type': 'text/html' }, + status: 500 + } + ); + } catch (err) { + logger.log('error', `Error creating 500 response for ${requestArg.url}: ${err}`); + return new Response('Internal error', { status: 500 }); + } }; - // Listen for fetch events. + // Listen for fetch events on the service worker's controlled window. this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => { - // Block specific scopes. - const originalRequest: Request = fetchEventArg.request; - const parsedUrl = new URL(originalRequest.url); - if ( - parsedUrl.hostname.includes('paddle.com') || - parsedUrl.hostname.includes('paypal.com') || - parsedUrl.hostname.includes('reception.lossless.one') || - parsedUrl.pathname.startsWith('/socket.io') || - originalRequest.url.startsWith('https://umami.') - ) { - logger.log('note', `serviceworker not active for ${parsedUrl.toString()}`); - return; - } + try { + const originalRequest: Request = fetchEventArg.request; + const parsedUrl = new URL(originalRequest.url); - // 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); - - // Try to serve from cache. - const matchRequest = createMatchRequest(originalRequest); - const cachedResponse = await caches.match(matchRequest); - if (cachedResponse) { - logger.log('ok', `CACHED: found cached response for ${matchRequest.url}`); - done.resolve(cachedResponse); + // Block requests that we don't want the service worker to handle. + if ( + parsedUrl.hostname.includes('paddle.com') || + parsedUrl.hostname.includes('paypal.com') || + parsedUrl.hostname.includes('reception.lossless.one') || + parsedUrl.pathname.startsWith('/socket.io') || + originalRequest.url.startsWith('https://umami.') + ) { + logger.log('note', `ServiceWorker not active for ${parsedUrl.toString()}`); return; } - // 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 create500Response(matchRequest, new Response(err.message)); - }); + // Create a deferred promise for the fetch event's response. + const done = plugins.smartpromise.defer(); + fetchEventArg.respondWith(done.promise); - // If the response status is an error or the response is opaque, do not cache it. - if (newResponse.status > 299 || newResponse.type === 'opaque' || (newResponse.headers.get('access-control-allow-origin') === null && !matchRequest.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin))) { - 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; - } 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); + // Determine whether this request should be cached. + 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') + ) { + // Kick off an asynchronous update check. + this.losslessServiceWorkerRef.updateManager.checkUpdate(this); + + const matchRequest = createMatchRequest(originalRequest); + const cachedResponse = await caches.match(matchRequest); + if (cachedResponse) { + logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`); + done.resolve(cachedResponse); + return; + } + + logger.log('info', `NOTYETCACHED: Trying to cache ${matchRequest.url}`); + let newResponse: Response; + try { + newResponse = await fetch(matchRequest); + } catch (err: any) { + logger.log('error', `Fetch error for ${matchRequest.url}: ${err}`); + newResponse = await create500Response(matchRequest, new Response(err.message)); + } + + // Check if the response should be cached. In this version, if the response status is >299 or the response is opaque, we do not cache. + if (newResponse.status > 299 || newResponse.type === 'opaque') { + logger.log( + 'error', + `NOTCACHED: Can't cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})` + ); + // Optionally, you can force a 500 response so errors are clearly visible. + done.resolve(await create500Response(matchRequest, newResponse)); + } else { + try { + const cache = await caches.open(this.usedCacheNames.runtimeCacheName); + const responseToPutToCache = newResponse.clone(); + + // Create new headers preserving all except caching-related ones. + const headers = new Headers(); + responseToPutToCache.headers.forEach((value, key) => { + if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) { + headers.set(key, value); + } + }); + + // Ensure that CORS-related 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 ServiceWorker 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 full response body as a blob to avoid issues (e.g., Safari locked streams). + const bodyBlob = await responseToPutToCache.blob(); + const newCachedResponse = new Response(bodyBlob, { + status: responseToPutToCache.status, + statusText: responseToPutToCache.statusText, + headers + }); + + await cache.put(matchRequest, newCachedResponse); + logger.log('ok', `NOWCACHED: Cached response for ${matchRequest.url} for subsequent requests!`); + done.resolve(newResponse); + } catch (err) { + logger.log('error', `Error caching response for ${matchRequest.url}: ${err}`); + done.resolve(await create500Response(matchRequest, newResponse)); } - }); - - // Ensure CORS headers are present. - if (!headers.has('Access-Control-Allow-Origin')) { - headers.set('Access-Control-Allow-Origin', '*'); } - headers.set('Vary', 'Origin'); - if (!headers.has('Access-Control-Expose-Headers')) { - headers.set('Access-Control-Expose-Headers', '*') + } else { + // For requests not intended for caching, simply fetch from the origin. + logger.log('ok', `NOTCACHED: Not caching ${originalRequest.url}. Fetching from origin...`); + try { + const originResponse = await fetch(originalRequest); + done.resolve(originResponse); + } catch (err: any) { + logger.log('error', `Fetch error for ${originalRequest.url}: ${err}`); + done.resolve(await create500Response(originalRequest, new Response(err.message))); } - 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 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)); - }) - ); + } catch (err) { + logger.log('error', `Unhandled fetch event error: ${err}`); } }); - } + }; /** * Cleans all caches. - * Should only be run when a new service worker is activated. + * Should only be run when a new ServiceWorker is activated. */ - public cleanCaches = async (reasonArg = 'no reason given') => { - logger.log('info', `MAJOR CACHEEVENT: cleaning caches now! Reason: ${reasonArg}`); - const cacheNames = await caches.keys(); - - const deletePromises = cacheNames.map(cacheToDelete => { - const deletePromise = caches.delete(cacheToDelete); - deletePromise.then(() => { - logger.log('ok', `Deleted cache ${cacheToDelete}`); - }); - return deletePromise; - }); - await Promise.all(deletePromises); - } + public cleanCaches = async (reasonArg = 'no reason given'): Promise => { + try { + logger.log('info', `MAJOR CACHEEVENT: Cleaning caches now! Reason: ${reasonArg}`); + const cacheNames = await caches.keys(); + const deletePromises = cacheNames.map((cacheToDelete) => + caches.delete(cacheToDelete).then(() => { + logger.log('ok', `Deleted cache ${cacheToDelete}`); + }) + ); + await Promise.all(deletePromises); + } catch (err) { + logger.log('error', `Error cleaning caches: ${err}`); + } + }; /** - * Revalidates the runtime cache. + * Revalidates the runtime cache by fetching fresh responses and updating the cache. */ - 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); // Increased timeout for better mobile compatibility - if (response && response.status >= 200 && response.status < 300) { - await runtimeCache.delete(requestArg); - await runtimeCache.put(requestArg, response); + public async revalidateCache(): Promise { + try { + const runtimeCache = await caches.open(this.usedCacheNames.runtimeCacheName); + const cacheKeys = await runtimeCache.keys(); + for (const requestArg of cacheKeys) { + try { + const clonedRequest = requestArg.clone(); + 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); + } + } catch (err) { + logger.log('error', `Error revalidating cache for ${requestArg.url}: ${err}`); + } } + } catch (err) { + logger.log('error', `Error revalidating runtime cache: ${err}`); } } } \ No newline at end of file diff --git a/ts_web_serviceworker/classes.updatemanager.ts b/ts_web_serviceworker/classes.updatemanager.ts index 34195b1..b96aa56 100644 --- a/ts_web_serviceworker/classes.updatemanager.ts +++ b/ts_web_serviceworker/classes.updatemanager.ts @@ -104,14 +104,9 @@ export class UpdateManager { >('/sw-typedrequest', 'serviceworker_versionInfo'); // Use networkManager for the request with retries and timeout - const response = await this.serviceworkerRef.networkManager.makeRequest('/sw-typedrequest', { - timeoutMs: 5000, - retries: 2, - backoffMs: 1000 - }); - - const result = await response.json(); - return result; + const response = await getAppHashRequest.fire({}); + + return response; } catch (error) { logger.log('warn', `Failed to get version info from server: ${error.message}`); throw error;