From a476c025c95f6a7ca01d3210716ef853a2c8ec7f Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH] feat: service worker improvements - drop web cache --- web/src/lib/utils/sw-messaging.ts | 29 +++- web/src/lib/utils/sw-messenger.ts | 143 ++++++++++++++++++ web/src/service-worker/broadcast-channel.ts | 42 +++++- web/src/service-worker/cache.ts | 42 ------ web/src/service-worker/index.ts | 6 +- web/src/service-worker/request.ts | 151 +++++++++++++------- 6 files changed, 305 insertions(+), 108 deletions(-) create mode 100644 web/src/lib/utils/sw-messenger.ts delete mode 100644 web/src/service-worker/cache.ts diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 61cd1b8df0..d46ee6cd69 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,14 +1,31 @@ -const broadcast = new BroadcastChannel('immich'); +import { ServiceWorkerMessenger } from './sw-messenger'; + +const messenger = new ServiceWorkerMessenger('immich'); + +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 }); + void messenger.send('cancel', { url }); } -export function preloadImageUrl(url: string | undefined | null) { - if (!url) { + +export async function prepareImageUrl(url: string | undefined | null) { + if (!isValidSwContext(url)) { return; } - broadcast.postMessage({ type: 'preload', url }); + await messenger.send('prepare', { 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..5b6f2566da --- /dev/null +++ b/web/src/lib/utils/sw-messenger.ts @@ -0,0 +1,143 @@ +/** + * Low-level protocol for communicating with the service worker via BroadcastChannel. + * + * 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 #broadcast: BroadcastChannel; + readonly #pendingRequests = new Map(); + readonly #ackTimeoutMs: number; + #requestCounter = 0; + #onTimeout?: (type: string, data: Record) => void; + + constructor(channelName: string, ackTimeoutMs = 5000) { + this.#broadcast = new BroadcastChannel(channelName); + this.#ackTimeoutMs = ackTimeoutMs; + + this.#broadcast.addEventListener('message', (event) => { + this.#handleMessage(event.data); + }); + } + + #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 { + pending.resolveAck(); + } + } + }, 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, + }); + + this.#broadcast.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. + * Rejects 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); + } + + /** + * Close the broadcast channel + */ + close(): void { + this.#broadcast.close(); + } +} diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts index ae6f1e1be6..a25f0d146e 100644 --- a/web/src/service-worker/broadcast-channel.ts +++ b/web/src/service-worker/broadcast-channel.ts @@ -1,23 +1,53 @@ -import { handleCancel, handlePreload } from './request'; +import { handleCancel, handlePrepare } from './request'; + +/** + * Send acknowledgment for a request + */ +function sendAck(broadcast: BroadcastChannel, requestId: string) { + broadcast.postMessage({ + type: 'ack', + requestId, + }); +} + +/** + * Handle 'prepare' request: prepare SW to track this request for cancelation + */ +const handlePrepareRequest = (broadcast: BroadcastChannel, url: URL, requestId: string) => { + sendAck(broadcast, requestId); + handlePrepare(url); +}; + +/** + * Handle 'cancel' request: cancel a pending request + */ +const handleCancelRequest = (broadcast: BroadcastChannel, url: URL, requestId: string) => { + sendAck(broadcast, requestId); + handleCancel(url); +}; export const installBroadcastChannelListener = () => { const broadcast = new BroadcastChannel('immich'); // eslint-disable-next-line unicorn/prefer-add-event-listener broadcast.onmessage = (event) => { - if (!event.data) { + if (!event.data?.requestId) { return; } - const url = new URL(event.data.url, event.origin); + const requestId = event.data.requestId; + const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined; + if (!url) { + return; + } switch (event.data.type) { - case 'preload': { - handlePreload(url); + case 'prepare': { + handlePrepareRequest(broadcast, url, requestId); break; } case 'cancel': { - handleCancel(url); + handleCancelRequest(broadcast, url, requestId); 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..fcb2db2b73 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -3,8 +3,7 @@ /// /// import { installBroadcastChannelListener } from './broadcast-channel'; -import { prune } from './cache'; -import { handleRequest } from './request'; +import { handleFetch as handleAssetFetch } from './request'; const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/; @@ -12,7 +11,6 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - event.waitUntil(prune()); }; const handleInstall = (event: ExtendableEvent) => { @@ -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; } }; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index aeb63be899..ca3607bf74 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,73 +1,124 @@ -import { get, put } from './cache'; +type PendingRequest = { + controller: AbortController; + promise: Promise; + canceled: boolean; + canceledAt?: number; // Timestamp when cancellation occurred + fetchStartedAt?: number; // Timestamp when fetch body) +}; -const pendingRequests = new Map(); +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 getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url); -const assertResponse = (response: Response) => { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); +const CANCELED_MESSAGE = 'Canceled - this is normal'; + +/** + * Clean up old requests after a timeout + */ +const CANCELATION_EXPIRED_TIMEOUT_MS = 60_000; +const FETCH_EXPIRED_TIMEOUT_MS = 60_000; + +const cleanupOldRequests = () => { + const now = Date.now(); + const keysToDelete: string[] = []; + + for (const [key, request] of pendingRequests.entries()) { + if (request.canceled && request.canceledAt) { + const age = now - request.canceledAt; + if (age > CANCELATION_EXPIRED_TIMEOUT_MS) { + keysToDelete.push(key); + } + continue; + } + + // Clean up completed requests after 5s (allows time for potential cancellations) + if (request.fetchStartedAt) { + const age = now - request.fetchStartedAt; + if (age > FETCH_EXPIRED_TIMEOUT_MS) { + keysToDelete.push(key); + } + } + } + + for (const key of keysToDelete) { + pendingRequests.delete(key); } }; -const getCacheKey = (request: URL | Request) => { - if (isURL(request)) { - return request.toString(); - } - - if (isRequest(request)) { - return request.url; - } - - throw new Error(`Invalid request: ${request}`); +/** + * Get existing request and cleanup old requests + */ +const getExisting = (requestKey: string): PendingRequest | undefined => { + cleanupOldRequests(); + return pendingRequests.get(requestKey); }; -export const handlePreload = async (request: URL | Request) => { - try { - return await handleRequest(request); - } catch (error) { - console.error(`Preload failed: ${error}`); +// Mark this URL as prepared - actual fetch will happen when handleFetch is called +export const handlePrepare = async (request: URL | Request) => { + const requestKey = getRequestKey(request); + const existing = getExisting(requestKey); + + if (existing?.canceled) { + // Prepare overrides cancel - reset the canceled request + pendingRequests.delete(requestKey); } }; -export const handleRequest = async (request: URL | Request) => { - const cacheKey = getCacheKey(request); - const cachedResponse = await get(cacheKey); - if (cachedResponse) { - return cachedResponse; +export const handleFetch = (request: URL | Request): Promise => { + const requestKey = getRequestKey(request); + const existing = getExisting(requestKey); + + if (existing) { + if (existing.canceled) { + return Promise.resolve(new Response(undefined, { status: 204 })); + } + // Clone the response from the shared promise to avoid "Response is disturbed or locked" errors + return existing.promise.then((response) => response.clone()); } - 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') { + // No existing request, create a new one + const controller = new AbortController(); + const promise = fetch(request, { signal: controller.signal }).catch((error: unknown) => { + const standardError = error instanceof Error ? error : new Error(String(error)); + if (standardError.name === 'AbortError' || standardError.message === CANCELED_MESSAGE) { // dummy response avoids network errors in the console for these requests return new Response(undefined, { status: 204 }); } + throw standardError; + }); - console.log('Not an abort error', error); + pendingRequests.set(requestKey, { + controller, + promise, + canceled: false, + fetchStartedAt: Date.now(), + }); - throw error; - } finally { - pendingRequests.delete(cacheKey); - } + // Clone for the first caller, so the promise retains the unconsumed original response for future callers + return 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) { + // Mark existing request as canceled with timestamp + pendingRequest.canceled = true; + pendingRequest.canceledAt = Date.now(); + pendingRequest.controller.abort(CANCELED_MESSAGE); + } else { + // No pending request - create a pre-canceled placeholder + const controller = new AbortController(); + controller.abort(CANCELED_MESSAGE); + + const preCanceledRequest: PendingRequest = { + controller, + promise: Promise.resolve(new Response(undefined, { status: 204 })), + canceled: true, + canceledAt: Date.now(), + }; + + pendingRequests.set(requestKey, preCanceledRequest); + } };