diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts new file mode 100644 index 0000000000..e1a30b203b --- /dev/null +++ b/web/src/service-worker/broadcast-channel.ts @@ -0,0 +1,18 @@ +import { cancelLoad, getCachedOrFetch } from './cache'; + +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 urlstring = event.data.url; + const url = new URL(urlstring, event.origin); + if (event.data.type === 'cancel') { + cancelLoad(url.toString()); + } else if (event.data.type === 'preload') { + getCachedOrFetch(url); + } + }; +}; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts new file mode 100644 index 0000000000..64359c6c68 --- /dev/null +++ b/web/src/service-worker/cache.ts @@ -0,0 +1,106 @@ +import { build, files, version } from '$service-worker'; + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; +const pendingLoads = new Map<string, AbortController>(); + +const useCache = true; +// Create a unique cache name for this deployment +const CACHE = `cache-${version}`; + +export const APP_RESOURCES = [ + ...build, // the app itself + ...files, // everything in `static` +]; + +export async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) { + await caches.delete(key); + } + } +} + +export async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(APP_RESOURCES); +} + +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 getCachedOrFetch(request: URL | Request | string, cancelable: boolean = false) { + const cached = await checkCache(request); + if (cached.response) { + return cached.response; + } + + try { + if (!cancelable) { + const response = await fetch(request); + checkResponse(response); + return response; + } + + return await fetchWithCancellation(request, cached.cache); + } catch { + console.log('getCachedOrFetch error', request); + return new Response(undefined, { + status: 499, + statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', + }); + } +} + +export async function cancelLoad(urlString: string) { + const pending = pendingLoads.get(urlString); + if (pending) { + pending.abort(); + pendingLoads.delete(urlString); + } +} + +async function fetchWithCancellation(request: URL | Request | string, cache: Cache) { + 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, cache, cacheKey); + return response; + } finally { + pendingLoads.delete(cacheKey); + } +} + +async function checkCache(url: URL | Request | string) { + const cache = await caches.open(CACHE); + const response = useCache ? await cache.match(url) : undefined; + return { cache, response }; +} + +async function setCached(response: Response, cache: Cache, cacheKey: URL | Request | string) { + if (response.status === 200) { + cache.put(cacheKey, response.clone()); + } +} + +function checkResponse(response: Response) { + if (!(response instanceof Response)) { + throw new TypeError('invalid response from fetch'); + } +} + +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/fetchEvent.ts b/web/src/service-worker/fetchEvent.ts new file mode 100644 index 0000000000..498fec8d43 --- /dev/null +++ b/web/src/service-worker/fetchEvent.ts @@ -0,0 +1,38 @@ +import { APP_RESOURCES, getCachedOrFetch } from './cache'; + +function isAssetRequest(pathname: string): boolean { + return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname); +} + +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|@vite|@id)(\/.*)?$/.test(pathname); +} + +export function handleFetchEvent(event: FetchEvent): void { + if (event.request.method !== 'GET') { + return; + } + + const url = new URL(event.request.url); + + if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) { + return; + } + + if (APP_RESOURCES.includes(url.pathname)) { + event.respondWith(getCachedOrFetch(event.request)); + return; + } + + if (isAssetRequest(url.pathname)) { + event.respondWith(getCachedOrFetch(event.request, true)); + return; + } + + 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 17ed9c5c67..c1b67ff050 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -2,148 +2,25 @@ /// <reference no-default-lib="true"/> /// <reference lib="esnext" /> /// <reference lib="webworker" /> -import { build, files, version } from '$service-worker'; +import { installBroadcastChannelListener } from './broadcast-channel'; +import { addFilesToCache, deleteOldCaches } from './cache'; +import { handleFetchEvent } from './fetchEvent'; -const useCache = true; const sw = globalThis as unknown as ServiceWorkerGlobalScope; -const pendingLoads = new Map<string, AbortController>(); -// Create a unique cache name for this deployment -const CACHE = `cache-${version}`; - -const APP_RESOURCES = [ - ...build, // the app itself - ...files, // everything in `static` -]; - -sw.addEventListener('install', (event) => { - event.waitUntil(sw.skipWaiting()); - // Create a new cache and add all files to it - event.waitUntil(addFilesToCache()); -}); - -sw.addEventListener('activate', (event) => { +const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); // Remove previous cached data from disk event.waitUntil(deleteOldCaches()); -}); - -sw.addEventListener('fetch', (event) => { - if (event.request.method !== 'GET') { - return; - } - const url = new URL(event.request.url); - if (APP_RESOURCES.includes(url.pathname)) { - event.respondWith(cacheOrFetch(event.request)); - return; - } else if (/^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(url.pathname)) { - event.respondWith(cacheOrFetch(event.request, true)); - return; - } else if (/\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(url.pathname)) { - return; - } else if (/^\/(src|api)(\/.*)?$/.test(url.pathname)) { - return; - } else if (/^\/(node_modules|@vite|@id)(\/.*)?$/.test(url.pathname)) { - return; - } - const slash = new URL('/', new URL(event.request.url).origin); - event.respondWith(cacheOrFetch(slash)); -}); - -async function deleteOldCaches() { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -} - -async function addFilesToCache() { - const cache = await caches.open(CACHE); - await cache.addAll(APP_RESOURCES); -} - -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; - -async function cacheOrFetch(request: URL | Request | string, cancelable: boolean = false) { - const cached = await checkCache(request); - if (cached.response) { - return cached.response; - } - try { - if (cancelable) { - const cacheKey = getCacheKey(request); - try { - const cancelToken = new AbortController(); - pendingLoads.set(cacheKey, cancelToken); - const response = await fetch(request, { - signal: cancelToken.signal, - }); - checkResponse(response); - setCached(response, cached.cache, cacheKey); - return response; - } finally { - if (cacheKey !== undefined) { - pendingLoads.delete(cacheKey); - } - } - } else { - const response = await fetch(request); - checkResponse(response); - return response; - } - } catch { - return new Response(undefined, { status: 499, statusText: 'Request canceled. Still buffering... forever.' }); - } -} - -async function checkCache(url: URL | Request | string) { - const cache = await caches.open(CACHE); - const response = useCache ? await cache.match(url) : undefined; - if (response) { - return { cache, response }; - } - return { cache, response }; -} - -async function setCached(response: Response, cache: Cache, cacheKey: URL | Request | string) { - if (response.status === 200) { - cache.put(cacheKey, response.clone()); - } -} - -function checkResponse(response: Response) { - if (!(response instanceof Response)) { - throw new TypeError('invalid response from fetch'); - } -} - -function getCacheKey(request: URL | Request | string) { - if (isURL(request)) { - return request.toString(); - } else if (isRequest(request)) { - return request.url; - } else { - return request; - } -} - -const broadcast = new BroadcastChannel('immich'); -// eslint-disable-next-line unicorn/prefer-add-event-listener -broadcast.onmessage = (event) => { - if (!event.data) { - return; - } - const urlstring = event.data.url; - const url = new URL(urlstring, event.origin); - if (event.data.type === 'cancel') { - const pending = pendingLoads.get(url.toString()); - if (pending) { - pending.abort(); - pendingLoads.delete(url.toString()); - } - } else if (event.data.type === 'preload') { - cacheOrFetch(event.data); - } }; + +const handleInstall = (event: ExtendableEvent) => { + event.waitUntil(sw.skipWaiting()); + // Create a new cache and add all files to it + event.waitUntil(addFilesToCache()); +}; + +sw.addEventListener('install', handleInstall); +sw.addEventListener('activate', handleActivate); +sw.addEventListener('fetch', handleFetchEvent); +installBroadcastChannelListener(); diff --git a/web/svelte.config.js b/web/svelte.config.js index 269ba2d923..f83cadda92 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -14,6 +14,9 @@ const config = { }, preprocess: vitePreprocess(), kit: { + paths: { + relative: false, + }, adapter: adapter({ fallback: 'index.html', precompress: true,