From 8ccca04e27f7e543b4e2a362787ae0dee0ab0c54 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 1 Jul 2025 06:53:04 -0400 Subject: [PATCH] fix(web): improve request cancellation handling in service worker cache (#19217) --- web/src/service-worker/cache.ts | 104 ++++++++++++++++++++++++++ web/src/service-worker/fetch-event.ts | 74 +++++++++--------- web/src/service-worker/index.ts | 4 +- 3 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 web/src/service-worker/cache.ts diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts new file mode 100644 index 0000000000..29d6073d5f --- /dev/null +++ b/web/src/service-worker/cache.ts @@ -0,0 +1,104 @@ +import { build, files, version } from '$service-worker'; + +const useCache = true; +const CACHE = `cache-${version}`; + +export const APP_RESOURCES = [ + ...build, // the app itself + ...files, // everything in `static` +]; + +let cache: Cache | undefined; +export async function getCache() { + if (cache) { + return cache; + } + cache = await caches.open(CACHE); + return cache; +} + +export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; +export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; + +export async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) { + await caches.delete(key); + } + } +} + +const pendingRequests = new Map(); +const canceledRequests = new Set(); + +export async function cancelLoad(urlString: string) { + const pending = pendingRequests.get(urlString); + if (pending) { + canceledRequests.add(urlString); + pending.abort(); + pendingRequests.delete(urlString); + } +} + +export async function getCachedOrFetch(request: URL | Request | string) { + const response = await checkCache(request); + if (response) { + return response; + } + + const urlString = getCacheKey(request); + const cancelToken = new AbortController(); + + try { + pendingRequests.set(urlString, cancelToken); + const response = await fetch(request, { + signal: cancelToken.signal, + }); + + checkResponse(response); + await setCached(response, urlString); + return response; + } catch (error) { + if (canceledRequests.has(urlString)) { + canceledRequests.delete(urlString); + return new Response(undefined, { + status: 499, + statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', + }); + } + throw error; + } finally { + pendingRequests.delete(urlString); + } +} + +export async function checkCache(url: URL | Request | string) { + if (!useCache) { + return; + } + const cache = await getCache(); + return await cache.match(url); +} + +export async function setCached(response: Response, cacheKey: URL | Request | string) { + if (cache && response.status === 200) { + const cache = await getCache(); + cache.put(cacheKey, response.clone()); + } +} + +function checkResponse(response: Response) { + if (!(response instanceof Response)) { + throw new TypeError('Fetch did not return a valid Response object'); + } +} + +export function getCacheKey(request: URL | Request | string) { + if (isURL(request)) { + return request.toString(); + } else if (isRequest(request)) { + return request.url; + } else { + return request; + } +} diff --git a/web/src/service-worker/fetch-event.ts b/web/src/service-worker/fetch-event.ts index 11c8e0fd00..c23620abe3 100644 --- a/web/src/service-worker/fetch-event.ts +++ b/web/src/service-worker/fetch-event.ts @@ -1,6 +1,6 @@ import { version } from '$service-worker'; +import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache'; -const useCache = true; const CACHE = `cache-${version}`; export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; @@ -24,20 +24,14 @@ export async function cancelLoad(urlString: string) { } } -export async function getCachedOrFetch(request: URL | Request | string, cancelable: boolean = false) { - const cached = await checkCache(request); - if (cached.response) { - return cached.response; +export async function getCachedOrFetch(request: URL | Request | string) { + const response = await checkCache(request); + if (response) { + return response; } try { - if (!cancelable) { - const response = await fetch(request); - checkResponse(response); - return response; - } - - return await fetchWithCancellation(request, cached.cache); + return await fetchWithCancellation(request); } catch { return new Response(undefined, { status: 499, @@ -46,7 +40,7 @@ export async function getCachedOrFetch(request: URL | Request | string, cancelab } } -async function fetchWithCancellation(request: URL | Request | string, cache: Cache) { +async function fetchWithCancellation(request: URL | Request | string) { const cacheKey = getCacheKey(request); const cancelToken = new AbortController(); @@ -57,44 +51,26 @@ async function fetchWithCancellation(request: URL | Request | string, cache: Cac }); checkResponse(response); - setCached(response, cache, cacheKey); + setCached(response, cacheKey); return response; } finally { pendingLoads.delete(cacheKey); } } -async function checkCache(url: URL | Request | string) { - if (!useCache) { - return; - } - const cache = await caches.open(CACHE); - const response = await cache.match(url); - return { cache, response }; -} - -async function setCached(response: Response, cache: Cache, cacheKey: URL | Request | string) { - if (response.status === 200) { - cache.put(cacheKey, response.clone()); - } -} - function checkResponse(response: Response) { if (!(response instanceof Response)) { - throw new TypeError('invalid response from fetch'); + throw new TypeError('Fetch did not return a valid Response object'); } } -function getCacheKey(request: URL | Request | string) { - if (isURL(request)) { - return request.toString(); - } else if (isRequest(request)) { - return request.url; - } else { - return request; - } +function isIgnoredFileType(pathname: string): boolean { + return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname); } +function isIgnoredPath(pathname: string): boolean { + return /^\/(src|api)(\/.*)?$/.test(pathname) || /^\/(node_modules|@vite|@id)(\/.*)?$/.test(pathname); +} function isAssetRequest(pathname: string): boolean { return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname); } @@ -105,12 +81,30 @@ export function handleFetchEvent(event: FetchEvent): void { } const url = new URL(event.request.url); + + // Only handle requests to the same origin if (url.origin !== self.location.origin) { return; } - if (isAssetRequest(url.pathname)) { - event.respondWith(getCachedOrFetch(event.request, true)); + // Do not cache app resources + if (APP_RESOURCES.includes(url.pathname)) { return; } + + // Cache requests for thumbnails + if (isAssetRequest(url.pathname)) { + event.respondWith(getCachedOrFetch(event.request)); + return; + } + + // Do not cache ignored file types or paths + if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) { + return; + } + + // At this point, the only remaining requests for top level routes + // so serve the Svelte SPA fallback page + const slash = new URL('/', url.origin); + event.respondWith(getCachedOrFetch(slash)); } diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index fbb6f74d82..54ada639ec 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -3,7 +3,8 @@ /// /// import { installBroadcastChannelListener } from './broadcast-channel'; -import { deleteOldCaches, handleFetchEvent } from './fetch-event'; +import { deleteOldCaches } from './cache'; +import { handleFetchEvent } from './fetch-event'; const sw = globalThis as unknown as ServiceWorkerGlobalScope; @@ -14,6 +15,7 @@ const handleActivate = (event: ExtendableEvent) => { const handleInstall = (event: ExtendableEvent) => { event.waitUntil(sw.skipWaiting()); + // do not preload app resources }; sw.addEventListener('install', handleInstall, { passive: true });