mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: Service Worker to preload/cancel images and other resources
This commit is contained in:
parent
9a4495eb5b
commit
9bb2ddc1a7
3
Makefile
3
Makefile
@ -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
24
docker/caddy/Caddyfile
Normal 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}
|
||||
}
|
15
docker/caddy/certs/README.md
Normal file
15
docker/caddy/certs/README.md
Normal 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.
|
12
docker/caddy/certs/intermediate.crt
Normal file
12
docker/caddy/certs/intermediate.crt
Normal 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-----
|
5
docker/caddy/certs/intermediate.key
Normal file
5
docker/caddy/certs/intermediate.key
Normal file
@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIPO0Ao4ha+T3Op2UljmdroXbvsDrKYMqGvu9762W+mZqoAoGCCqGSM49
|
||||
AwEHoUQDQgAEMgM3g9HfpZT5dlhpTHN1Gl1Hk9Qnfih6qe2FEi/HPmTW/fbili+T
|
||||
+6cqSvj/vf5iJCBsYlihe5LVGiwyHr4nhg==
|
||||
-----END EC PRIVATE KEY-----
|
12
docker/caddy/certs/root.crt
Normal file
12
docker/caddy/certs/root.crt
Normal 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-----
|
5
docker/caddy/certs/root.key
Normal file
5
docker/caddy/certs/root.key
Normal file
@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIDrpG22VrpagAKo7dPL16RihojPr7MgYcKwZA5jSMrXioAoGCCqGSM49
|
||||
AwEHoUQDQgAE3GRpgCVK0nHtazrzT3EgDPYQ0v/TaKWFvfN95+dpKqwvgK1GbDBw
|
||||
tgcTeTXZbvmnNvPKEq1Cizsc3rNC6/iXrQ==
|
||||
-----END EC PRIVATE KEY-----
|
@ -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']
|
||||
|
@ -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
|
||||
|
@ -64,6 +64,8 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
ignores: ['**/service-worker/**'],
|
||||
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
|
@ -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));
|
||||
|
@ -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(
|
||||
[
|
||||
|
8
web/src/lib/utils/sw-messaging.ts
Normal file
8
web/src/lib/utils/sw-messaging.ts
Normal 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 });
|
||||
}
|
137
web/src/service-worker/index.ts
Normal file
137
web/src/service-worker/index.ts
Normal 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);
|
||||
}
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user