feat: Service Worker to preload/cancel images and other resources

This commit is contained in:
Min Idzelis 2025-03-15 18:50:59 +00:00
parent 9a4495eb5b
commit 9bb2ddc1a7
14 changed files with 260 additions and 3 deletions

View File

@ -17,6 +17,9 @@ e2e:
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans

24
docker/caddy/Caddyfile Normal file
View File

@ -0,0 +1,24 @@
{
# debug
local_certs
log {
format console
}
pki {
ca local {
name "Immich Local CA - TESTING ONLY"
intermediate_lifetime 3599d
}
}
grace_period 0
shutdown_delay 0
skip_install_trust
auto_https disable_redirects
}
{$IMMICH_HOST}:3443 {
tls internal {
on_demand
}
reverse_proxy {$IMMICH_INTERNAL_URL}
}

View File

@ -0,0 +1,15 @@
## What is in this folder?
These are Caddy certificates necessary for local development using the service-worker, clipboard access, etc.
This folder contains certs root and intermediate CAs. Caddy uses this to sign its server certs.
These certificates have a 10yr expiration date. They should NOT be used in production.
## How to use?
1. You should import these into your system keychain or truststore. (OS-specific)
2. Ensure 'immich-dev' resolves to the docker host.
* i.e. add entry in /etc/hosts that points to the host running the immich docker container.
## Permissions
Caddy runs as root user. These files must be owned by root with 600 permissions. You make need to temporarily make these 644 so you can copy/import them into your trust store.

View File

@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIB2TCCAX+gAwIBAgIQaofX+uLl1ohUu1tDEoKbdjAKBggqhkjOPQQDAjA5MTcw
NQYDVQQDEy5JbW1pY2ggTG9jYWwgQ0EgLSBURVNUSU5HIE9OTFkgLSAyMDI1IEVD
QyBSb290MB4XDTI1MDMxNTE1MTMxOVoXDTM1MDEyMTE1MTMxOVowPDE6MDgGA1UE
AxMxSW1taWNoIExvY2FsIENBIC0gVEVTVElORyBPTkxZIC0gRUNDIEludGVybWVk
aWF0ZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDIDN4PR36WU+XZYaUxzdRpd
R5PUJ34oeqnthRIvxz5k1v324pYvk/unKkr4/73+YiQgbGJYoXuS1RosMh6+J4aj
ZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQW
BBSgTH3mPyuKmXKSfUn/XC9Ag69trTAfBgNVHSMEGDAWgBRjdUoajCqc0KfFvLbw
sdJQqL6iCjAKBggqhkjOPQQDAgNIADBFAiEA2zQBXgof4D7pk9RF/J5MKCMi+mGq
s8I8MQM0X0PWv6wCIG8R0KOvwiYPxsX+TDUtG4F2rYdSb6OHbcoYg0UEwMVZ
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIPO0Ao4ha+T3Op2UljmdroXbvsDrKYMqGvu9762W+mZqoAoGCCqGSM49
AwEHoUQDQgAEMgM3g9HfpZT5dlhpTHN1Gl1Hk9Qnfih6qe2FEi/HPmTW/fbili+T
+6cqSvj/vf5iJCBsYlihe5LVGiwyHr4nhg==
-----END EC PRIVATE KEY-----

View File

@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBtzCCAVygAwIBAgIRAMd1v26Z7/BEBZVgNeUSPD8wCgYIKoZIzj0EAwIwOTE3
MDUGA1UEAxMuSW1taWNoIExvY2FsIENBIC0gVEVTVElORyBPTkxZIC0gMjAyNSBF
Q0MgUm9vdDAeFw0yNTAzMTUxNTEzMTlaFw0zNTAxMjIxNTEzMTlaMDkxNzA1BgNV
BAMTLkltbWljaCBMb2NhbCBDQSAtIFRFU1RJTkcgT05MWSAtIDIwMjUgRUNDIFJv
b3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATcZGmAJUrSce1rOvNPcSAM9hDS
/9NopYW9833n52kqrC+ArUZsMHC2BxN5Ndlu+ac288oSrUKLOxzes0Lr+Jeto0Uw
QzAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQU
Y3VKGowqnNCnxby28LHSUKi+ogowCgYIKoZIzj0EAwIDSQAwRgIhAOQMD95mhs6G
qxzoMXbYgjw5S5cF4HP4yYBYcvrmuypVAiEAlG//Ayx9kicVHVeOchm4RyRCm1hU
zEBhaqC33ivd4D8=
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDrpG22VrpagAKo7dPL16RihojPr7MgYcKwZA5jSMrXioAoGCCqGSM49
AwEHoUQDQgAE3GRpgCVK0nHtazrzT3EgDPYQ0v/TaKWFvfN95+dpKqwvgK1GbDBw
tgcTeTXZbvmnNvPKEq1Cizsc3rNC6/iXrQ==
-----END EC PRIVATE KEY-----

View File

@ -14,6 +14,21 @@
name: immich-dev
services:
immich-caddy:
container_name: immich_caddy
image: caddy:2.9.1-alpine
restart: unless-stopped
ports:
- "2019:2019"
- "3443:3443"
- "3443:3443/udp"
environment:
IMMICH_HOST: immich-dev
IMMICH_INTERNAL_URL: http://immich-web:3000
volumes:
- ./caddy:/etc/caddy
- ./caddy/certs:/data/caddy/pki/authorities/local
command: ["/bin/sh", "-c", "chown 0:0 /data/caddy/pki/authorities/local/*; chmod 600 /data/caddy/pki/authorities/local/*; caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
immich-server:
container_name: immich_server
command: ['/usr/src/app/bin/immich-dev']

View File

@ -10,6 +10,20 @@
name: immich-prod
services:
immich-caddy:
container_name: immich_caddy
image: caddy:2.9.1-alpine
restart: unless-stopped
ports:
- "3443:3443"
- "3443:3443/udp"
environment:
IMMICH_HOST: immich-dev
IMMICH_INTERNAL_URL: http://immich-server:2283
volumes:
- ./caddy:/etc/caddy
- ./caddy/certs:/data/caddy/pki/authorities/local
command: ["/bin/sh", "-c", "chown 0:0 /data/caddy/pki/authorities/local/*; chmod 600 /data/caddy/pki/authorities/local/*; caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
immich-server:
container_name: immich_server
image: immich-server:latest

View File

@ -64,6 +64,8 @@ export default [
},
},
ignores: ['**/service-worker/**'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',

View File

@ -21,6 +21,7 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
interface Props {
asset: AssetResponseDto;
@ -71,8 +72,7 @@
const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash);
preloadImageUrl(getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash));
}
}
};
@ -150,6 +150,7 @@
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
};
});
let isWebCompatible = $derived(isWebCompatibleImage(asset));

View File

@ -2,9 +2,10 @@
import { thumbhash } from '$lib/actions/thumbhash';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { TUNABLES } from '$lib/utils/tunables';
import { mdiEyeOffOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
@ -64,6 +65,9 @@
setLoaded();
}
});
onDestroy(() => {
cancelImageUrl(url);
});
let optionalClasses = $derived(
[

View File

@ -0,0 +1,8 @@
const broadcast = new BroadcastChannel('immich');
export function cancelImageUrl(url: string) {
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string) {
broadcast.postMessage({ type: 'preload', url });
}

View File

@ -0,0 +1,137 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
import { build, files, version } from '$service-worker';
const pendingLoads = new Map<string, AbortController>();
const useCache = true;
// 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) => {
event.waitUntil(sw.clients.claim());
// Remove previous cached data from disk
event.waitUntil(deleteOldCaches());
});
sw.addEventListener('fetch', (event) => {
// ignore POST requests etc
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
if (APP_RESOURCES.includes(url.pathname)) {
event.respondWith(appResources(url, event));
} else if (/^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(url.pathname)) {
event.respondWith(immichAsset(url));
}
});
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);
}
async function immichAsset(url: URL) {
const cache = await caches.open(CACHE);
let response = useCache ? await cache.match(url) : undefined;
if (!response) {
try {
const cancelToken = new AbortController();
const request = fetch(url, {
signal: cancelToken.signal,
});
pendingLoads.set(url.toString(), cancelToken);
response = await request;
if (!(response instanceof Response)) {
throw new TypeError('invalid response from fetch');
}
if (response.status === 200) {
cache.put(url, response.clone());
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).name !== 'AbortError') {
throw error;
}
} finally {
pendingLoads.delete(url.toString());
}
}
return response as Response;
}
async function appResources(url: URL, event: FetchEvent) {
const cache = await caches.open(CACHE);
// `build`/`files` can always be served from the cache
if (APP_RESOURCES.includes(url.pathname)) {
const response = await cache.match(url.pathname);
if (response) {
return response;
}
}
// for everything else, try the network first, but
// fall back to the cache if we're offline
try {
const response = await fetch(event.request);
// if we're offline, fetch can return a value that is not a Response
// instead of throwing - and we can't pass this non-Response to respondWith
if (!(response instanceof Response)) {
throw new TypeError('invalid response from fetch');
}
if (response.status === 200) {
cache.put(event.request, response.clone());
}
return response;
} catch (error) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw error;
}
}
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
if (event.data.type === 'cancel') {
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
const pending = pendingLoads.get(url.toString());
if (pending) {
pending.abort();
pendingLoads.delete(url.toString());
}
} else if (event.data.type === 'preload') {
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
immichAsset(url);
}
};