mirror of
https://github.com/immich-app/immich.git
synced 2026-01-21 00:53:31 -08:00
Compare commits
2 Commits
feat/mobil
...
push-lmxsu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c629675c4e | ||
|
|
7daf395904 |
@@ -17,13 +17,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 { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { getAssetJobMessage, 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 {
|
||||
AssetJobName,
|
||||
@@ -134,9 +133,7 @@
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
if (stack && stack?.assets.length > 1) {
|
||||
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
|
||||
}
|
||||
preloadManager.preload(stack?.assets[1]);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
157
web/src/lib/utils/sw-messenger.ts
Normal file
157
web/src/lib/utils/sw-messenger.ts
Normal file
@@ -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<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;
|
||||
#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<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 {
|
||||
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<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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,9 +2,9 @@
|
||||
/// <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,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();
|
||||
|
||||
53
web/src/service-worker/messaging.ts
Normal file
53
web/src/service-worker/messaging.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,73 +1,68 @@
|
||||
import { get, put } from './cache';
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
|
||||
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<Response>;
|
||||
cleanupTimeout?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
|
||||
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<Response> => {
|
||||
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<Response>,
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user