From 81f592ca5214babf13ad87ce7bf3b4847f0140c6 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 12 Feb 2026 11:25:20 -0500 Subject: [PATCH] feat: remove Cache API, rework preload(), cancel() and fetch() (#25289) * feat: remove Cache API, rework preload(), cancel() and fetch() perf - replace broadcast channel with direct postMessage * remove sw response handling * review comments --- web/src/lib/utils/sw-messaging.ts | 16 ++- web/src/lib/utils/sw-messenger.ts | 17 +++ web/src/service-worker/broadcast-channel.ts | 25 ----- web/src/service-worker/cache.ts | 42 ------- web/src/service-worker/index.ts | 12 +- web/src/service-worker/messaging.ts | 33 ++++++ web/src/service-worker/request.ts | 118 ++++++++++---------- 7 files changed, 119 insertions(+), 144 deletions(-) create mode 100644 web/src/lib/utils/sw-messenger.ts delete mode 100644 web/src/service-worker/broadcast-channel.ts delete mode 100644 web/src/service-worker/cache.ts create mode 100644 web/src/service-worker/messaging.ts diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 61cd1b8df0..3c32bf7de1 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,14 +1,12 @@ -const broadcast = new BroadcastChannel('immich'); +import { ServiceWorkerMessenger } from './sw-messenger'; + +const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator; +// eslint-disable-next-line compat/compat +const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined; export function cancelImageUrl(url: string | undefined | null) { - if (!url) { + if (!url || !messenger) { return; } - broadcast.postMessage({ type: 'cancel', url }); -} -export function preloadImageUrl(url: string | undefined | null) { - if (!url) { - return; - } - broadcast.postMessage({ type: 'preload', url }); + messenger.send('cancel', { url }); } diff --git a/web/src/lib/utils/sw-messenger.ts b/web/src/lib/utils/sw-messenger.ts new file mode 100644 index 0000000000..b656f3fc2c --- /dev/null +++ b/web/src/lib/utils/sw-messenger.ts @@ -0,0 +1,17 @@ +export class ServiceWorkerMessenger { + readonly #serviceWorker: ServiceWorkerContainer; + + constructor(serviceWorker: ServiceWorkerContainer) { + this.#serviceWorker = serviceWorker; + } + + /** + * Send a one-way message to the service worker. + */ + send(type: string, data: Record) { + this.#serviceWorker.controller?.postMessage({ + type, + ...data, + }); + } +} diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts deleted file mode 100644 index ae6f1e1be6..0000000000 --- a/web/src/service-worker/broadcast-channel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { handleCancel, handlePreload } from './request'; - -export const installBroadcastChannelListener = () => { - const broadcast = new BroadcastChannel('immich'); - // eslint-disable-next-line unicorn/prefer-add-event-listener - broadcast.onmessage = (event) => { - if (!event.data) { - return; - } - - const url = new URL(event.data.url, event.origin); - - switch (event.data.type) { - case 'preload': { - handlePreload(url); - break; - } - - case 'cancel': { - handleCancel(url); - break; - } - } - }; -}; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts deleted file mode 100644 index f91d8366ea..0000000000 --- a/web/src/service-worker/cache.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { version } from '$service-worker'; - -const CACHE = `cache-${version}`; - -let _cache: Cache | undefined; -const getCache = async () => { - if (_cache) { - return _cache; - } - _cache = await caches.open(CACHE); - return _cache; -}; - -export const get = async (key: string) => { - const cache = await getCache(); - if (!cache) { - return; - } - - return cache.match(key); -}; - -export const put = async (key: string, response: Response) => { - if (response.status !== 200) { - return; - } - - const cache = await getCache(); - if (!cache) { - return; - } - - cache.put(key, response.clone()); -}; - -export const prune = async () => { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -}; diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 28336aca6a..377195b0c8 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -2,9 +2,9 @@ /// /// /// -import { installBroadcastChannelListener } from './broadcast-channel'; -import { prune } from './cache'; -import { handleRequest } from './request'; + +import { installMessageListener } from './messaging'; +import { handleFetch as handleAssetFetch } from './request'; const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/; @@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - event.waitUntil(prune()); }; const handleInstall = (event: ExtendableEvent) => { event.waitUntil(sw.skipWaiting()); - // do not preload app resources }; const handleFetch = (event: FetchEvent): void => { @@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => { // Cache requests for thumbnails const url = new URL(event.request.url); if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) { - event.respondWith(handleRequest(event.request)); + event.respondWith(handleAssetFetch(event.request)); return; } }; @@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => { sw.addEventListener('install', handleInstall, { passive: true }); sw.addEventListener('activate', handleActivate, { passive: true }); sw.addEventListener('fetch', handleFetch, { passive: true }); -installBroadcastChannelListener(); +installMessageListener(); diff --git a/web/src/service-worker/messaging.ts b/web/src/service-worker/messaging.ts new file mode 100644 index 0000000000..b60ff055a2 --- /dev/null +++ b/web/src/service-worker/messaging.ts @@ -0,0 +1,33 @@ +/// +/// +/// +/// + +import { handleCancel } from './request'; + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; + +export const installMessageListener = () => { + sw.addEventListener('message', (event) => { + if (!event.data?.type) { + return; + } + + switch (event.data.type) { + case 'cancel': { + const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined; + if (!url) { + return; + } + + const client = event.source; + if (!client) { + return; + } + + handleCancel(url); + break; + } + } + }); +}; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index aeb63be899..5fdf7f82c1 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,73 +1,69 @@ -import { get, put } from './cache'; +/// +/// +/// +/// -const pendingRequests = new Map(); - -const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; -const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; - -const assertResponse = (response: Response) => { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } +type PendingRequest = { + controller: AbortController; + promise: Promise; + cleanupTimeout?: ReturnType; }; -const getCacheKey = (request: URL | Request) => { - if (isURL(request)) { - return request.toString(); +const pendingRequests = new Map(); + +const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url); + +const CANCELATION_MESSAGE = 'Request canceled by application'; +const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export const handleFetch = (request: URL | Request): Promise => { + const requestKey = getRequestKey(request); + const existing = pendingRequests.get(requestKey); + + if (existing) { + // Clone the response since response bodies can only be read once + // Each caller gets an independent clone they can consume + return existing.promise.then((response) => response.clone()); } - if (isRequest(request)) { - return request.url; - } + const pendingRequest: PendingRequest = { + controller: new AbortController(), + promise: undefined as unknown as Promise, + cleanupTimeout: undefined, + }; + pendingRequests.set(requestKey, pendingRequest); - throw new Error(`Invalid request: ${request}`); -}; + // NOTE: fetch returns after headers received, not the body + pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal }) + .catch((error: unknown) => { + const standardError = error instanceof Error ? error : new Error(String(error)); + if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) { + // dummy response avoids network errors in the console for these requests + return new Response(undefined, { status: 204 }); + } + throw standardError; + }) + .finally(() => { + // Schedule cleanup after timeout to allow response body streaming to complete + const cleanupTimeout = setTimeout(() => { + pendingRequests.delete(requestKey); + }, CLEANUP_TIMEOUT_MS); + pendingRequest.cleanupTimeout = cleanupTimeout; + }); -export const handlePreload = async (request: URL | Request) => { - try { - return await handleRequest(request); - } catch (error) { - console.error(`Preload failed: ${error}`); - } -}; - -export const handleRequest = async (request: URL | Request) => { - const cacheKey = getCacheKey(request); - const cachedResponse = await get(cacheKey); - if (cachedResponse) { - return cachedResponse; - } - - try { - const cancelToken = new AbortController(); - pendingRequests.set(cacheKey, cancelToken); - const response = await fetch(request, { signal: cancelToken.signal }); - - assertResponse(response); - put(cacheKey, response); - - return response; - } catch (error) { - if (error.name === 'AbortError') { - // dummy response avoids network errors in the console for these requests - return new Response(undefined, { status: 204 }); - } - - console.log('Not an abort error', error); - - throw error; - } finally { - pendingRequests.delete(cacheKey); - } + // Clone for the first caller to keep the original response unconsumed for future callers + return pendingRequest.promise.then((response) => response.clone()); }; export const handleCancel = (url: URL) => { - const cacheKey = getCacheKey(url); - const pendingRequest = pendingRequests.get(cacheKey); - if (!pendingRequest) { - return; - } + const requestKey = getRequestKey(url); - pendingRequest.abort(); - pendingRequests.delete(cacheKey); + const pendingRequest = pendingRequests.get(requestKey); + if (pendingRequest) { + pendingRequest.controller.abort(CANCELATION_MESSAGE); + if (pendingRequest.cleanupTimeout) { + clearTimeout(pendingRequest.cleanupTimeout); + } + pendingRequests.delete(requestKey); + } };