From 3df9e255c6548348eccd8980fbfb46ee74f59a17 Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH] feat: remove Cache API, rework preload(), cancel() and fetch() perf - replace broadcast channel with direct postMessage --- .../asset-viewer/asset-viewer.svelte | 7 +- web/src/lib/managers/PreloadManager.svelte.ts | 6 +- web/src/lib/utils/sw-messaging.ts | 28 +++- web/src/lib/utils/sw-messenger.ts | 157 ++++++++++++++++++ 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 | 53 ++++++ web/src/service-worker/request.ts | 117 +++++++------ 9 files changed, 293 insertions(+), 154 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/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 9e3c121024..955a4934ae 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -19,13 +19,12 @@ import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; - import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; + import { getSharedLink, handlePromiseError } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { navigateToAsset } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; - import { preloadImageUrl } from '$lib/utils/sw-messaging'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetTypeEnum, @@ -133,9 +132,7 @@ } untrack(() => { - if (stack && stack?.assets.length > 1) { - preloadImageUrl(getAssetUrl({ asset: stack.assets[1] })); - } + preloadManager.preload(stack?.assets[1]); }); }; diff --git a/web/src/lib/managers/PreloadManager.svelte.ts b/web/src/lib/managers/PreloadManager.svelte.ts index a68c07d505..856ecdd44e 100644 --- a/web/src/lib/managers/PreloadManager.svelte.ts +++ b/web/src/lib/managers/PreloadManager.svelte.ts @@ -1,13 +1,9 @@ import { getAssetUrl } from '$lib/utils'; -import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; +import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; class PreloadManager { preload(asset: AssetResponseDto | undefined) { - if (globalThis.isSecureContext) { - preloadImageUrl(getAssetUrl({ asset })); - return; - } if (!asset || asset.type !== AssetTypeEnum.Image) { return; } diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 61cd1b8df0..f0fc93f50b 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,14 +1,24 @@ -const broadcast = new BroadcastChannel('immich'); +import { ServiceWorkerMessenger } from './sw-messenger'; + +const messenger = new ServiceWorkerMessenger(); + +let isServiceWorkerEnabled = true; + +messenger.onAckTimeout(() => { + if (!isServiceWorkerEnabled) { + return; + } + console.error('[ServiceWorker] No communication detected. Auto-disabled service worker.'); + isServiceWorkerEnabled = false; +}); + +const isValidSwContext = (url: string | undefined | null): url is string => { + return globalThis.isSecureContext && isServiceWorkerEnabled && !!url; +}; export function cancelImageUrl(url: string | undefined | null) { - if (!url) { + if (!isValidSwContext(url)) { return; } - broadcast.postMessage({ type: 'cancel', url }); -} -export function preloadImageUrl(url: string | undefined | null) { - if (!url) { - return; - } - broadcast.postMessage({ type: 'preload', url }); + void 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..749f834e9e --- /dev/null +++ b/web/src/lib/utils/sw-messenger.ts @@ -0,0 +1,157 @@ +/** + * Low-level protocol for communicating with the service worker via postMessage. + * + * Protocol: + * 1. Main thread sends request: { type: string, requestId: string, ...data } + * 2. SW sends ack: { type: 'ack', requestId: string } + * 3. SW sends response (optional): { type: 'response', requestId: string, result?: any, error?: string } + */ + +interface PendingRequest { + resolveAck: () => void; + resolveResponse?: (result: unknown) => void; + rejectResponse?: (error: Error) => void; + ackTimeout: ReturnType; + ackReceived: boolean; +} + +export class ServiceWorkerMessenger { + readonly #pendingRequests = new Map(); + readonly #ackTimeoutMs: number; + #requestCounter = 0; + #onTimeout?: (type: string, data: Record) => void; + #messageHandler?: (event: MessageEvent) => void; + + constructor(ackTimeoutMs = 5000) { + this.#ackTimeoutMs = ackTimeoutMs; + + // Listen for messages from the service worker + if ('serviceWorker' in navigator) { + this.#messageHandler = (event) => { + this.#handleMessage(event.data); + }; + navigator.serviceWorker.addEventListener('message', this.#messageHandler); + } + } + + #handleMessage(data: unknown) { + if (typeof data !== 'object' || data === null) { + return; + } + + const message = data as { requestId?: string; type?: string; error?: string; result?: unknown }; + const requestId = message.requestId; + if (!requestId) { + return; + } + + const pending = this.#pendingRequests.get(requestId); + if (!pending) { + return; + } + + if (message.type === 'ack') { + pending.ackReceived = true; + clearTimeout(pending.ackTimeout); + pending.resolveAck(); + return; + } + + if (message.type === 'response') { + clearTimeout(pending.ackTimeout); + this.#pendingRequests.delete(requestId); + + if (message.error) { + pending.rejectResponse?.(new Error(message.error)); + return; + } + + pending.resolveResponse?.(message.result); + } + } + + /** + * Set a callback to be invoked when an ack timeout occurs. + * This can be used to detect and disable faulty service workers. + */ + onAckTimeout(callback: (type: string, data: Record) => void): void { + this.#onTimeout = callback; + } + + /** + * Send a message to the service worker. + * - send(): waits for ack, resolves when acknowledged + * - request(): waits for response, throws on error/timeout + */ + #sendInternal(type: string, data: Record, waitForResponse: boolean): Promise { + const requestId = `${type}-${++this.#requestCounter}-${Date.now()}`; + + const promise = new Promise((resolve, reject) => { + const ackTimeout = setTimeout(() => { + const pending = this.#pendingRequests.get(requestId); + if (pending && !pending.ackReceived) { + this.#pendingRequests.delete(requestId); + console.warn(`[ServiceWorker] ${type} request not acknowledged:`, data); + this.#onTimeout?.(type, data); + // Only reject if we're waiting for a response + if (waitForResponse) { + reject(new Error(`Service worker did not acknowledge ${type} request`)); + } else { + resolve(undefined as T); + } + } + }, this.#ackTimeoutMs); + + this.#pendingRequests.set(requestId, { + resolveAck: waitForResponse ? () => {} : () => resolve(undefined as T), + resolveResponse: waitForResponse ? (result: unknown) => resolve(result as T) : undefined, + rejectResponse: waitForResponse ? reject : undefined, + ackTimeout, + ackReceived: false, + }); + + // Send message to the active service worker + // Feature detection is done in constructor and at call sites (sw-messaging.ts:isValidSwContext) + // eslint-disable-next-line compat/compat + navigator.serviceWorker.controller?.postMessage({ + type, + requestId, + ...data, + }); + }); + + return promise; + } + + /** + * Send a one-way message to the service worker. + * Returns a promise that resolves after the service worker acknowledges receipt. + * Resolves even if no ack is received within the timeout period. + */ + send(type: string, data: Record): Promise { + return this.#sendInternal(type, data, false); + } + + /** + * Send a request and wait for ack + response. + * Returns a promise that resolves with the response data or rejects on error/timeout. + */ + request(type: string, data: Record): Promise { + return this.#sendInternal(type, data, true); + } + + /** + * Clean up pending requests and remove event listener + */ + close(): void { + for (const pending of this.#pendingRequests.values()) { + clearTimeout(pending.ackTimeout); + } + this.#pendingRequests.clear(); + + if (this.#messageHandler && 'serviceWorker' in navigator) { + navigator.serviceWorker.removeEventListener('message', this.#messageHandler); + this.#messageHandler = undefined; + } + } +} 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..2dd2d51f72 --- /dev/null +++ b/web/src/service-worker/messaging.ts @@ -0,0 +1,53 @@ +/// +/// +/// +/// + +import { handleCancel } from './request'; + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; + +/** + * Send acknowledgment for a request + */ +function sendAck(client: Client, requestId: string) { + client.postMessage({ + type: 'ack', + requestId, + }); +} + +/** + * Handle 'cancel' request: cancel a pending request + */ +const handleCancelRequest = (client: Client, url: URL, requestId: string) => { + sendAck(client, requestId); + handleCancel(url); +}; + +export const installMessageListener = () => { + sw.addEventListener('message', (event) => { + if (!event.data?.requestId || !event.data?.type) { + return; + } + + const requestId = event.data.requestId; + + 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; + } + + handleCancelRequest(client, url, requestId); + break; + } + } + }); +}; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index aeb63be899..1060cd4b6c 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,73 +1,68 @@ -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, + }; + 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); + } };