From a96a08939e5d2387504f599aa7c331f4baa77249 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 22 Jan 2026 22:11:57 -0500 Subject: [PATCH 1/7] refactor: rename preloadManager to imageManager (#25436) rename: preloadManager to imageManager --- .../openapi/lib/model/asset_media_size.dart | 3 ++ open-api/immich-openapi-specs.json | 1 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset-media.dto.ts | 1 + .../album-page/__tests__/album-cover.spec.ts | 6 +-- .../components/album-page/album-cover.svelte | 8 ++-- .../asset-viewer/activity-viewer.svelte | 6 +-- .../asset-viewer/album-list-item.svelte | 4 +- .../asset-viewer/asset-viewer.svelte | 20 ++++----- .../asset-viewer/detail-panel.svelte | 4 +- .../editor/transform-tool/crop-area.svelte | 4 +- .../asset-viewer/image-panorama-viewer.svelte | 11 +---- .../asset-viewer/photo-viewer.svelte | 4 +- .../asset-viewer/video-native-viewer.svelte | 10 +++-- .../asset-viewer/video-panorama-viewer.svelte | 11 ++--- .../asset-viewer/video-wrapper-viewer.svelte | 11 +++-- .../assets/thumbnail/image-thumbnail.svelte | 4 +- .../assets/thumbnail/thumbnail.svelte | 6 +-- .../memory-page/memory-photo-viewer.svelte | 4 +- .../memory-page/memory-video-viewer.svelte | 4 +- .../memory-page/memory-viewer.svelte | 6 +-- .../places-page/places-card-group.svelte | 4 +- .../shared-components/map/map.svelte | 4 +- .../side-bar/recent-albums.svelte | 4 +- .../covers/__tests__/share-cover.spec.ts | 6 +-- .../covers/share-cover.svelte | 4 +- .../duplicates/duplicate-asset.svelte | 4 +- .../workflows/WorkflowPickerItemCard.svelte | 4 +- web/src/lib/managers/ImageManager.svelte.ts | 43 +++++++++++++++++++ web/src/lib/managers/PreloadManager.svelte.ts | 38 ---------------- .../managers/edit/transform-manager.svelte.ts | 4 +- web/src/lib/utils.ts | 40 ++++++----------- web/src/lib/utils/people-utils.ts | 4 +- web/src/lib/utils/shared-links.ts | 4 +- web/src/routes/(user)/explore/+page.svelte | 4 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 4 +- 36 files changed, 148 insertions(+), 152 deletions(-) create mode 100644 web/src/lib/managers/ImageManager.svelte.ts delete mode 100644 web/src/lib/managers/PreloadManager.svelte.ts diff --git a/mobile/openapi/lib/model/asset_media_size.dart b/mobile/openapi/lib/model/asset_media_size.dart index aa7e2a6f5c..087d19da1f 100644 --- a/mobile/openapi/lib/model/asset_media_size.dart +++ b/mobile/openapi/lib/model/asset_media_size.dart @@ -23,12 +23,14 @@ class AssetMediaSize { String toJson() => value; + static const original = AssetMediaSize._(r'original'); static const fullsize = AssetMediaSize._(r'fullsize'); static const preview = AssetMediaSize._(r'preview'); static const thumbnail = AssetMediaSize._(r'thumbnail'); /// List of all possible values in this [enum][AssetMediaSize]. static const values = [ + original, fullsize, preview, thumbnail, @@ -70,6 +72,7 @@ class AssetMediaSizeTypeTransformer { AssetMediaSize? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { + case r'original': return AssetMediaSize.original; case r'fullsize': return AssetMediaSize.fullsize; case r'preview': return AssetMediaSize.preview; case r'thumbnail': return AssetMediaSize.thumbnail; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cb0c8f8a67..137e7045ac 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16301,6 +16301,7 @@ }, "AssetMediaSize": { "enum": [ + "original", "fullsize", "preview", "thumbnail" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 09a0860539..684818d28f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5660,6 +5660,7 @@ export enum MirrorAxis { Vertical = "vertical" } export enum AssetMediaSize { + Original = "original", Fullsize = "fullsize", Preview = "preview", Thumbnail = "thumbnail" diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index f5207d3048..3935774f3e 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -7,6 +7,7 @@ import { AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; export enum AssetMediaSize { + Original = 'original', /** * An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF. * or otherwise the original image itself. diff --git a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts index 5fa8b96008..3d081a3d2d 100644 --- a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts @@ -1,5 +1,5 @@ import AlbumCover from '$lib/components/album-page/album-cover.svelte'; -import { getAssetThumbnailUrl } from '$lib/utils'; +import { getAssetMediaUrl } from '$lib/utils'; import { albumFactory } from '@test-data/factories/album-factory'; import { render } from '@testing-library/svelte'; @@ -7,7 +7,7 @@ vi.mock('$lib/utils'); describe('AlbumCover component', () => { it('renders an image when the album has a thumbnail', () => { - vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf'); + vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf'); const component = render(AlbumCover, { album: albumFactory.build({ albumName: 'someName', @@ -21,7 +21,7 @@ describe('AlbumCover component', () => { expect(img.getAttribute('loading')).toBe('lazy'); expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); - expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' }); + expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' }); }); it('renders an image when the album has no thumbnail', () => { diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index 3f71bbe632..c6242c5fad 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -1,8 +1,8 @@ diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 30ea8ff6bd..4120212165 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -7,7 +7,7 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl } from '$lib/utils'; import { getAssetType } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { isTenMinutesApart } from '$lib/utils/timesince'; @@ -142,7 +142,7 @@ Profile picture of {reaction.user.name}, who commented on this asset @@ -195,7 +195,7 @@ > Profile picture of {reaction.user.name}, who liked this asset diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index da0df21839..35b2ab99ff 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,7 +1,7 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else} import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { preloadManager } from '$lib/managers/PreloadManager.svelte'; + import { imageManager } from '$lib/managers/ImageManager.svelte'; import { Icon } from '@immich/ui'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ActionReturn } from 'svelte/action'; @@ -60,7 +60,7 @@ onComplete?.(false); } return { - destroy: () => preloadManager.cancelPreloadUrl(url), + destroy: () => imageManager.cancelPreloadUrl(url), }; } diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 63e6f7cc04..cd2bf1d670 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,7 +1,7 @@ {#if !imageLoaded} diff --git a/web/src/lib/components/memory-page/memory-video-viewer.svelte b/web/src/lib/components/memory-page/memory-video-viewer.svelte index 06a41e2ab9..45501aff0b 100644 --- a/web/src/lib/components/memory-page/memory-video-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-video-viewer.svelte @@ -2,7 +2,7 @@ import { assetViewerFadeDuration } from '$lib/constants'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { autoPlayVideo } from '$lib/stores/preferences.store'; - import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { AssetMediaSize } from '@immich/sdk'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; @@ -32,7 +32,7 @@ playsinline class="h-full w-full rounded-2xl object-contain transition-all" src={getAssetPlaybackUrl({ id: asset.id })} - poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })} + poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })} draggable="false" muted={videoViewerMuted} volume={videoViewerVolume} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 1b82de6a4b..3c7ec4b0db 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -30,7 +30,7 @@ import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; - import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; + import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; @@ -449,7 +449,7 @@ {#if current.previousMemory && current.previousMemory.assets.length > 0} {$t('previous_memory')} @@ -598,7 +598,7 @@ {#if current.nextMemory && current.nextMemory.assets.length > 0} {$t('next_memory')} diff --git a/web/src/lib/components/places-page/places-card-group.svelte b/web/src/lib/components/places-page/places-card-group.svelte index 6675014704..c50f2a0441 100644 --- a/web/src/lib/components/places-page/places-card-group.svelte +++ b/web/src/lib/components/places-page/places-card-group.svelte @@ -1,7 +1,7 @@ From 84679fb2b2cdd5ca53f5d2b9b51f8ab67a061887 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 22 Jan 2026 22:12:56 -0500 Subject: [PATCH 2/7] refactor: use assetCacheManager for OCR data (#25437) --- web/src/lib/stores/ocr.svelte.spec.ts | 2 ++ web/src/lib/stores/ocr.svelte.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/lib/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts index 516e9f9f92..5220cbb77d 100644 --- a/web/src/lib/stores/ocr.svelte.spec.ts +++ b/web/src/lib/stores/ocr.svelte.spec.ts @@ -1,3 +1,4 @@ +import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { getAssetOcr } from '@immich/sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -30,6 +31,7 @@ describe('OcrManager', () => { beforeEach(() => { // Reset the singleton state before each test ocrManager.clear(); + assetCacheManager.clearOcrCache(); vi.clearAllMocks(); }); diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts index f68e550851..39c42875de 100644 --- a/web/src/lib/stores/ocr.svelte.ts +++ b/web/src/lib/stores/ocr.svelte.ts @@ -1,5 +1,5 @@ +import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { getAssetOcr } from '@immich/sdk'; export type OcrBoundingBox = { id: string; @@ -38,7 +38,7 @@ class OcrManager { this.#cleared = false; } await this.#ocrLoader.execute(async () => { - this.#data = await getAssetOcr({ id }); + this.#data = await assetCacheManager.getAssetOcr(id); }, false); } From 20dca391430f8a492978d056c423305467bf3094 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:03:57 +0100 Subject: [PATCH 3/7] fix(server): scoped permissions for more endpoints (#25452) --- mobile/openapi/lib/model/permission.dart | 3 +++ open-api/immich-openapi-specs.json | 5 +++++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/controllers/asset-media.controller.ts | 2 +- server/src/controllers/asset.controller.ts | 2 +- server/src/controllers/view.controller.ts | 6 +++--- server/src/enum.ts | 2 ++ 7 files changed, 16 insertions(+), 5 deletions(-) diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 37aecc8b9c..01bb689538 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -72,6 +72,7 @@ class Permission { static const facePeriodRead = Permission._(r'face.read'); static const facePeriodUpdate = Permission._(r'face.update'); static const facePeriodDelete = Permission._(r'face.delete'); + static const folderPeriodRead = Permission._(r'folder.read'); static const jobPeriodCreate = Permission._(r'job.create'); static const jobPeriodRead = Permission._(r'job.read'); static const libraryPeriodCreate = Permission._(r'library.create'); @@ -230,6 +231,7 @@ class Permission { facePeriodRead, facePeriodUpdate, facePeriodDelete, + folderPeriodRead, jobPeriodCreate, jobPeriodRead, libraryPeriodCreate, @@ -423,6 +425,7 @@ class PermissionTypeTransformer { case r'face.read': return Permission.facePeriodRead; case r'face.update': return Permission.facePeriodUpdate; case r'face.delete': return Permission.facePeriodDelete; + case r'folder.read': return Permission.folderPeriodRead; case r'job.create': return Permission.jobPeriodCreate; case r'job.read': return Permission.jobPeriodRead; case r'library.create': return Permission.libraryPeriodCreate; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 137e7045ac..28b61c421e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3173,6 +3173,7 @@ "state": "Stable" } ], + "x-immich-permission": "asset.upload", "x-immich-state": "Stable" } }, @@ -3225,6 +3226,7 @@ "state": "Stable" } ], + "x-immich-permission": "job.create", "x-immich-state": "Stable" } }, @@ -14618,6 +14620,7 @@ "state": "Stable" } ], + "x-immich-permission": "folder.read", "x-immich-state": "Stable" } }, @@ -14670,6 +14673,7 @@ "state": "Stable" } ], + "x-immich-permission": "folder.read", "x-immich-state": "Stable" } }, @@ -18959,6 +18963,7 @@ "face.read", "face.update", "face.delete", + "folder.read", "job.create", "job.read", "library.create", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 684818d28f..c18ae9f475 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5524,6 +5524,7 @@ export enum Permission { FaceRead = "face.read", FaceUpdate = "face.update", FaceDelete = "face.delete", + FolderRead = "folder.read", JobCreate = "job.create", JobRead = "job.read", LibraryCreate = "library.create", diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 788ee0c0ed..3ef63ff7f9 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -202,7 +202,7 @@ export class AssetMediaController { } @Post('exist') - @Authenticated() + @Authenticated({ permission: Permission.AssetUpload }) @Endpoint({ summary: 'Check existing assets', description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup', diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 988623360b..8eb3a5ce44 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -66,7 +66,7 @@ export class AssetController { } @Post('jobs') - @Authenticated() + @Authenticated({ permission: Permission.JobCreate }) @HttpCode(HttpStatus.NO_CONTENT) @Endpoint({ summary: 'Run an asset job', diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts index 8a977e15bc..b07d83fe58 100644 --- a/server/src/controllers/view.controller.ts +++ b/server/src/controllers/view.controller.ts @@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ApiTag } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ViewService } from 'src/services/view.service'; @@ -13,7 +13,7 @@ export class ViewController { constructor(private service: ViewService) {} @Get('folder/unique-paths') - @Authenticated() + @Authenticated({ permission: Permission.FolderRead }) @Endpoint({ summary: 'Retrieve unique paths', description: 'Retrieve a list of unique folder paths from asset original paths.', @@ -24,7 +24,7 @@ export class ViewController { } @Get('folder') - @Authenticated() + @Authenticated({ permission: Permission.FolderRead }) @Endpoint({ summary: 'Retrieve assets by original path', description: 'Retrieve assets that are children of a specific folder.', diff --git a/server/src/enum.ts b/server/src/enum.ts index 5a0f6bdbe0..8f509754da 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -146,6 +146,8 @@ export enum Permission { FaceUpdate = 'face.update', FaceDelete = 'face.delete', + FolderRead = 'folder.read', + JobCreate = 'job.create', JobRead = 'job.read', From 6d9dc466196065adbd1dfea22399b129285b3760 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 23 Jan 2026 09:24:25 -0500 Subject: [PATCH 4/7] chore: include sync dtos (#25470) --- open-api/bin/generate-open-api.sh | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 209 ++++++++++++++++++++ web/src/lib/stores/websocket.ts | 3 +- 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 43292089d7..522063185f 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -27,7 +27,7 @@ function dart { } function typescript { - pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts + pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts pnpm --filter @immich/sdk install --frozen-lockfile pnpm --filter @immich/sdk build } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c18ae9f475..41d4f2689d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1875,6 +1875,210 @@ export type WorkflowUpdateDto = { name?: string; triggerType?: PluginTriggerType; }; +export type SyncAckV1 = {}; +export type SyncAlbumDeleteV1 = { + albumId: string; +}; +export type SyncAlbumToAssetDeleteV1 = { + albumId: string; + assetId: string; +}; +export type SyncAlbumToAssetV1 = { + albumId: string; + assetId: string; +}; +export type SyncAlbumUserDeleteV1 = { + albumId: string; + userId: string; +}; +export type SyncAlbumUserV1 = { + albumId: string; + role: AlbumUserRole; + userId: string; +}; +export type SyncAlbumV1 = { + createdAt: string; + description: string; + id: string; + isActivityEnabled: boolean; + name: string; + order: AssetOrder; + ownerId: string; + thumbnailAssetId: string | null; + updatedAt: string; +}; +export type SyncAssetDeleteV1 = { + assetId: string; +}; +export type SyncAssetExifV1 = { + assetId: string; + city: string | null; + country: string | null; + dateTimeOriginal: string | null; + description: string | null; + exifImageHeight: number | null; + exifImageWidth: number | null; + exposureTime: string | null; + fNumber: number | null; + fileSizeInByte: number | null; + focalLength: number | null; + fps: number | null; + iso: number | null; + latitude: number | null; + lensModel: string | null; + longitude: number | null; + make: string | null; + model: string | null; + modifyDate: string | null; + orientation: string | null; + profileDescription: string | null; + projectionType: string | null; + rating: number | null; + state: string | null; + timeZone: string | null; +}; +export type SyncAssetFaceDeleteV1 = { + assetFaceId: string; +}; +export type SyncAssetFaceV1 = { + assetId: string; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + id: string; + imageHeight: number; + imageWidth: number; + personId: string | null; + sourceType: string; +}; +export type SyncAssetMetadataDeleteV1 = { + assetId: string; + key: string; +}; +export type SyncAssetMetadataV1 = { + assetId: string; + key: string; + value: object; +}; +export type SyncAssetV1 = { + checksum: string; + deletedAt: string | null; + duration: string | null; + fileCreatedAt: string | null; + fileModifiedAt: string | null; + height: number | null; + id: string; + isEdited: boolean; + isFavorite: boolean; + libraryId: string | null; + livePhotoVideoId: string | null; + localDateTime: string | null; + originalFileName: string; + ownerId: string; + stackId: string | null; + thumbhash: string | null; + "type": AssetTypeEnum; + visibility: AssetVisibility; + width: number | null; +}; +export type SyncAuthUserV1 = { + avatarColor: (UserAvatarColor) | null; + deletedAt: string | null; + email: string; + hasProfileImage: boolean; + id: string; + isAdmin: boolean; + name: string; + oauthId: string; + pinCode: string | null; + profileChangedAt: string; + quotaSizeInBytes: number | null; + quotaUsageInBytes: number; + storageLabel: string | null; +}; +export type SyncCompleteV1 = {}; +export type SyncMemoryAssetDeleteV1 = { + assetId: string; + memoryId: string; +}; +export type SyncMemoryAssetV1 = { + assetId: string; + memoryId: string; +}; +export type SyncMemoryDeleteV1 = { + memoryId: string; +}; +export type SyncMemoryV1 = { + createdAt: string; + data: object; + deletedAt: string | null; + hideAt: string | null; + id: string; + isSaved: boolean; + memoryAt: string; + ownerId: string; + seenAt: string | null; + showAt: string | null; + "type": MemoryType; + updatedAt: string; +}; +export type SyncPartnerDeleteV1 = { + sharedById: string; + sharedWithId: string; +}; +export type SyncPartnerV1 = { + inTimeline: boolean; + sharedById: string; + sharedWithId: string; +}; +export type SyncPersonDeleteV1 = { + personId: string; +}; +export type SyncPersonV1 = { + birthDate: string | null; + color: string | null; + createdAt: string; + faceAssetId: string | null; + id: string; + isFavorite: boolean; + isHidden: boolean; + name: string; + ownerId: string; + updatedAt: string; +}; +export type SyncResetV1 = {}; +export type SyncStackDeleteV1 = { + stackId: string; +}; +export type SyncStackV1 = { + createdAt: string; + id: string; + ownerId: string; + primaryAssetId: string; + updatedAt: string; +}; +export type SyncUserDeleteV1 = { + userId: string; +}; +export type SyncUserMetadataDeleteV1 = { + key: UserMetadataKey; + userId: string; +}; +export type SyncUserMetadataV1 = { + key: UserMetadataKey; + userId: string; + value: object; +}; +export type SyncUserV1 = { + avatarColor: (UserAvatarColor) | null; + deletedAt: string | null; + email: string; + hasProfileImage: boolean; + id: string; + name: string; + profileChangedAt: string; +}; /** * List all activities */ @@ -5939,3 +6143,8 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum UserMetadataKey { + Preferences = "preferences", + License = "license", + Onboarding = "onboarding" +} diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 5e197fbb3f..335ec188ea 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -12,6 +12,7 @@ import { type MaintenanceStatusResponseDto, type NotificationDto, type ServerVersionResponseDto, + type SyncAssetV1, } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; @@ -40,7 +41,7 @@ export interface Events { AppRestartV1: (event: AppRestartEvent) => void; MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void; - AssetEditReadyV1: (data: { asset: { id: string } }) => void; + AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void; } const websocket: Socket = io({ From 41c5a0ca2fb9dc6b5652a920afa0a47ecb5f9032 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:42:21 +0100 Subject: [PATCH 5/7] fix(docs): document that fullsize thumbnail might redirect to original (#25416) --- mobile/openapi/lib/api/assets_api.dart | 4 ++-- open-api/immich-openapi-specs.json | 2 +- server/src/controllers/asset-media.controller.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 03d91c9dae..e737397fc8 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -1691,7 +1691,7 @@ class AssetsApi { /// View asset thumbnail /// - /// Retrieve the thumbnail image for the specified asset. + /// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission. /// /// Note: This method returns the HTTP [Response]. /// @@ -1747,7 +1747,7 @@ class AssetsApi { /// View asset thumbnail /// - /// Retrieve the thumbnail image for the specified asset. + /// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission. /// /// Parameters: /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 28b61c421e..fb329d2653 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4279,7 +4279,7 @@ }, "/assets/{id}/thumbnail": { "get": { - "description": "Retrieve the thumbnail image for the specified asset.", + "description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.", "operationId": "viewAsset", "parameters": [ { diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 3ef63ff7f9..ec6083cfa8 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -147,7 +147,8 @@ export class AssetMediaController { @Authenticated({ permission: Permission.AssetView, sharedLink: true }) @Endpoint({ summary: 'View asset thumbnail', - description: 'Retrieve the thumbnail image for the specified asset.', + description: + 'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) async viewAsset( From 439604757c08d4073e1eb8913bcdd779b3cbee36 Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH 6/7] feat: remove Cache API, rework preload(), cancel() and fetch() perf - replace broadcast channel with direct postMessage --- web/src/lib/utils/sw-messaging.ts | 28 ++-- web/src/lib/utils/sw-messenger.ts | 157 ++++++++++++++++++++ web/src/service-worker/broadcast-channel.ts | 25 ---- web/src/service-worker/cache.ts | 42 ------ web/src/service-worker/index.ts | 12 +- web/src/service-worker/messaging.ts | 53 +++++++ web/src/service-worker/request.ts | 117 +++++++-------- 7 files changed, 290 insertions(+), 144 deletions(-) create mode 100644 web/src/lib/utils/sw-messenger.ts delete mode 100644 web/src/service-worker/broadcast-channel.ts delete mode 100644 web/src/service-worker/cache.ts create mode 100644 web/src/service-worker/messaging.ts diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 61cd1b8df0..f0fc93f50b 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,14 +1,24 @@ -const broadcast = new BroadcastChannel('immich'); +import { ServiceWorkerMessenger } from './sw-messenger'; + +const messenger = new ServiceWorkerMessenger(); + +let isServiceWorkerEnabled = true; + +messenger.onAckTimeout(() => { + if (!isServiceWorkerEnabled) { + return; + } + console.error('[ServiceWorker] No communication detected. Auto-disabled service worker.'); + isServiceWorkerEnabled = false; +}); + +const isValidSwContext = (url: string | undefined | null): url is string => { + return globalThis.isSecureContext && isServiceWorkerEnabled && !!url; +}; export function cancelImageUrl(url: string | undefined | null) { - if (!url) { + if (!isValidSwContext(url)) { return; } - broadcast.postMessage({ type: 'cancel', url }); -} -export function preloadImageUrl(url: string | undefined | null) { - if (!url) { - return; - } - broadcast.postMessage({ type: 'preload', url }); + void messenger.send('cancel', { url }); } diff --git a/web/src/lib/utils/sw-messenger.ts b/web/src/lib/utils/sw-messenger.ts new file mode 100644 index 0000000000..749f834e9e --- /dev/null +++ b/web/src/lib/utils/sw-messenger.ts @@ -0,0 +1,157 @@ +/** + * Low-level protocol for communicating with the service worker via postMessage. + * + * Protocol: + * 1. Main thread sends request: { type: string, requestId: string, ...data } + * 2. SW sends ack: { type: 'ack', requestId: string } + * 3. SW sends response (optional): { type: 'response', requestId: string, result?: any, error?: string } + */ + +interface PendingRequest { + resolveAck: () => void; + resolveResponse?: (result: unknown) => void; + rejectResponse?: (error: Error) => void; + ackTimeout: ReturnType; + ackReceived: boolean; +} + +export class ServiceWorkerMessenger { + readonly #pendingRequests = new Map(); + readonly #ackTimeoutMs: number; + #requestCounter = 0; + #onTimeout?: (type: string, data: Record) => void; + #messageHandler?: (event: MessageEvent) => void; + + constructor(ackTimeoutMs = 5000) { + this.#ackTimeoutMs = ackTimeoutMs; + + // Listen for messages from the service worker + if ('serviceWorker' in navigator) { + this.#messageHandler = (event) => { + this.#handleMessage(event.data); + }; + navigator.serviceWorker.addEventListener('message', this.#messageHandler); + } + } + + #handleMessage(data: unknown) { + if (typeof data !== 'object' || data === null) { + return; + } + + const message = data as { requestId?: string; type?: string; error?: string; result?: unknown }; + const requestId = message.requestId; + if (!requestId) { + return; + } + + const pending = this.#pendingRequests.get(requestId); + if (!pending) { + return; + } + + if (message.type === 'ack') { + pending.ackReceived = true; + clearTimeout(pending.ackTimeout); + pending.resolveAck(); + return; + } + + if (message.type === 'response') { + clearTimeout(pending.ackTimeout); + this.#pendingRequests.delete(requestId); + + if (message.error) { + pending.rejectResponse?.(new Error(message.error)); + return; + } + + pending.resolveResponse?.(message.result); + } + } + + /** + * Set a callback to be invoked when an ack timeout occurs. + * This can be used to detect and disable faulty service workers. + */ + onAckTimeout(callback: (type: string, data: Record) => void): void { + this.#onTimeout = callback; + } + + /** + * Send a message to the service worker. + * - send(): waits for ack, resolves when acknowledged + * - request(): waits for response, throws on error/timeout + */ + #sendInternal(type: string, data: Record, waitForResponse: boolean): Promise { + const requestId = `${type}-${++this.#requestCounter}-${Date.now()}`; + + const promise = new Promise((resolve, reject) => { + const ackTimeout = setTimeout(() => { + const pending = this.#pendingRequests.get(requestId); + if (pending && !pending.ackReceived) { + this.#pendingRequests.delete(requestId); + console.warn(`[ServiceWorker] ${type} request not acknowledged:`, data); + this.#onTimeout?.(type, data); + // Only reject if we're waiting for a response + if (waitForResponse) { + reject(new Error(`Service worker did not acknowledge ${type} request`)); + } else { + resolve(undefined as T); + } + } + }, this.#ackTimeoutMs); + + this.#pendingRequests.set(requestId, { + resolveAck: waitForResponse ? () => {} : () => resolve(undefined as T), + resolveResponse: waitForResponse ? (result: unknown) => resolve(result as T) : undefined, + rejectResponse: waitForResponse ? reject : undefined, + ackTimeout, + ackReceived: false, + }); + + // Send message to the active service worker + // Feature detection is done in constructor and at call sites (sw-messaging.ts:isValidSwContext) + // eslint-disable-next-line compat/compat + navigator.serviceWorker.controller?.postMessage({ + type, + requestId, + ...data, + }); + }); + + return promise; + } + + /** + * Send a one-way message to the service worker. + * Returns a promise that resolves after the service worker acknowledges receipt. + * Resolves even if no ack is received within the timeout period. + */ + send(type: string, data: Record): Promise { + return this.#sendInternal(type, data, false); + } + + /** + * Send a request and wait for ack + response. + * Returns a promise that resolves with the response data or rejects on error/timeout. + */ + request(type: string, data: Record): Promise { + return this.#sendInternal(type, data, true); + } + + /** + * Clean up pending requests and remove event listener + */ + close(): void { + for (const pending of this.#pendingRequests.values()) { + clearTimeout(pending.ackTimeout); + } + this.#pendingRequests.clear(); + + if (this.#messageHandler && 'serviceWorker' in navigator) { + navigator.serviceWorker.removeEventListener('message', this.#messageHandler); + this.#messageHandler = undefined; + } + } +} diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts deleted file mode 100644 index ae6f1e1be6..0000000000 --- a/web/src/service-worker/broadcast-channel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { handleCancel, handlePreload } from './request'; - -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 url = new URL(event.data.url, event.origin); - - switch (event.data.type) { - case 'preload': { - handlePreload(url); - break; - } - - case 'cancel': { - handleCancel(url); - break; - } - } - }; -}; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts deleted file mode 100644 index f91d8366ea..0000000000 --- a/web/src/service-worker/cache.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { version } from '$service-worker'; - -const CACHE = `cache-${version}`; - -let _cache: Cache | undefined; -const getCache = async () => { - if (_cache) { - return _cache; - } - _cache = await caches.open(CACHE); - return _cache; -}; - -export const get = async (key: string) => { - const cache = await getCache(); - if (!cache) { - return; - } - - 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); - } - } -}; diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 28336aca6a..377195b0c8 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -2,9 +2,9 @@ /// /// /// -import { installBroadcastChannelListener } from './broadcast-channel'; -import { prune } from './cache'; -import { handleRequest } from './request'; + +import { installMessageListener } from './messaging'; +import { handleFetch as handleAssetFetch } from './request'; const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/; @@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - event.waitUntil(prune()); }; const handleInstall = (event: ExtendableEvent) => { event.waitUntil(sw.skipWaiting()); - // do not preload app resources }; const handleFetch = (event: FetchEvent): void => { @@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => { // 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)); + event.respondWith(handleAssetFetch(event.request)); return; } }; @@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => { sw.addEventListener('install', handleInstall, { passive: true }); sw.addEventListener('activate', handleActivate, { passive: true }); sw.addEventListener('fetch', handleFetch, { passive: true }); -installBroadcastChannelListener(); +installMessageListener(); diff --git a/web/src/service-worker/messaging.ts b/web/src/service-worker/messaging.ts new file mode 100644 index 0000000000..2dd2d51f72 --- /dev/null +++ b/web/src/service-worker/messaging.ts @@ -0,0 +1,53 @@ +/// +/// +/// +/// + +import { handleCancel } from './request'; + +const sw = globalThis as unknown as ServiceWorkerGlobalScope; + +/** + * Send acknowledgment for a request + */ +function sendAck(client: Client, requestId: string) { + client.postMessage({ + type: 'ack', + requestId, + }); +} + +/** + * Handle 'cancel' request: cancel a pending request + */ +const handleCancelRequest = (client: Client, url: URL, requestId: string) => { + sendAck(client, requestId); + handleCancel(url); +}; + +export const installMessageListener = () => { + sw.addEventListener('message', (event) => { + if (!event.data?.requestId || !event.data?.type) { + return; + } + + const requestId = event.data.requestId; + + switch (event.data.type) { + case 'cancel': { + const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined; + if (!url) { + return; + } + + const client = event.source; + if (!client) { + return; + } + + handleCancelRequest(client, url, requestId); + break; + } + } + }); +}; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index aeb63be899..1060cd4b6c 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,73 +1,68 @@ -import { get, put } from './cache'; +/// +/// +/// +/// -const pendingRequests = new Map(); - -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'); - } +type PendingRequest = { + controller: AbortController; + promise: Promise; + cleanupTimeout?: ReturnType; }; -const getCacheKey = (request: URL | Request) => { - if (isURL(request)) { - return request.toString(); +const pendingRequests = new Map(); + +const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url); + +const CANCELATION_MESSAGE = 'Request canceled by application'; +const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export const handleFetch = (request: URL | Request): Promise => { + const requestKey = getRequestKey(request); + const existing = pendingRequests.get(requestKey); + + if (existing) { + // Clone the response since response bodies can only be read once + // Each caller gets an independent clone they can consume + return existing.promise.then((response) => response.clone()); } - if (isRequest(request)) { - return request.url; - } + const pendingRequest: PendingRequest = { + controller: new AbortController(), + promise: undefined as unknown as Promise, + }; + pendingRequests.set(requestKey, pendingRequest); - throw new Error(`Invalid request: ${request}`); -}; + // NOTE: fetch returns after headers received, not the body + pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal }) + .catch((error: unknown) => { + const standardError = error instanceof Error ? error : new Error(String(error)); + if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) { + // dummy response avoids network errors in the console for these requests + return new Response(undefined, { status: 204 }); + } + throw standardError; + }) + .finally(() => { + // Schedule cleanup after timeout to allow response body streaming to complete + const cleanupTimeout = setTimeout(() => { + pendingRequests.delete(requestKey); + }, CLEANUP_TIMEOUT_MS); + pendingRequest.cleanupTimeout = cleanupTimeout; + }); -export const handlePreload = async (request: URL | Request) => { - try { - return await handleRequest(request); - } catch (error) { - console.error(`Preload failed: ${error}`); - } -}; - -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) { - if (error.name === 'AbortError') { - // dummy response avoids network errors in the console for these requests - return new Response(undefined, { status: 204 }); - } - - console.log('Not an abort error', error); - - throw error; - } finally { - pendingRequests.delete(cacheKey); - } + // Clone for the first caller to keep the original response unconsumed for future callers + return pendingRequest.promise.then((response) => response.clone()); }; export const handleCancel = (url: URL) => { - const cacheKey = getCacheKey(url); - const pendingRequest = pendingRequests.get(cacheKey); - if (!pendingRequest) { - return; - } + const requestKey = getRequestKey(url); - pendingRequest.abort(); - pendingRequests.delete(cacheKey); + const pendingRequest = pendingRequests.get(requestKey); + if (pendingRequest) { + pendingRequest.controller.abort(CANCELATION_MESSAGE); + if (pendingRequest.cleanupTimeout) { + clearTimeout(pendingRequest.cleanupTimeout); + } + pendingRequests.delete(requestKey); + } }; From b2e2ae3a663e63b4f51bc652098922fe4b699393 Mon Sep 17 00:00:00 2001 From: midzelis Date: Fri, 23 Jan 2026 15:44:20 +0000 Subject: [PATCH 7/7] remove sw response handling --- web/src/lib/utils/sw-messaging.ts | 13 +-- web/src/lib/utils/sw-messenger.ts | 155 ++---------------------------- 2 files changed, 12 insertions(+), 156 deletions(-) diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index f0fc93f50b..789341d84a 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,19 +1,10 @@ import { ServiceWorkerMessenger } from './sw-messenger'; const messenger = new ServiceWorkerMessenger(); - -let isServiceWorkerEnabled = true; - -messenger.onAckTimeout(() => { - if (!isServiceWorkerEnabled) { - return; - } - console.error('[ServiceWorker] No communication detected. Auto-disabled service worker.'); - isServiceWorkerEnabled = false; -}); +const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator; const isValidSwContext = (url: string | undefined | null): url is string => { - return globalThis.isSecureContext && isServiceWorkerEnabled && !!url; + return hasServiceWorker && !!url; }; export function cancelImageUrl(url: string | undefined | null) { diff --git a/web/src/lib/utils/sw-messenger.ts b/web/src/lib/utils/sw-messenger.ts index 749f834e9e..c9b2ce9eb2 100644 --- a/web/src/lib/utils/sw-messenger.ts +++ b/web/src/lib/utils/sw-messenger.ts @@ -1,157 +1,22 @@ -/** - * Low-level protocol for communicating with the service worker via postMessage. - * - * Protocol: - * 1. Main thread sends request: { type: string, requestId: string, ...data } - * 2. SW sends ack: { type: 'ack', requestId: string } - * 3. SW sends response (optional): { type: 'response', requestId: string, result?: any, error?: string } - */ - -interface PendingRequest { - resolveAck: () => void; - resolveResponse?: (result: unknown) => void; - rejectResponse?: (error: Error) => void; - ackTimeout: ReturnType; - ackReceived: boolean; -} - export class ServiceWorkerMessenger { - readonly #pendingRequests = new Map(); - readonly #ackTimeoutMs: number; - #requestCounter = 0; - #onTimeout?: (type: string, data: Record) => void; - #messageHandler?: (event: MessageEvent) => void; + constructor() {} - constructor(ackTimeoutMs = 5000) { - this.#ackTimeoutMs = ackTimeoutMs; - - // Listen for messages from the service worker - if ('serviceWorker' in navigator) { - this.#messageHandler = (event) => { - this.#handleMessage(event.data); - }; - navigator.serviceWorker.addEventListener('message', this.#messageHandler); - } - } - - #handleMessage(data: unknown) { - if (typeof data !== 'object' || data === null) { - return; + #sendInternal(type: string, data: Record) { + if (!('serviceWorker' in navigator)) { + throw new Error('Service Worker not enabled in this environment '); } - const message = data as { requestId?: string; type?: string; error?: string; result?: unknown }; - const requestId = message.requestId; - if (!requestId) { - return; - } - - const pending = this.#pendingRequests.get(requestId); - if (!pending) { - return; - } - - if (message.type === 'ack') { - pending.ackReceived = true; - clearTimeout(pending.ackTimeout); - pending.resolveAck(); - return; - } - - if (message.type === 'response') { - clearTimeout(pending.ackTimeout); - this.#pendingRequests.delete(requestId); - - if (message.error) { - pending.rejectResponse?.(new Error(message.error)); - return; - } - - pending.resolveResponse?.(message.result); - } - } - - /** - * Set a callback to be invoked when an ack timeout occurs. - * This can be used to detect and disable faulty service workers. - */ - onAckTimeout(callback: (type: string, data: Record) => void): void { - this.#onTimeout = callback; - } - - /** - * Send a message to the service worker. - * - send(): waits for ack, resolves when acknowledged - * - request(): waits for response, throws on error/timeout - */ - #sendInternal(type: string, data: Record, waitForResponse: boolean): Promise { - const requestId = `${type}-${++this.#requestCounter}-${Date.now()}`; - - const promise = new Promise((resolve, reject) => { - const ackTimeout = setTimeout(() => { - const pending = this.#pendingRequests.get(requestId); - if (pending && !pending.ackReceived) { - this.#pendingRequests.delete(requestId); - console.warn(`[ServiceWorker] ${type} request not acknowledged:`, data); - this.#onTimeout?.(type, data); - // Only reject if we're waiting for a response - if (waitForResponse) { - reject(new Error(`Service worker did not acknowledge ${type} request`)); - } else { - resolve(undefined as T); - } - } - }, this.#ackTimeoutMs); - - this.#pendingRequests.set(requestId, { - resolveAck: waitForResponse ? () => {} : () => resolve(undefined as T), - resolveResponse: waitForResponse ? (result: unknown) => resolve(result as T) : undefined, - rejectResponse: waitForResponse ? reject : undefined, - ackTimeout, - ackReceived: false, - }); - - // Send message to the active service worker - // Feature detection is done in constructor and at call sites (sw-messaging.ts:isValidSwContext) - // eslint-disable-next-line compat/compat - navigator.serviceWorker.controller?.postMessage({ - type, - requestId, - ...data, - }); + // eslint-disable-next-line compat/compat + navigator.serviceWorker.controller?.postMessage({ + type, + ...data, }); - - return promise; } /** * Send a one-way message to the service worker. - * Returns a promise that resolves after the service worker acknowledges receipt. - * Resolves even if no ack is received within the timeout period. */ - send(type: string, data: Record): Promise { - return this.#sendInternal(type, data, false); - } - - /** - * Send a request and wait for ack + response. - * Returns a promise that resolves with the response data or rejects on error/timeout. - */ - request(type: string, data: Record): Promise { - return this.#sendInternal(type, data, true); - } - - /** - * Clean up pending requests and remove event listener - */ - close(): void { - for (const pending of this.#pendingRequests.values()) { - clearTimeout(pending.ackTimeout); - } - this.#pendingRequests.clear(); - - if (this.#messageHandler && 'serviceWorker' in navigator) { - navigator.serviceWorker.removeEventListener('message', this.#messageHandler); - this.#messageHandler = undefined; - } + send(type: string, data: Record) { + return this.#sendInternal(type, data); } }