Compare commits

...

4 Commits

Author SHA1 Message Date
midzelis
7ecf7ad66a perf - replace broadcast channel with direct postMessage 2026-01-16 05:13:27 +00:00
midzelis
5f34d4bab3 Backward compat changes only 2026-01-16 05:13:27 +00:00
midzelis
70df21277e perf - replace broadcast channel with direct postMessage 2026-01-16 05:13:27 +00:00
midzelis
a476c025c9 feat: service worker improvements - drop web cache 2026-01-15 21:37:25 +00:00
10 changed files with 378 additions and 142 deletions

View File

@@ -23,7 +23,7 @@
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 { prepareImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -135,7 +135,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
void prepareImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
});
};
@@ -385,8 +385,8 @@
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
void preloadManager.preload(cursor.nextAsset);
void preloadManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {

View File

@@ -1,21 +1,22 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { cancelImageUrl, prepareImageUrl } from '$lib/utils/sw-messaging';
import { type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined) {
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
async preload(asset: AssetResponseDto | undefined) {
if (!asset) {
return;
}
if (!asset || asset.type !== AssetTypeEnum.Image) {
return;
}
const img = new Image();
const url = getAssetUrl({ asset });
if (!url) {
return;
}
// Prepare the URL with the service worker for cancellation tracking
await prepareImageUrl(url);
// Create an img element to trigger browser fetch (kept in memory, not added to DOM)
const img = new Image();
img.src = url;
}

View File

@@ -31,6 +31,15 @@ import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, md
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
export const ImageKinds = {
thumbnail: true,
preview: true,
fullsize: true,
original: true,
} as const;
export type ImageKind = keyof typeof ImageKinds;
interface DownloadRequestOptions<T = unknown> {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
url: string;
@@ -195,6 +204,23 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
export const getAssetUrlForKind = (asset: AssetResponseDto, kind: ImageKind) => {
switch (kind) {
case 'preview': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey: asset.thumbhash });
}
case 'thumbnail': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash });
}
case 'fullsize': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash });
}
case 'original': {
return getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
}
}
};
export const getAssetUrl = ({
asset,
sharedLink,

View File

@@ -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 });
}

View File

@@ -0,0 +1,150 @@
/**
* 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<typeof setTimeout>;
ackReceived: boolean;
}
export class ServiceWorkerMessenger {
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.#ackTimeoutMs = ackTimeoutMs;
// Listen for messages from the service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.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,
});
// 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.
* 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);
}
/**
* Clean up pending requests
*/
close(): void {
for (const pending of this.#pendingRequests.values()) {
clearTimeout(pending.ackTimeout);
}
this.#pendingRequests.clear();
}
}

View File

@@ -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;
}
}
};
};

View File

@@ -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);
}
}
};

View File

@@ -2,9 +2,8 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
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,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;
}
};
@@ -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();

View File

@@ -0,0 +1,60 @@
import { handleCancel, handlePrepare } 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 'prepare' request: prepare SW to track this request for cancelation
*/
const handlePrepareRequest = (client: Client, url: URL, requestId: string) => {
sendAck(client, requestId);
handlePrepare(url);
};
/**
* 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) {
return;
}
const requestId = event.data.requestId;
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
if (!url) {
return;
}
const client = event.source as Client;
if (!client) {
return;
}
switch (event.data.type) {
case 'prepare': {
handlePrepareRequest(client, url, requestId);
break;
}
case 'cancel': {
handleCancelRequest(client, url, requestId);
break;
}
}
});
};

View File

@@ -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);
}
};