mirror of
https://github.com/immich-app/immich.git
synced 2026-01-21 17:13:17 -08:00
Compare commits
2 Commits
push-lmxsu
...
chore-cont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9aac2a4d6 | ||
|
|
cd286717a2 |
@@ -19,12 +19,13 @@
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { getAssetUrl, 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,
|
||||
@@ -132,7 +133,9 @@
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
preloadManager.preload(stack?.assets[1]);
|
||||
if (stack && stack?.assets.length > 1) {
|
||||
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { cancelImageUrl, preloadImageUrl } 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;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type UpdateLibraryDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiInformationOutline, mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
|
||||
@@ -45,13 +45,6 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
};
|
||||
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Detail: ActionItem = {
|
||||
icon: mdiInformationOutline,
|
||||
type: $t('command'),
|
||||
title: $t('details'),
|
||||
onAction: () => goto(Route.viewLibrary(library)),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
@@ -91,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
return { Detail, Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
};
|
||||
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiDeleteRestore,
|
||||
mdiInformationOutline,
|
||||
mdiLockReset,
|
||||
mdiLockSmart,
|
||||
mdiPencilOutline,
|
||||
@@ -46,6 +47,12 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
||||
};
|
||||
|
||||
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
|
||||
const Detail: ActionItem = {
|
||||
icon: mdiInformationOutline,
|
||||
title: $t('details'),
|
||||
onAction: () => goto(Route.viewUser(user)),
|
||||
};
|
||||
|
||||
const Update: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
title: $t('edit'),
|
||||
@@ -92,7 +99,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
onAction: () => handleResetPinCodeUserAdmin(user),
|
||||
};
|
||||
|
||||
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
|
||||
return { Detail, Update, Delete, Restore, ResetPassword, ResetPinCode };
|
||||
};
|
||||
|
||||
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
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;
|
||||
};
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
|
||||
export function cancelImageUrl(url: string | undefined | null) {
|
||||
if (!isValidSwContext(url)) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
void messenger.send('cancel', { url });
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
}
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,14 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
||||
import { getLibrariesActions } from '$lib/services/library.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
Link,
|
||||
MenuItemType,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -60,18 +58,13 @@
|
||||
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
|
||||
const getActionsForLibrary = (library: LibraryResponseDto) => {
|
||||
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
|
||||
return [Detail, Scan, Edit, MenuItemType.Divider, Delete];
|
||||
};
|
||||
|
||||
const classes = {
|
||||
column1: 'w-4/12',
|
||||
column2: 'w-4/12',
|
||||
column3: 'w-1/12',
|
||||
column4: 'w-1/12',
|
||||
column5: 'w-1/12',
|
||||
column6: 'w-1/12 flex justify-end',
|
||||
column3: 'w-2/12',
|
||||
column4: 'w-2/12',
|
||||
column5: 'w-2/12',
|
||||
column6: 'w-2/12',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -96,19 +89,14 @@
|
||||
{#each libraries as library (library.id + library.name)}
|
||||
{@const { photos, usage, videos } = statistics[library.id]}
|
||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
||||
{@const owner = owners[library.id]}
|
||||
<TableRow>
|
||||
<TableCell class={classes.column1}>
|
||||
<Link href={Route.viewLibrary(library)}>{library.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column2}>
|
||||
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column1}>{library.name}</TableCell>
|
||||
<TableCell class={classes.column2}>{owners[library.id].name}</TableCell>
|
||||
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
||||
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
||||
<TableCell class={classes.column6}>
|
||||
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
||||
<Button size="small" href={Route.viewLibrary(library)}>{$t('view')}</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { Route } from '$lib/route';
|
||||
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
Icon,
|
||||
Link,
|
||||
MenuItemType,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -46,11 +49,16 @@
|
||||
|
||||
const { Create } = $derived(getUserAdminsActions($t));
|
||||
|
||||
const getActionsForUser = (user: UserAdminResponseDto) => {
|
||||
const { Detail, Update, Delete, ResetPassword, ResetPinCode } = getUserAdminActions($t, user);
|
||||
return [Detail, Update, ResetPassword, ResetPinCode, MenuItemType.Divider, Delete];
|
||||
};
|
||||
|
||||
const classes = {
|
||||
column1: 'w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12',
|
||||
column2: 'hidden sm:block w-3/12',
|
||||
column3: 'hidden xl:block w-3/12 2xl:w-2/12',
|
||||
column4: 'w-4/12 lg:w-3/12 xl:w-2/12',
|
||||
column1: 'w-8/12 md:w-5/12 lg:w-4/12',
|
||||
column2: 'hidden md:block md:w-5/12 lg:w-4/12',
|
||||
column3: 'hidden lg:block lg:w-2/12',
|
||||
column4: 'w-4/12 md:w-2/12 flex justify-end',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -68,16 +76,18 @@
|
||||
<Container center size="large">
|
||||
<Table class="mt-4" striped spacing="small" size="small">
|
||||
<TableHeader>
|
||||
<TableHeading class={classes.column1}>{$t('email')}</TableHeading>
|
||||
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>
|
||||
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
|
||||
<TableHeading class={classes.column2}>{$t('email')}</TableHeading>
|
||||
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{#each users as user (user.id)}
|
||||
<TableRow color={user.deletedAt ? 'danger' : undefined}>
|
||||
<TableCell class={classes.column1}>{user.email}</TableCell>
|
||||
<TableCell class={classes.column2}>{user.name}</TableCell>
|
||||
<TableCell class={classes.column1}>
|
||||
<Link href={Route.viewUser(user)}>{user.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column2}>{user.email}</TableCell>
|
||||
<TableCell class={classes.column3}>
|
||||
<div class="container mx-auto flex flex-wrap justify-center">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
@@ -88,7 +98,7 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class={classes.column4}>
|
||||
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
||||
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForUser(user)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
|
||||
@@ -198,8 +198,8 @@
|
||||
})}
|
||||
>
|
||||
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
||||
<div class="mt-4 h-1.75 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-1.75 rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
25
web/src/service-worker/broadcast-channel.ts
Normal file
25
web/src/service-worker/broadcast-channel.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
42
web/src/service-worker/cache.ts
Normal file
42
web/src/service-worker/cache.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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 { installMessageListener } from './messaging';
|
||||
import { handleFetch as handleAssetFetch } from './request';
|
||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||
import { prune } from './cache';
|
||||
import { handleRequest } from './request';
|
||||
|
||||
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||
|
||||
@@ -12,10 +12,12 @@ 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 => {
|
||||
@@ -26,7 +28,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(handleAssetFetch(event.request));
|
||||
event.respondWith(handleRequest(event.request));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -34,4 +36,4 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
sw.addEventListener('install', handleInstall, { passive: true });
|
||||
sw.addEventListener('activate', handleActivate, { passive: true });
|
||||
sw.addEventListener('fetch', handleFetch, { passive: true });
|
||||
installMessageListener();
|
||||
installBroadcastChannelListener();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/// <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,68 +1,73 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { get, put } from './cache';
|
||||
|
||||
type PendingRequest = {
|
||||
controller: AbortController;
|
||||
promise: Promise<Response>;
|
||||
cleanupTimeout?: ReturnType<typeof setTimeout>;
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
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());
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
}
|
||||
|
||||
const pendingRequest: PendingRequest = {
|
||||
controller: new AbortController(),
|
||||
promise: undefined as unknown as Promise<Response>,
|
||||
};
|
||||
pendingRequests.set(requestKey, pendingRequest);
|
||||
if (isRequest(request)) {
|
||||
return request.url;
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
throw new Error(`Invalid request: ${request}`);
|
||||
};
|
||||
|
||||
// Clone for the first caller to keep the original response unconsumed for future callers
|
||||
return pendingRequest.promise.then((response) => response.clone());
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const requestKey = getRequestKey(url);
|
||||
|
||||
const pendingRequest = pendingRequests.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
pendingRequest.controller.abort(CANCELATION_MESSAGE);
|
||||
if (pendingRequest.cleanupTimeout) {
|
||||
clearTimeout(pendingRequest.cleanupTimeout);
|
||||
}
|
||||
pendingRequests.delete(requestKey);
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user