mirror of
https://github.com/immich-app/immich.git
synced 2025-06-03 13:44:16 -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:
|
prod:
|
||||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
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:
|
prod-scale:
|
||||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
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
|
name: immich-dev
|
||||||
|
|
||||||
services:
|
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:
|
immich-server:
|
||||||
container_name: immich_server
|
container_name: immich_server
|
||||||
command: ['/usr/src/app/bin/immich-dev']
|
command: ['/usr/src/app/bin/immich-dev']
|
||||||
|
@ -10,6 +10,20 @@
|
|||||||
name: immich-prod
|
name: immich-prod
|
||||||
|
|
||||||
services:
|
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:
|
immich-server:
|
||||||
container_name: immich_server
|
container_name: immich_server
|
||||||
image: immich-server:latest
|
image: immich-server:latest
|
||||||
|
@ -64,6 +64,8 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ignores: ['**/service-worker/**'],
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
|
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@ -71,8 +72,7 @@
|
|||||||
const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => {
|
const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => {
|
||||||
for (const preloadAsset of preloadAssets || []) {
|
for (const preloadAsset of preloadAssets || []) {
|
||||||
if (preloadAsset.type === AssetTypeEnum.Image) {
|
if (preloadAsset.type === AssetTypeEnum.Image) {
|
||||||
let img = new Image();
|
preloadImageUrl(getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash));
|
||||||
img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -150,6 +150,7 @@
|
|||||||
return () => {
|
return () => {
|
||||||
loader?.removeEventListener('load', onload);
|
loader?.removeEventListener('load', onload);
|
||||||
loader?.removeEventListener('error', onerror);
|
loader?.removeEventListener('error', onerror);
|
||||||
|
cancelImageUrl(imageLoaderUrl);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
let isWebCompatible = $derived(isWebCompatibleImage(asset));
|
let isWebCompatible = $derived(isWebCompatibleImage(asset));
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
import { mdiEyeOffOutline } from '@mdi/js';
|
import { mdiEyeOffOutline } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -64,6 +65,9 @@
|
|||||||
setLoaded();
|
setLoaded();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
cancelImageUrl(url);
|
||||||
|
});
|
||||||
|
|
||||||
let optionalClasses = $derived(
|
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