Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions web/src/service-worker/broadcast-channel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cancelLoad, getCachedOrFetch } from './fetch-event';
import { cancelRequest, handleRequest } from './request';

export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
Expand All @@ -7,12 +7,12 @@ export const installBroadcastChannelListener = () => {
if (!event.data) {
return;
}
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
const urlString = event.data.url;
const url = new URL(urlString, event.origin);
if (event.data.type === 'cancel') {
cancelLoad(url.toString());
cancelRequest(url);
} else if (event.data.type === 'preload') {
getCachedOrFetch(url);
handleRequest(url);
}
};
};
116 changes: 27 additions & 89 deletions web/src/service-worker/cache.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,42 @@
import { build, files, version } from '$service-worker';
import { version } from '$service-worker';

const useCache = true;
const CACHE = `cache-${version}`;

export const APP_RESOURCES = [
...build, // the app itself
...files, // everything in `static`
];

let cache: Cache | undefined;
export async function getCache() {
if (cache) {
return cache;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
cache = await caches.open(CACHE);
return cache;
}

export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
_cache = await caches.open(CACHE);
return _cache;
};

export async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}

const pendingRequests = new Map<string, AbortController>();
const canceledRequests = new Set<string>();

export async function cancelLoad(urlString: string) {
const pending = pendingRequests.get(urlString);
if (pending) {
canceledRequests.add(urlString);
pending.abort();
pendingRequests.delete(urlString);
}
}

export async function getCachedOrFetch(request: URL | Request | string) {
const response = await checkCache(request);
if (response) {
return response;
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}

const urlString = getCacheKey(request);
const cancelToken = new AbortController();
return cache.match(key);
};

try {
pendingRequests.set(urlString, cancelToken);
const response = await fetch(request, {
signal: cancelToken.signal,
});

checkResponse(response);
await setCached(response, urlString);
return response;
} catch (error) {
if (canceledRequests.has(urlString)) {
canceledRequests.delete(urlString);
return new Response(undefined, {
status: 499,
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
});
}
throw error;
} finally {
pendingRequests.delete(urlString);
}
}

export async function checkCache(url: URL | Request | string) {
if (!useCache) {
export const put = async (key: string, response: Response) => {
if (response.status !== 200) {
return;
}
const cache = await getCache();
return await cache.match(url);
}

export async function setCached(response: Response, cacheKey: URL | Request | string) {
if (cache && response.status === 200) {
const cache = await getCache();
cache.put(cacheKey, response.clone());
const cache = await getCache();
if (!cache) {
return;
}
}

function checkResponse(response: Response) {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
}
cache.put(key, response.clone());
};

export function getCacheKey(request: URL | Request | string) {
if (isURL(request)) {
return request.toString();
} else if (isRequest(request)) {
return request.url;
} else {
return request;
export const prune = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
};
113 changes: 0 additions & 113 deletions web/src/service-worker/fetch-event.ts

This file was deleted.

23 changes: 19 additions & 4 deletions web/src/service-worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,37 @@
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { deleteOldCaches } from './cache';
import { handleFetchEvent } from './fetch-event';
import { prune } from './cache';
import { handleRequest } from './request';

const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;

const sw = globalThis as unknown as ServiceWorkerGlobalScope;

const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(deleteOldCaches());
event.waitUntil(prune());
};

const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(sw.skipWaiting());
// do not preload app resources
};

const handleFetch = (event: FetchEvent): void => {
if (event.request.method !== 'GET') {
return;
}

// 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));
return;
}
};

sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetchEvent, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();
63 changes: 63 additions & 0 deletions web/src/service-worker/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { get, put } from './cache';

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 getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
}

if (isRequest(request)) {
return request.url;
}

throw new Error(`Invalid request: ${request}`);
};

const pendingRequests = new Map<string, AbortController>();

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) {
console.log(error);
return new Response(undefined, {
status: 499,
statusText: `Request canceled: Instructions unclear, accidentally interrupted myself (${error})`,
});
} finally {
pendingRequests.delete(cacheKey);
}
};

export const cancelRequest = (url: URL) => {
const cacheKey = getCacheKey(url);
const pending = pendingRequests.get(cacheKey);
if (!pending) {
return;
}

pending.abort();
pendingRequests.delete(cacheKey);
};