mirror of
https://github.com/immich-app/immich.git
synced 2026-01-15 14:33:16 -08:00
feat: service worker improvements - drop web cache
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
143
web/src/lib/utils/sw-messenger.ts
Normal file
143
web/src/lib/utils/sw-messenger.ts
Normal file
@@ -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<typeof setTimeout>;
|
||||
ackReceived: boolean;
|
||||
}
|
||||
|
||||
export class ServiceWorkerMessenger {
|
||||
readonly #broadcast: BroadcastChannel;
|
||||
readonly #pendingRequests = new Map<string, PendingRequest>();
|
||||
readonly #ackTimeoutMs: number;
|
||||
#requestCounter = 0;
|
||||
#onTimeout?: (type: string, data: Record<string, unknown>) => 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<string, unknown>) => 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<T>(type: string, data: Record<string, unknown>, waitForResponse: boolean): Promise<T> {
|
||||
const requestId = `${type}-${++this.#requestCounter}-${Date.now()}`;
|
||||
|
||||
const promise = new Promise<T>((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<string, unknown>): Promise<void> {
|
||||
return this.#sendInternal<void>(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<T = void>(type: string, data: Record<string, unknown>): Promise<T> {
|
||||
return this.#sendInternal<T>(type, data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the broadcast channel
|
||||
*/
|
||||
close(): void {
|
||||
this.#broadcast.close();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,7 @@
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,73 +1,124 @@
|
||||
import { get, put } from './cache';
|
||||
type PendingRequest = {
|
||||
controller: AbortController;
|
||||
promise: Promise<Response>;
|
||||
canceled: boolean;
|
||||
canceledAt?: number; // Timestamp when cancellation occurred
|
||||
fetchStartedAt?: number; // Timestamp when fetch body)
|
||||
};
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
|
||||
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<Response> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user