diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts index 12f835c52f..3d2845192f 100644 --- a/web/src/service-worker/broadcast-channel.ts +++ b/web/src/service-worker/broadcast-channel.ts @@ -1,4 +1,4 @@ -import { cancelLoad, getCachedOrFetch } from './fetch-event'; +import { cancelRequest, handleRequest } from './request'; export const installBroadcastChannelListener = () => { const broadcast = new BroadcastChannel('immich'); @@ -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); } }; }; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts index 29d6073d5f..f91d8366ea 100644 --- a/web/src/service-worker/cache.ts +++ b/web/src/service-worker/cache.ts @@ -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; -} + _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; +export const get = async (key: string) => { + const cache = await getCache(); + if (!cache) { + return; + } -export async function deleteOldCaches() { + 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); } } -} - -const pendingRequests = new Map(); -const canceledRequests = new Set(); - -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; - } - - const urlString = getCacheKey(request); - const cancelToken = new AbortController(); - - 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) { - 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()); - } -} - -function checkResponse(response: Response) { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } -} - -export function getCacheKey(request: URL | Request | string) { - if (isURL(request)) { - return request.toString(); - } else if (isRequest(request)) { - return request.url; - } else { - return request; - } -} +}; diff --git a/web/src/service-worker/fetch-event.ts b/web/src/service-worker/fetch-event.ts deleted file mode 100644 index b88eb3f0e5..0000000000 --- a/web/src/service-worker/fetch-event.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { version } from '$service-worker'; -import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache'; - -const CACHE = `cache-${version}`; - -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; - -export async function deleteOldCaches() { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -} - -const pendingLoads = new Map(); - -export async function cancelLoad(urlString: string) { - const pending = pendingLoads.get(urlString); - if (pending) { - pending.abort(); - pendingLoads.delete(urlString); - } -} - -export async function getCachedOrFetch(request: URL | Request | string) { - const response = await checkCache(request); - if (response) { - return response; - } - - try { - return await fetchWithCancellation(request); - } catch { - return new Response(undefined, { - status: 499, - statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', - }); - } -} - -async function fetchWithCancellation(request: URL | Request | string) { - const cacheKey = getCacheKey(request); - const cancelToken = new AbortController(); - - try { - pendingLoads.set(cacheKey, cancelToken); - const response = await fetch(request, { - signal: cancelToken.signal, - }); - - checkResponse(response); - setCached(response, cacheKey); - return response; - } finally { - pendingLoads.delete(cacheKey); - } -} - -function checkResponse(response: Response) { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } -} - -function isIgnoredFileType(pathname: string): boolean { - return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname); -} - -function isIgnoredPath(pathname: string): boolean { - return ( - /^\/(src|api)(\/.*)?$/.test(pathname) || /node_modules/.test(pathname) || /^\/@(vite|id)(\/.*)?$/.test(pathname) - ); -} - -function isAssetRequest(pathname: string): boolean { - return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname); -} - -export function handleFetchEvent(event: FetchEvent): void { - if (event.request.method !== 'GET') { - return; - } - - const url = new URL(event.request.url); - - // Only handle requests to the same origin - if (url.origin !== self.location.origin) { - return; - } - - // Do not cache app resources - if (APP_RESOURCES.includes(url.pathname)) { - return; - } - - // Cache requests for thumbnails - if (isAssetRequest(url.pathname)) { - event.respondWith(getCachedOrFetch(event.request)); - return; - } - - // Do not cache ignored file types or paths - if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) { - return; - } - - // At this point, the only remaining requests for top level routes - // so serve the Svelte SPA fallback page - const slash = new URL('/', url.origin); - event.respondWith(getCachedOrFetch(slash)); -} diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 54ada639ec..28336aca6a 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -3,14 +3,16 @@ /// /// 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) => { @@ -18,7 +20,20 @@ const handleInstall = (event: ExtendableEvent) => { // 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(); diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts new file mode 100644 index 0000000000..10aed23d51 --- /dev/null +++ b/web/src/service-worker/request.ts @@ -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(); + +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); +};