| author | |
| committer | |
| log | eee05575ebd514db36f61d7eb05d2258a10c96bd |
| tree | ac282bd9edcc2541db816c5d0866798b3c3433b9 |
| parent | 050690d1bc048c1f94096c17a1c9231c3ad61a83 |
| signature | Signed by PGP key B5690EEEBB952194 |
20 files changed, 1207 insertions(+), 62 deletions(-)
apps/mobile/src/components/ProjectFavicon.tsx+28-16| ... | ... | @@ -5,9 +5,13 @@ import { View } from "react-native"; |
| 5 | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | 7 | getProjectFaviconCacheKey, |
| 8 | getProjectFaviconResourceKey, | |
| 8 | 9 | isProjectFaviconFallbackUrl, |
| 9 | 10 | } from "@t3tools/shared/projectFavicon"; |
| 10 | import { useAssetUrl } from "../state/assets"; | |
| 11 | import { useAtomValue } from "@effect/atom-react"; | |
| 12 | import { Atom } from "effect/unstable/reactivity"; | |
| 13 | import { projectFaviconUrlAtom } from "../state/assets"; | |
| 14 | ||
| 11 | 15 | import { |
| 12 | 16 | beginProjectFaviconRequest, |
| 13 | 17 | createProjectFaviconRequest, |
| ... | ... | @@ -16,6 +20,8 @@ import { |
| 16 | 20 | markProjectFaviconLoaded, |
| 17 | 21 | } from "./projectFaviconCache"; |
| 18 | 22 | |
| 23 | const EMPTY_FAVICON_URL = Atom.make<string | null>(null); | |
| 24 | ||
| 19 | 25 | /* ─── Component ──────────────────────────────────────────────────────── */ |
| 20 | 26 | export function ProjectFavicon(props: { |
| 21 | 27 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { |
| 26 | 32 | readonly faviconPath?: string | null; |
| 27 | 33 | }) { |
| 28 | 34 | const size = props.size ?? 42; |
| 29 | const faviconUrl = useAssetUrl( | |
| 30 | props.environmentId, | |
| 31 | props.workspaceRoot === null || props.workspaceRoot === undefined | |
| 32 | ? null | |
| 33 | : { | |
| 34 | _tag: "project-favicon", | |
| 35 | const faviconUrl = useAtomValue( | |
| 36 | props.workspaceRoot == null | |
| 37 | ? EMPTY_FAVICON_URL | |
| 38 | : projectFaviconUrlAtom({ | |
| 39 | environmentId: props.environmentId, | |
| 35 | 40 | cwd: props.workspaceRoot, |
| 36 | ...(props.faviconPath ? { path: props.faviconPath } : {}), | |
| 37 | }, | |
| 41 | faviconPath: props.faviconPath, | |
| 42 | }), | |
| 38 | 43 | ); |
| 39 | 44 | const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; |
| 45 | // Inline images are self-contained; remote URLs key on their revision so signed-token | |
| 46 | // rotation reuses the disk cache while a changed icon starts from the loading state. | |
| 40 | 47 | const cacheKey = |
| 41 | 48 | renderableFaviconUrl && props.workspaceRoot |
| 42 | ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) | |
| 49 | ? renderableFaviconUrl.startsWith("data:") | |
| 50 | ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) | |
| 51 | : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) | |
| 43 | 52 | : null; |
| 44 | 53 | |
| 45 | 54 | return ( |
| ... | ... | @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { |
| 75 | 84 | }, [faviconRequest]); |
| 76 | 85 | |
| 77 | 86 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 78 | hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", | |
| 87 | props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) | |
| 88 | ? "loaded" | |
| 89 | : "loading", | |
| 79 | 90 | ); |
| 80 | 91 | |
| 81 | 92 | const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; |
| ... | ... | @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { |
| 104 | 115 | {requestIsActive ? ( |
| 105 | 116 | <Image |
| 106 | 117 | key={faviconRequest.faviconUrl} |
| 107 | source={{ | |
| 108 | uri: faviconRequest.faviconUrl, | |
| 109 | cacheKey: faviconRequest.cacheKey, | |
| 110 | }} | |
| 111 | cachePolicy="memory-disk" | |
| 118 | source={ | |
| 119 | faviconRequest.faviconUrl.startsWith("data:") | |
| 120 | ? { uri: faviconRequest.faviconUrl } | |
| 121 | : { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey } | |
| 122 | } | |
| 123 | cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"} | |
| 112 | 124 | recyclingKey={faviconRequest.cacheKey} |
| 113 | 125 | accessibilityLabel={`${props.projectTitle} favicon`} |
| 114 | 126 | style={{ |
apps/mobile/src/connection/environment-cache-store.test.ts+6| ... | ... | @@ -32,6 +32,12 @@ function makeDatabase() { |
| 32 | 32 | const database = MobileDatabase.of({ |
| 33 | 33 | loadCache: (environmentId, kind, cacheKey) => |
| 34 | 34 | Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), |
| 35 | listCache: (kind) => | |
| 36 | Effect.sync(() => | |
| 37 | [...values.entries()] | |
| 38 | .filter(([key]) => key.split(":")[1] === kind) | |
| 39 | .map(([, payload]) => payload), | |
| 40 | ), | |
| 35 | 41 | saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => |
| 36 | 42 | Effect.sync(() => { |
| 37 | 43 | values.set(cacheId(environmentId, kind, cacheKey), payload); |
apps/mobile/src/connection/environment-cache-store.ts+7-4| ... | ... | @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; |
| 15 | 15 | import * as Schema from "effect/Schema"; |
| 16 | 16 | |
| 17 | 17 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 18 | import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 18 | 19 | |
| 19 | 20 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 20 | 21 | // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump |
| ... | ... | @@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: { |
| 115 | 116 | |
| 116 | 117 | export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 117 | 118 | const database = yield* MobileDatabase.MobileDatabase; |
| 119 | attachProjectFaviconDatabase(database); | |
| 118 | 120 | return EnvironmentCacheStore.of({ |
| 119 | 121 | loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => |
| 120 | 122 | loadDecodedCache({ |
| ... | ... | @@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 126 | 128 | decode: decodeStoredShellSnapshot, |
| 127 | 129 | select: (stored) => |
| 128 | 130 | stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), |
| 129 | }), | |
| 131 | }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), | |
| 130 | 132 | ), |
| 131 | 133 | saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { |
| 132 | 134 | const payload = yield* encodeStoredShellSnapshot({ |
| ... | ... | @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 237 | 239 | .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), |
| 238 | 240 | ), |
| 239 | 241 | clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => |
| 240 | database | |
| 241 | .clearEnvironmentCache(environmentId) | |
| 242 | .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), | |
| 242 | Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( | |
| 243 | Effect.andThen(database.clearEnvironmentCache(environmentId)), | |
| 244 | Effect.mapError(mapDatabaseError("clear-environment")), | |
| 245 | ), | |
| 243 | 246 | ), |
| 244 | 247 | }); |
| 245 | 248 | }); |
apps/mobile/src/lib/projectFaviconCache.test.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 3 | ||
| 4 | const native = vi.hoisted(() => ({ | |
| 5 | load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ | |
| 6 | width: options.maxWidth, | |
| 7 | height: options.maxHeight, | |
| 8 | release: vi.fn(), | |
| 9 | })), | |
| 10 | write: vi.fn(async () => {}), | |
| 11 | path: vi.fn(async () => "/cache/thumbnail"), | |
| 12 | read: vi.fn(), | |
| 13 | remove: vi.fn(), | |
| 14 | })); | |
| 15 | vi.mock("expo-image", () => ({ | |
| 16 | Image: { | |
| 17 | loadAsync: native.load, | |
| 18 | writeToCacheAsync: native.write, | |
| 19 | getCachePathAsync: native.path, | |
| 20 | }, | |
| 21 | })); | |
| 22 | vi.mock("expo-file-system", () => ({ | |
| 23 | File: class { | |
| 24 | size = 24_000; | |
| 25 | base64 = native.read; | |
| 26 | delete = native.remove; | |
| 27 | }, | |
| 28 | })); | |
| 29 | ||
| 30 | import { downscaleProjectFavicon } from "./projectFaviconCache"; | |
| 31 | ||
| 32 | const png = "iVBORw0KGgoAAAAA"; | |
| 33 | const image = { url: "https://remote/icon.png" }; | |
| 34 | ||
| 35 | beforeEach(() => { | |
| 36 | vi.clearAllMocks(); | |
| 37 | native.read.mockReset().mockResolvedValue(png); | |
| 38 | native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ | |
| 39 | width: maxWidth, | |
| 40 | height: maxWidth, | |
| 41 | release: vi.fn(), | |
| 42 | })); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("mobile project icon thumbnails", () => { | |
| 46 | it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { | |
| 47 | native.read.mockResolvedValueOnce( | |
| 48 | `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, | |
| 49 | ); | |
| 50 | const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); | |
| 51 | expect(thumbnail).toBe(`data:image/png;base64,${png}`); | |
| 52 | expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); | |
| 53 | expect(native.remove).toHaveBeenCalledTimes(2); | |
| 54 | for (const call of native.load.mock.results) | |
| 55 | expect((await call.value).release).toHaveBeenCalledOnce(); | |
| 56 | }); | |
| 57 | ||
| 58 | it("releases a decoded image when its request was canceled", async () => { | |
| 59 | const controller = new AbortController(); | |
| 60 | const release = vi.fn(); | |
| 61 | native.load.mockImplementationOnce(async () => { | |
| 62 | controller.abort(); | |
| 63 | return { width: 96, height: 96, release }; | |
| 64 | }); | |
| 65 | await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); | |
| 66 | expect(release).toHaveBeenCalledOnce(); | |
| 67 | expect(native.write).not.toHaveBeenCalled(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("rejects an image the native decoder did not downsize", async () => { | |
| 71 | const release = vi.fn(); | |
| 72 | native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); | |
| 73 | await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( | |
| 74 | "not resized", | |
| 75 | ); | |
| 76 | expect(native.write).not.toHaveBeenCalled(); | |
| 77 | expect(release).toHaveBeenCalledOnce(); | |
| 78 | }); | |
| 79 | }); |
apps/mobile/src/lib/projectFaviconCache.ts created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | import { | |
| 2 | createProjectFaviconCache, | |
| 3 | createProjectFaviconImageLoader, | |
| 4 | PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 5 | PROJECT_FAVICON_THUMBNAIL_SIZE, | |
| 6 | type ProjectFaviconEntry, | |
| 7 | } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | ||
| 10 | import * as MobileDatabase from "../persistence/mobile-database"; | |
| 11 | ||
| 12 | const CACHE_KIND = "project-favicon"; | |
| 13 | const CACHE_SCHEMA_VERSION = 1; | |
| 14 | ||
| 15 | let database: MobileDatabase.MobileDatabase["Service"] | undefined; | |
| 16 | ||
| 17 | /** | |
| 18 | * The cache is a module singleton because the favicon atom family holds it outside | |
| 19 | * any Effect runtime. Its rows live in `client_cache`, so the environment cache store | |
| 20 | * hands over the database it already owns instead of the cache re-entering the runtime. | |
| 21 | */ | |
| 22 | export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { | |
| 23 | database = service; | |
| 24 | } | |
| 25 | ||
| 26 | const runDatabase = <A, E>( | |
| 27 | use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>, | |
| 28 | ) => | |
| 29 | database | |
| 30 | ? Effect.runPromise(use(database)) | |
| 31 | : Promise.reject(new Error("Project icon storage is not attached.")); | |
| 32 | ||
| 33 | /** | |
| 34 | * Rasterizes a bitmap that is too large to inline. The native decoder writes the | |
| 35 | * downsized frame to expo-image's disk cache, which is the only encode path it | |
| 36 | * exposes; the temporary entry is removed once its bytes are read. | |
| 37 | */ | |
| 38 | export async function downscaleProjectFavicon( | |
| 39 | image: { readonly url: string }, | |
| 40 | signal: AbortSignal, | |
| 41 | ) { | |
| 42 | const [{ Image }, { File }] = await Promise.all([ | |
| 43 | import("expo-image"), | |
| 44 | import("expo-file-system"), | |
| 45 | ]); | |
| 46 | for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { | |
| 47 | signal.throwIfAborted(); | |
| 48 | const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); | |
| 49 | const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; | |
| 50 | try { | |
| 51 | signal.throwIfAborted(); | |
| 52 | if (decoded.width > size || decoded.height > size) { | |
| 53 | throw new Error("Project icon was not resized."); | |
| 54 | } | |
| 55 | await Image.writeToCacheAsync(decoded, cacheKey); | |
| 56 | const path = await Image.getCachePathAsync(cacheKey); | |
| 57 | if (!path) throw new Error("Project icon thumbnail was not written."); | |
| 58 | const file = new File(path.startsWith("file:") ? path : `file://${path}`); | |
| 59 | try { | |
| 60 | if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; | |
| 61 | const base64 = await file.base64(); | |
| 62 | // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. | |
| 63 | const mimeType = base64.startsWith("/9j/") | |
| 64 | ? "image/jpeg" | |
| 65 | : base64.startsWith("iVBORw0KGgo") | |
| 66 | ? "image/png" | |
| 67 | : null; | |
| 68 | if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); | |
| 69 | const dataUrl = `data:${mimeType};base64,${base64}`; | |
| 70 | if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; | |
| 71 | } finally { | |
| 72 | file.delete(); | |
| 73 | } | |
| 74 | } finally { | |
| 75 | decoded.release(); | |
| 76 | } | |
| 77 | } | |
| 78 | throw new Error("Project icon thumbnail exceeds the cache limit."); | |
| 79 | } | |
| 80 | ||
| 81 | /** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ | |
| 82 | export const projectFaviconCache = createProjectFaviconCache({ | |
| 83 | storage: { | |
| 84 | list: () => | |
| 85 | runDatabase((database) => | |
| 86 | database.listCache(CACHE_KIND).pipe( | |
| 87 | Effect.map((payloads) => | |
| 88 | payloads.flatMap((payload): Array<unknown> => { | |
| 89 | try { | |
| 90 | return [JSON.parse(payload)]; | |
| 91 | } catch { | |
| 92 | return []; | |
| 93 | } | |
| 94 | }), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ), | |
| 98 | put: (key, entry: ProjectFaviconEntry) => | |
| 99 | runDatabase((database) => | |
| 100 | database.saveCache( | |
| 101 | entry.environmentId, | |
| 102 | CACHE_KIND, | |
| 103 | key, | |
| 104 | CACHE_SCHEMA_VERSION, | |
| 105 | JSON.stringify(entry), | |
| 106 | ), | |
| 107 | ), | |
| 108 | remove: (key, entry) => | |
| 109 | runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), | |
| 110 | }, | |
| 111 | load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), | |
| 112 | }); |
apps/mobile/src/persistence/mobile-database.ts+22-1| ... | ... | @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ |
| 16 | 16 | "connection-vcs-refs", |
| 17 | 17 | ] as const; |
| 18 | 18 | |
| 19 | export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); | |
| 19 | export const ClientCacheKind = Schema.Literals([ | |
| 20 | "shell", | |
| 21 | "thread", | |
| 22 | "server-config", | |
| 23 | "vcs-refs", | |
| 24 | "project-favicon", | |
| 25 | ]); | |
| 20 | 26 | export type ClientCacheKind = typeof ClientCacheKind.Type; |
| 21 | 27 | |
| 22 | 28 | export interface ClientCacheSummaryRow { |
| ... | ... | @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ |
| 44 | 50 | "open", |
| 45 | 51 | "migrate", |
| 46 | 52 | "load-cache", |
| 53 | "list-cache", | |
| 47 | 54 | "save-cache", |
| 48 | 55 | "remove-cache", |
| 49 | 56 | "clear-cache-kind", |
| ... | ... | @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< |
| 192 | 199 | kind: ClientCacheKind, |
| 193 | 200 | cacheKey: string, |
| 194 | 201 | ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>; |
| 202 | readonly listCache: ( | |
| 203 | kind: ClientCacheKind, | |
| 204 | ) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>; | |
| 195 | 205 | readonly saveCache: ( |
| 196 | 206 | environmentId: EnvironmentId, |
| 197 | 207 | kind: ClientCacheKind, |
| ... | ... | @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { |
| 292 | 302 | catch: databaseError("load-cache"), |
| 293 | 303 | }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), |
| 294 | 304 | ), |
| 305 | listCache: Effect.fn("MobileDatabase.listCache")((kind) => | |
| 306 | Effect.tryPromise({ | |
| 307 | try: () => | |
| 308 | database.getAllAsync<{ readonly payload: string }>( | |
| 309 | "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", | |
| 310 | kind, | |
| 311 | ), | |
| 312 | catch: databaseError("list-cache"), | |
| 313 | }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), | |
| 314 | ), | |
| 295 | 315 | saveCache: Effect.fn("MobileDatabase.saveCache")( |
| 296 | 316 | (environmentId, kind, cacheKey, schemaVersion, payload) => |
| 297 | 317 | Effect.tryPromise({ |
| ... | ... | @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] |
| 405 | 425 | const fail = Effect.fail(error); |
| 406 | 426 | return MobileDatabase.of({ |
| 407 | 427 | loadCache: () => fail, |
| 428 | listCache: () => fail, | |
| 408 | 429 | saveCache: () => fail, |
| 409 | 430 | removeCache: () => fail, |
| 410 | 431 | clearCacheKind: () => fail, |
apps/mobile/src/state/assets.ts+9-1| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | import { |
| 7 | 7 | assetUrlStateFromResult, |
| 8 | 8 | createAssetEnvironmentAtoms, |
| 9 | createProjectFaviconUrlAtomFamily, | |
| 9 | 10 | EMPTY_ASSET_URL_ATOM, |
| 10 | 11 | } from "@t3tools/client-runtime/state/assets"; |
| 11 | 12 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| ... | ... | @@ -15,14 +16,21 @@ import { useCallback } from "react"; |
| 15 | 16 | |
| 16 | 17 | import { environmentCatalog } from "../connection/catalog"; |
| 17 | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 18 | 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 19 | import { usePreparedConnection } from "./session"; | |
| 21 | import { environmentSession, usePreparedConnection } from "./session"; | |
| 20 | 22 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| 21 | 23 | |
| 22 | 24 | export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; |
| 23 | 25 | |
| 24 | 26 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 25 | 27 | |
| 28 | export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | |
| 29 | imageCache: projectFaviconCache, | |
| 30 | createUrl: assetEnvironment.createUrl, | |
| 31 | preparedConnection: environmentSession.preparedConnectionValueAtom, | |
| 32 | }); | |
| 33 | ||
| 26 | 34 | const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( |
| 27 | 35 | Atom.withLabel("mobile-asset-connection-state:empty"), |
| 28 | 36 | ); |
apps/mobile/src/state/client-cache-state.ts+7-1| ... | ... | @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | 5 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; |
| 6 | import { projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 6 | 7 | import * as Runtime from "../lib/runtime"; |
| 7 | 8 | |
| 8 | 9 | export interface EnvironmentClientCacheSummary { |
| ... | ... | @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime |
| 71 | 72 | |
| 72 | 73 | export const clearClientCacheAtom = clientCacheRuntime |
| 73 | 74 | .fn((scope: ClientCacheClearScope, get) => |
| 74 | MobileDatabase.pipe( | |
| 75 | Effect.promise(() => | |
| 76 | scope.type === "all" | |
| 77 | ? projectFaviconCache.clearAll() | |
| 78 | : projectFaviconCache.clearEnvironment(scope.environmentId), | |
| 79 | ).pipe( | |
| 80 | Effect.andThen(MobileDatabase), | |
| 75 | 81 | Effect.flatMap((database) => |
| 76 | 82 | scope.type === "all" |
| 77 | 83 | ? database.clearAllCaches |
apps/web/src/assets/projectFaviconCache.ts created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | import { | |
| 2 | createProjectFaviconCache, | |
| 3 | createProjectFaviconImageLoader, | |
| 4 | PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 5 | PROJECT_FAVICON_THUMBNAIL_SIZE, | |
| 6 | } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 7 | ||
| 8 | const DATABASE_NAME = "t3code:project-favicons"; | |
| 9 | const DATABASE_VERSION = 2; | |
| 10 | const STORE_NAME = "images"; | |
| 11 | let database: Promise<IDBDatabase> | undefined; | |
| 12 | ||
| 13 | function openDatabase() { | |
| 14 | return (database ??= new Promise<IDBDatabase>((resolve, reject) => { | |
| 15 | const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); | |
| 16 | request.addEventListener("upgradeneeded", () => { | |
| 17 | for (const name of request.result.objectStoreNames) { | |
| 18 | if (name !== STORE_NAME) request.result.deleteObjectStore(name); | |
| 19 | } | |
| 20 | if (!request.result.objectStoreNames.contains(STORE_NAME)) { | |
| 21 | request.result.createObjectStore(STORE_NAME); | |
| 22 | } | |
| 23 | }); | |
| 24 | request.addEventListener("success", () => resolve(request.result)); | |
| 25 | request.addEventListener("error", () => reject(request.error)); | |
| 26 | request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); | |
| 27 | })); | |
| 28 | } | |
| 29 | ||
| 30 | function completed(transaction: IDBTransaction) { | |
| 31 | return new Promise<void>((resolve, reject) => { | |
| 32 | transaction.addEventListener("complete", () => resolve()); | |
| 33 | transaction.addEventListener("abort", () => reject(transaction.error)); | |
| 34 | transaction.addEventListener("error", () => reject(transaction.error)); | |
| 35 | }); | |
| 36 | } | |
| 37 | ||
| 38 | async function withStore<A>( | |
| 39 | mode: IDBTransactionMode, | |
| 40 | use: (store: IDBObjectStore) => IDBRequest<A> | void, | |
| 41 | ) { | |
| 42 | const transaction = (await openDatabase()).transaction(STORE_NAME, mode); | |
| 43 | const request = use(transaction.objectStore(STORE_NAME)); | |
| 44 | await completed(transaction); | |
| 45 | return request?.result; | |
| 46 | } | |
| 47 | ||
| 48 | /** Rasterizes a bitmap that is too large to inline, retrying at half size. */ | |
| 49 | export async function downscaleProjectFavicon( | |
| 50 | image: { readonly mimeType: string; readonly bytes: Uint8Array<ArrayBuffer> }, | |
| 51 | signal: AbortSignal, | |
| 52 | ) { | |
| 53 | const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); | |
| 54 | try { | |
| 55 | signal.throwIfAborted(); | |
| 56 | const canvas = document.createElement("canvas"); | |
| 57 | for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { | |
| 58 | const scale = Math.min(1, size / bitmap.width, size / bitmap.height); | |
| 59 | canvas.width = Math.max(1, Math.round(bitmap.width * scale)); | |
| 60 | canvas.height = Math.max(1, Math.round(bitmap.height * scale)); | |
| 61 | const context = canvas.getContext("2d"); | |
| 62 | if (!context) throw new Error("Canvas is unavailable."); | |
| 63 | context.clearRect(0, 0, canvas.width, canvas.height); | |
| 64 | context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); | |
| 65 | const dataUrl = canvas.toDataURL("image/webp", 0.85); | |
| 66 | if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; | |
| 67 | } | |
| 68 | throw new Error("Project icon thumbnail exceeds the cache limit."); | |
| 69 | } finally { | |
| 70 | bitmap.close(); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | export const projectFaviconCache = createProjectFaviconCache({ | |
| 75 | storage: { | |
| 76 | list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], | |
| 77 | put: async (key, entry) => { | |
| 78 | await withStore("readwrite", (store) => store.put(entry, key)); | |
| 79 | }, | |
| 80 | remove: async (key) => { | |
| 81 | await withStore("readwrite", (store) => store.delete(key)); | |
| 82 | }, | |
| 83 | }, | |
| 84 | load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), | |
| 85 | }); |
apps/web/src/components/ProjectFavicon.test.tsx+10-8| ... | ... | @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon" |
| 5 | 5 | |
| 6 | 6 | const testState = vi.hoisted(() => ({ |
| 7 | 7 | faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", |
| 8 | lastResource: null as unknown, | |
| 8 | lastTarget: null as unknown, | |
| 9 | 9 | })); |
| 10 | 10 | |
| 11 | 11 | const hooks = vi.hoisted(() => { |
| ... | ... | @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ |
| 57 | 57 | DynamicIcon: "dynamic-icon", |
| 58 | 58 | iconNames: ["alarm-clock", "folder-code"], |
| 59 | 59 | })); |
| 60 | vi.mock("../assets/assetUrls", () => ({ | |
| 61 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { | |
| 62 | testState.lastResource = resource; | |
| 63 | return { _tag: "Success", url: testState.faviconUrl }; | |
| 60 | vi.mock("@effect/atom-react", () => ({ | |
| 61 | useAtomValue: () => testState.faviconUrl, | |
| 62 | })); | |
| 63 | vi.mock("../state/assets", () => ({ | |
| 64 | projectFaviconUrlAtom: (input: unknown) => { | |
| 65 | testState.lastTarget = input; | |
| 64 | 66 | }, |
| 65 | 67 | })); |
| 66 | 68 | |
| ... | ... | @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { |
| 212 | 214 | faviconPath: "brand/icon.svg", |
| 213 | 215 | }); |
| 214 | 216 | |
| 215 | expect(testState.lastResource).toEqual({ | |
| 216 | _tag: "project-favicon", | |
| 217 | expect(testState.lastTarget).toMatchObject({ | |
| 218 | environmentId: "environment-test", | |
| 217 | 219 | cwd: "/workspace-test", |
| 218 | path: "brand/icon.svg", | |
| 220 | faviconPath: "brand/icon.svg", | |
| 219 | 221 | }); |
| 220 | 222 | }); |
| 221 | 223 | }); |
apps/web/src/components/ProjectFavicon.tsx+7-27| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; |
| 2 | 2 | import { |
| 3 | getProjectFaviconCacheKey, | |
| 3 | getProjectFaviconResourceKey, | |
| 4 | 4 | isProjectFaviconFallbackUrl, |
| 5 | 5 | } from "@t3tools/shared/projectFavicon"; |
| 6 | 6 | import { |
| ... | ... | @@ -30,12 +30,12 @@ import { |
| 30 | 30 | import type { IconName } from "lucide-react/dynamic"; |
| 31 | 31 | import type { ComponentType } from "react"; |
| 32 | 32 | import { lazy, Suspense, useState } from "react"; |
| 33 | import { useAssetUrlState } from "../assets/assetUrls"; | |
| 33 | import { useAtomValue } from "@effect/atom-react"; | |
| 34 | import { projectFaviconUrlAtom } from "../state/assets"; | |
| 34 | 35 | import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; |
| 35 | 36 | import { projectIconColorClassName } from "../projectIconColors"; |
| 36 | 37 | import { cn } from "~/lib/utils"; |
| 37 | 38 | |
| 38 | const loadedProjectFaviconSrcs = new Map<string, string>(); | |
| 39 | 39 | const DynamicIcon = lazy(() => |
| 40 | 40 | import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), |
| 41 | 41 | ); |
| ... | ... | @@ -103,8 +103,7 @@ export function ProjectFavicon(input: { |
| 103 | 103 | className?: string | undefined; |
| 104 | 104 | fallbackIcon?: ComponentType<{ className?: string }>; |
| 105 | 105 | }) { |
| 106 | const state = useProjectFaviconAsset(input); | |
| 107 | const src = state._tag === "Success" ? state.url : null; | |
| 106 | const src = useAtomValue(projectFaviconUrlAtom(input)); | |
| 108 | 107 | if (input.projectIcon?.kind === "emoji") { |
| 109 | 108 | return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />; |
| 110 | 109 | } |
| ... | ... | @@ -150,12 +149,11 @@ export function ProjectFavicon(input: { |
| 150 | 149 | ); |
| 151 | 150 | } |
| 152 | 151 | |
| 153 | const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); | |
| 152 | const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath); | |
| 154 | 153 | |
| 155 | 154 | return ( |
| 156 | 155 | <ProjectFaviconImage |
| 157 | 156 | key={cacheKey} |
| 158 | cacheKey={cacheKey} | |
| 159 | 157 | src={src} |
| 160 | 158 | className={input.className} |
| 161 | 159 | fallbackIcon={FallbackIcon} |
| ... | ... | @@ -165,18 +163,6 @@ export function ProjectFavicon(input: { |
| 165 | 163 | ); |
| 166 | 164 | } |
| 167 | 165 | |
| 168 | export function useProjectFaviconAsset(input: { | |
| 169 | readonly environmentId: EnvironmentId; | |
| 170 | readonly cwd: string; | |
| 171 | readonly faviconPath?: string | null | undefined; | |
| 172 | }) { | |
| 173 | return useAssetUrlState(input.environmentId, { | |
| 174 | _tag: "project-favicon", | |
| 175 | cwd: input.cwd, | |
| 176 | ...(input.faviconPath ? { path: input.faviconPath } : {}), | |
| 177 | }); | |
| 178 | } | |
| 179 | ||
| 180 | 166 | function ProjectFaviconFallback({ |
| 181 | 167 | className, |
| 182 | 168 | colorClassName, |
| ... | ... | @@ -207,28 +193,23 @@ function ProjectFaviconFallback({ |
| 207 | 193 | } |
| 208 | 194 | |
| 209 | 195 | function ProjectFaviconImage({ |
| 210 | cacheKey, | |
| 211 | 196 | src, |
| 212 | 197 | className, |
| 213 | 198 | fallbackIcon: FallbackIcon, |
| 214 | 199 | fallbackEmoji, |
| 215 | 200 | fallbackColorClassName, |
| 216 | 201 | }: { |
| 217 | readonly cacheKey: string; | |
| 218 | 202 | readonly src: string; |
| 219 | 203 | readonly className?: string | undefined; |
| 220 | 204 | readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; |
| 221 | 205 | readonly fallbackEmoji?: string | undefined; |
| 222 | 206 | readonly fallbackColorClassName?: string | undefined; |
| 223 | 207 | }) { |
| 224 | const [displayedSrc, setDisplayedSrc] = useState<string | null>( | |
| 225 | () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, | |
| 208 | const [displayedSrc, setDisplayedSrc] = useState<string | null>(() => | |
| 209 | src.startsWith("data:image/") ? src : null, | |
| 226 | 210 | ); |
| 227 | 211 | const isLoading = displayedSrc !== src; |
| 228 | 212 | const handleLoadError = (failedSrc: string) => { |
| 229 | if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { | |
| 230 | loadedProjectFaviconSrcs.delete(cacheKey); | |
| 231 | } | |
| 232 | 213 | setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); |
| 233 | 214 | }; |
| 234 | 215 | |
| ... | ... | @@ -256,7 +237,6 @@ function ProjectFaviconImage({ |
| 256 | 237 | alt="" |
| 257 | 238 | className="hidden" |
| 258 | 239 | onLoad={() => { |
| 259 | loadedProjectFaviconSrcs.set(cacheKey, src); | |
| 260 | 240 | setDisplayedSrc(src); |
| 261 | 241 | }} |
| 262 | 242 | onError={() => handleLoadError(src)} |
apps/web/src/components/preview/PreviewView.test.tsx+2-1| ... | ... | @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ |
| 56 | 56 | useThreadRecentHistory: () => EMPTY_HISTORY, |
| 57 | 57 | })); |
| 58 | 58 | |
| 59 | vi.mock("~/state/session", () => ({ | |
| 59 | vi.mock("~/state/session", async (importOriginal) => ({ | |
| 60 | ...(await importOriginal<typeof import("~/state/session")>()), | |
| 60 | 61 | readPreparedConnection: mocks.readPreparedConnection, |
| 61 | 62 | })); |
| 62 | 63 |
apps/web/src/connection/storage.ts+3| ... | ... | @@ -33,6 +33,7 @@ import * as Option from "effect/Option"; |
| 33 | 33 | import * as Ref from "effect/Ref"; |
| 34 | 34 | import * as Schema from "effect/Schema"; |
| 35 | 35 | import * as Semaphore from "effect/Semaphore"; |
| 36 | import { projectFaviconCache } from "../assets/projectFaviconCache"; | |
| 36 | 37 | |
| 37 | 38 | const DATABASE_NAME = "t3code:connection-runtime"; |
| 38 | 39 | const DATABASE_VERSION = 4; |
| ... | ... | @@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 461 | 462 | const cacheStore = EnvironmentCacheStore.of({ |
| 462 | 463 | loadShell: (environmentId) => |
| 463 | 464 | readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( |
| 465 | Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), | |
| 464 | 466 | Effect.flatMap((raw) => { |
| 465 | 467 | if (typeof raw !== "string") { |
| 466 | 468 | return Effect.succeed(Option.none()); |
| ... | ... | @@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 638 | 640 | clear: (environmentId) => |
| 639 | 641 | Effect.all( |
| 640 | 642 | [ |
| 643 | Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), | |
| 641 | 644 | removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), |
| 642 | 645 | removeDatabaseValuesInRange( |
| 643 | 646 | database, |
apps/web/src/state/assets.ts+12-1| ... | ... | @@ -1,5 +1,16 @@ |
| 1 | import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; | |
| 1 | import { | |
| 2 | createAssetEnvironmentAtoms, | |
| 3 | createProjectFaviconUrlAtomFamily, | |
| 4 | } from "@t3tools/client-runtime/state/assets"; | |
| 2 | 5 | |
| 3 | 6 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 7 | import { projectFaviconCache } from "../assets/projectFaviconCache"; | |
| 8 | import { environmentSession } from "./session"; | |
| 4 | 9 | |
| 5 | 10 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 11 | ||
| 12 | export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | |
| 13 | imageCache: projectFaviconCache, | |
| 14 | createUrl: assetEnvironment.createUrl, | |
| 15 | preparedConnection: environmentSession.preparedConnectionValueAtom, | |
| 16 | }); |
packages/client-runtime/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./project-favicon-cache": { | |
| 7 | "types": "./src/projectFaviconCache.ts", | |
| 8 | "default": "./src/projectFaviconCache.ts" | |
| 9 | }, | |
| 6 | 10 | "./connection": { |
| 7 | 11 | "types": "./src/connection/index.ts", |
| 8 | 12 | "default": "./src/connection/index.ts" |
packages/client-runtime/src/projectFaviconCache.test.ts created+323| ... | ... | @@ -0,0 +1,323 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { | |
| 5 | createProjectFaviconCache, | |
| 6 | createProjectFaviconImageLoader, | |
| 7 | PROJECT_FAVICON_CACHE_MAX_BYTES, | |
| 8 | PROJECT_FAVICON_CACHE_MAX_ENTRIES, | |
| 9 | PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 10 | PROJECT_FAVICON_MAX_SOURCE_BYTES, | |
| 11 | type ProjectFaviconEntry, | |
| 12 | type ProjectFaviconStorage, | |
| 13 | } from "./projectFaviconCache.ts"; | |
| 14 | ||
| 15 | const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; | |
| 16 | const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; | |
| 17 | const image = "data:image/png;base64,aWNvbg=="; | |
| 18 | const replacement = "data:image/png;base64,bmV3"; | |
| 19 | const signal = () => new AbortController().signal; | |
| 20 | ||
| 21 | function deferred<A>() { | |
| 22 | let resolve!: (value: A) => void; | |
| 23 | const promise = new Promise<A>((done) => { | |
| 24 | resolve = done; | |
| 25 | }); | |
| 26 | return { promise, resolve }; | |
| 27 | } | |
| 28 | ||
| 29 | function fixture() { | |
| 30 | const records = new Map<string, ProjectFaviconEntry>(); | |
| 31 | const load = vi.fn(async () => image); | |
| 32 | const storage: ProjectFaviconStorage = { | |
| 33 | list: async () => [...records.values()], | |
| 34 | put: async (key, entry) => { | |
| 35 | records.set(key, entry); | |
| 36 | }, | |
| 37 | remove: async (key) => { | |
| 38 | records.delete(key); | |
| 39 | }, | |
| 40 | }; | |
| 41 | return { | |
| 42 | storage, | |
| 43 | load, | |
| 44 | records, | |
| 45 | cache: createProjectFaviconCache({ storage, load }), | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | describe("persistent project favicon cache", () => { | |
| 50 | it("restores image bytes in a fresh client before any remote response", async () => { | |
| 51 | const { cache, storage, load } = fixture(); | |
| 52 | expect(await cache.resolve(target, url, signal())).toBe(image); | |
| 53 | await cache.flush(); | |
| 54 | const reloaded = createProjectFaviconCache({ storage, load }); | |
| 55 | await reloaded.hydrate(); | |
| 56 | expect(reloaded.peek(target)).toBe(image); | |
| 57 | expect(await reloaded.resolve(target, null, signal())).toBe(image); | |
| 58 | expect(load).toHaveBeenCalledTimes(1); | |
| 59 | }); | |
| 60 | ||
| 61 | it("reuses the image when signed URLs or connection origins change", async () => { | |
| 62 | const { cache, load } = fixture(); | |
| 63 | await cache.resolve(target, url, signal()); | |
| 64 | expect( | |
| 65 | await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), | |
| 66 | ).toBe(image); | |
| 67 | expect(load).toHaveBeenCalledTimes(1); | |
| 68 | }); | |
| 69 | ||
| 70 | it("keeps the old image during refresh and failures, then persists its replacement", async () => { | |
| 71 | const { cache, load, storage } = fixture(); | |
| 72 | await cache.resolve(target, url, signal()); | |
| 73 | const next = deferred<string>(); | |
| 74 | load.mockImplementationOnce(() => next.promise); | |
| 75 | const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); | |
| 76 | expect(cache.peek(target)).toBe(image); | |
| 77 | next.resolve(replacement); | |
| 78 | expect(await refreshing).toBe(replacement); | |
| 79 | load.mockRejectedValueOnce(new Error("offline")); | |
| 80 | expect(await cache.resolve(target, url, signal())).toBe(replacement); | |
| 81 | await cache.flush(); | |
| 82 | expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( | |
| 83 | replacement, | |
| 84 | ); | |
| 85 | }); | |
| 86 | ||
| 87 | it("persists confirmed removal and ignores an aborted older download", async () => { | |
| 88 | const { cache, load, storage } = fixture(); | |
| 89 | await cache.resolve(target, url, signal()); | |
| 90 | const next = deferred<string>(); | |
| 91 | const started = deferred<void>(); | |
| 92 | load.mockImplementationOnce(() => { | |
| 93 | started.resolve(); | |
| 94 | return next.promise; | |
| 95 | }); | |
| 96 | const controller = new AbortController(); | |
| 97 | const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); | |
| 98 | await started.promise; | |
| 99 | controller.abort(); | |
| 100 | expect( | |
| 101 | await cache.resolve( | |
| 102 | target, | |
| 103 | "https://remote.test/api/assets/token/project-favicon-missing", | |
| 104 | signal(), | |
| 105 | ), | |
| 106 | ).toBeNull(); | |
| 107 | next.resolve(replacement); | |
| 108 | await pending; | |
| 109 | await cache.flush(); | |
| 110 | expect( | |
| 111 | await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), | |
| 112 | ).toBeNull(); | |
| 113 | }); | |
| 114 | ||
| 115 | it("isolates environments, workspaces, and icon selections", async () => { | |
| 116 | const { cache } = fixture(); | |
| 117 | await cache.resolve(target, url, signal()); | |
| 118 | expect(cache.peek({ ...target, faviconPath: null })).toBe(image); | |
| 119 | expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); | |
| 120 | expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); | |
| 121 | expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); | |
| 122 | }); | |
| 123 | ||
| 124 | it.each([ | |
| 125 | { | |
| 126 | scope: "one environment", | |
| 127 | clear: (cache: ReturnType<typeof fixture>["cache"]) => | |
| 128 | cache.clearEnvironment(target.environmentId), | |
| 129 | remaining: 1, | |
| 130 | }, | |
| 131 | { | |
| 132 | scope: "every environment", | |
| 133 | clear: (cache: ReturnType<typeof fixture>["cache"]) => cache.clearAll(), | |
| 134 | remaining: 0, | |
| 135 | }, | |
| 136 | ])( | |
| 137 | "does not restore images for $scope removed during a download", | |
| 138 | async ({ clear, remaining }) => { | |
| 139 | const { cache, load, records } = fixture(); | |
| 140 | const other = { ...target, environmentId: EnvironmentId.make("other") }; | |
| 141 | await cache.resolve(other, url, signal()); | |
| 142 | const next = deferred<string>(); | |
| 143 | const started = deferred<void>(); | |
| 144 | load.mockImplementationOnce(() => { | |
| 145 | started.resolve(); | |
| 146 | return next.promise; | |
| 147 | }); | |
| 148 | const pending = cache.resolve(target, url, signal()); | |
| 149 | await started.promise; | |
| 150 | await clear(cache); | |
| 151 | next.resolve(image); | |
| 152 | await pending; | |
| 153 | await cache.flush(); | |
| 154 | expect(cache.peek(target)).toBeNull(); | |
| 155 | expect(records.size).toBe(remaining); | |
| 156 | }, | |
| 157 | ); | |
| 158 | ||
| 159 | it("discards a download that starts while the environment is being cleared", async () => { | |
| 160 | const records = new Map<string, ProjectFaviconEntry>(); | |
| 161 | const removal = deferred<void>(); | |
| 162 | const load = vi.fn(async () => image); | |
| 163 | const storage: ProjectFaviconStorage = { | |
| 164 | list: async () => [...records.values()], | |
| 165 | put: async (key, entry) => { | |
| 166 | records.set(key, entry); | |
| 167 | }, | |
| 168 | remove: async (key) => { | |
| 169 | await removal.promise; | |
| 170 | records.delete(key); | |
| 171 | }, | |
| 172 | }; | |
| 173 | const cache = createProjectFaviconCache({ storage, load }); | |
| 174 | await cache.resolve(target, url, signal()); | |
| 175 | await cache.flush(); | |
| 176 | const clearing = cache.clearEnvironment(target.environmentId); | |
| 177 | await Promise.resolve(); | |
| 178 | const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); | |
| 179 | removal.resolve(); | |
| 180 | await clearing; | |
| 181 | expect(records.size).toBe(0); | |
| 182 | expect(cache.peek(target)).toBeNull(); | |
| 183 | expect(load).toHaveBeenCalledTimes(1); | |
| 184 | expect(await late).toBe(image); | |
| 185 | expect(load).toHaveBeenCalledTimes(2); | |
| 186 | }); | |
| 187 | ||
| 188 | it("bounds individual images, total bytes, and entry count in storage", async () => { | |
| 189 | const { cache, load, records } = fixture(); | |
| 190 | load.mockResolvedValueOnce( | |
| 191 | `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, | |
| 192 | ); | |
| 193 | expect(await cache.resolve(target, url, signal())).toBe(url); | |
| 194 | expect(cache.peek(target)).toBeNull(); | |
| 195 | const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; | |
| 196 | load.mockResolvedValue(large); | |
| 197 | for (let i = 0; i < 40; i++) { | |
| 198 | await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); | |
| 199 | } | |
| 200 | await cache.flush(); | |
| 201 | expect( | |
| 202 | [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), | |
| 203 | ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); | |
| 204 | expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); | |
| 205 | expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); | |
| 206 | load.mockResolvedValue(image); | |
| 207 | for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { | |
| 208 | await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); | |
| 209 | } | |
| 210 | await cache.flush(); | |
| 211 | expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); | |
| 212 | expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); | |
| 213 | }); | |
| 214 | ||
| 215 | it("skips corrupt records and tolerates unavailable storage", async () => { | |
| 216 | const corrupt = createProjectFaviconCache({ | |
| 217 | storage: { | |
| 218 | list: async () => [ | |
| 219 | { ...target, faviconPath: null, revision: "r", dataUrl: image }, | |
| 220 | { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, | |
| 221 | "garbage", | |
| 222 | ], | |
| 223 | put: async () => {}, | |
| 224 | remove: async () => {}, | |
| 225 | }, | |
| 226 | load: async () => replacement, | |
| 227 | }); | |
| 228 | await corrupt.hydrate(); | |
| 229 | expect(corrupt.peek(target)).toBe(image); | |
| 230 | expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); | |
| 231 | ||
| 232 | const unavailable = createProjectFaviconCache({ | |
| 233 | storage: { | |
| 234 | list: async () => { | |
| 235 | throw new Error("storage unavailable"); | |
| 236 | }, | |
| 237 | put: async () => { | |
| 238 | throw new Error("quota exceeded"); | |
| 239 | }, | |
| 240 | remove: async () => { | |
| 241 | throw new Error("quota exceeded"); | |
| 242 | }, | |
| 243 | }, | |
| 244 | load: async () => image, | |
| 245 | }); | |
| 246 | expect(await unavailable.resolve(target, url, signal())).toBe(image); | |
| 247 | await unavailable.flush(); | |
| 248 | expect(unavailable.peek(target)).toBe(image); | |
| 249 | }); | |
| 250 | }); | |
| 251 | ||
| 252 | describe("project favicon image loader", () => { | |
| 253 | const svg = | |
| 254 | '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16"/></svg>'; | |
| 255 | const svgBase64 = btoa(svg); | |
| 256 | ||
| 257 | function loader(response: Response, downscale = vi.fn(async () => replacement)) { | |
| 258 | return { | |
| 259 | downscale, | |
| 260 | load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), | |
| 261 | }; | |
| 262 | } | |
| 263 | ||
| 264 | it("inlines small icons exactly as served without rasterizing", async () => { | |
| 265 | const { load, downscale } = loader( | |
| 266 | new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), | |
| 267 | ); | |
| 268 | expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); | |
| 269 | expect(downscale).not.toHaveBeenCalled(); | |
| 270 | }); | |
| 271 | ||
| 272 | it("falls back to the file extension when the response has no image type", async () => { | |
| 273 | const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); | |
| 274 | expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); | |
| 275 | }); | |
| 276 | ||
| 277 | it("downscales large bitmaps and refuses large vector icons", async () => { | |
| 278 | const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); | |
| 279 | const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); | |
| 280 | expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( | |
| 281 | replacement, | |
| 282 | ); | |
| 283 | expect(bitmap.downscale).toHaveBeenCalledWith( | |
| 284 | expect.objectContaining({ mimeType: "image/png", bytes }), | |
| 285 | expect.any(AbortSignal), | |
| 286 | ); | |
| 287 | const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); | |
| 288 | await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); | |
| 289 | expect(vector.downscale).not.toHaveBeenCalled(); | |
| 290 | }); | |
| 291 | ||
| 292 | it("stops reading a response that exceeds the source limit", async () => { | |
| 293 | let pulled = 0; | |
| 294 | const chunk = new Uint8Array(1024 * 1024); | |
| 295 | const stream = new ReadableStream<Uint8Array>({ | |
| 296 | pull(controller) { | |
| 297 | pulled += 1; | |
| 298 | controller.enqueue(chunk); | |
| 299 | }, | |
| 300 | }); | |
| 301 | const { load, downscale } = loader( | |
| 302 | new Response(stream, { headers: { "content-type": "image/png" } }), | |
| 303 | ); | |
| 304 | await expect(load(url, signal())).rejects.toThrow("too large"); | |
| 305 | expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); | |
| 306 | expect(downscale).not.toHaveBeenCalled(); | |
| 307 | const declared = loader( | |
| 308 | new Response("x", { | |
| 309 | headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, | |
| 310 | }), | |
| 311 | ); | |
| 312 | await expect(declared.load(url, signal())).rejects.toThrow("too large"); | |
| 313 | }); | |
| 314 | ||
| 315 | it("rejects failed responses and non-image payloads", async () => { | |
| 316 | const failed = loader(new Response("nope", { status: 404 })); | |
| 317 | await expect(failed.load(url, signal())).rejects.toThrow("404"); | |
| 318 | const html = loader(new Response("<html/>", { headers: { "content-type": "text/html" } })); | |
| 319 | await expect( | |
| 320 | html.load("https://remote.test/api/assets/t/v1-favicon", signal()), | |
| 321 | ).rejects.toThrow("no image type"); | |
| 322 | }); | |
| 323 | }); |
packages/client-runtime/src/projectFaviconCache.ts created+263| ... | ... | @@ -0,0 +1,263 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { mediaMimeType } from "@t3tools/shared/filePreview"; | |
| 3 | import { | |
| 4 | getProjectFaviconCacheKey, | |
| 5 | getProjectFaviconResourceKey, | |
| 6 | isProjectFaviconFallbackUrl, | |
| 7 | } from "@t3tools/shared/projectFavicon"; | |
| 8 | import * as Encoding from "effect/Encoding"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | ||
| 12 | export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; | |
| 13 | export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; | |
| 14 | /** Larger sources are not worth decoding for an icon and are left to the remote URL. */ | |
| 15 | export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; | |
| 16 | export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; | |
| 17 | export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; | |
| 18 | ||
| 19 | export interface ProjectFaviconTarget { | |
| 20 | readonly environmentId: EnvironmentId; | |
| 21 | readonly cwd: string; | |
| 22 | readonly faviconPath?: string | null | undefined; | |
| 23 | } | |
| 24 | ||
| 25 | const ImageDataUrl = Schema.String.check( | |
| 26 | Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), | |
| 27 | Schema.isPattern( | |
| 28 | /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, | |
| 29 | ), | |
| 30 | ); | |
| 31 | const Entry = Schema.Struct({ | |
| 32 | environmentId: EnvironmentId, | |
| 33 | cwd: Schema.String, | |
| 34 | faviconPath: Schema.NullOr(Schema.String), | |
| 35 | revision: Schema.String, | |
| 36 | dataUrl: ImageDataUrl, | |
| 37 | }); | |
| 38 | export type ProjectFaviconEntry = typeof Entry.Type; | |
| 39 | const decodeEntry = Schema.decodeUnknownOption(Entry); | |
| 40 | const isImageDataUrl = Schema.is(ImageDataUrl); | |
| 41 | ||
| 42 | function keyFor(target: ProjectFaviconTarget) { | |
| 43 | return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); | |
| 44 | } | |
| 45 | ||
| 46 | export interface ProjectFaviconStorage { | |
| 47 | /** Every persisted record; entries that fail validation are ignored. */ | |
| 48 | readonly list: () => Promise<ReadonlyArray<unknown>>; | |
| 49 | readonly put: (key: string, entry: ProjectFaviconEntry) => Promise<void>; | |
| 50 | readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise<void>; | |
| 51 | } | |
| 52 | ||
| 53 | async function readBounded(response: Response, maxBytes: number) { | |
| 54 | const declared = Number(response.headers.get("content-length")); | |
| 55 | if (declared > maxBytes) throw new Error("Project icon is too large to decode."); | |
| 56 | if (!response.body) { | |
| 57 | const bytes = new Uint8Array<ArrayBuffer>(await response.arrayBuffer()); | |
| 58 | if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); | |
| 59 | return bytes; | |
| 60 | } | |
| 61 | const reader = response.body.getReader(); | |
| 62 | const chunks: Array<Uint8Array> = []; | |
| 63 | let total = 0; | |
| 64 | try { | |
| 65 | for (;;) { | |
| 66 | const { done, value } = await reader.read(); | |
| 67 | if (done) break; | |
| 68 | total += value.byteLength; | |
| 69 | if (total > maxBytes) throw new Error("Project icon is too large to decode."); | |
| 70 | chunks.push(value); | |
| 71 | } | |
| 72 | } finally { | |
| 73 | reader.cancel().catch(() => {}); | |
| 74 | } | |
| 75 | const bytes = new Uint8Array<ArrayBuffer>(new ArrayBuffer(total)); | |
| 76 | let offset = 0; | |
| 77 | for (const chunk of chunks) { | |
| 78 | bytes.set(chunk, offset); | |
| 79 | offset += chunk.byteLength; | |
| 80 | } | |
| 81 | return bytes; | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs | |
| 86 | * and small bitmaps are stored exactly as served. Larger bitmaps go through the | |
| 87 | * platform downscaler; larger SVGs stay remote because rasterizing them without | |
| 88 | * intrinsic dimensions is unreliable. | |
| 89 | */ | |
| 90 | export function createProjectFaviconImageLoader(input: { | |
| 91 | readonly fetch?: typeof fetch; | |
| 92 | readonly downscale: ( | |
| 93 | image: { | |
| 94 | readonly url: string; | |
| 95 | readonly mimeType: string; | |
| 96 | readonly bytes: Uint8Array<ArrayBuffer>; | |
| 97 | }, | |
| 98 | signal: AbortSignal, | |
| 99 | ) => Promise<string>; | |
| 100 | }) { | |
| 101 | const fetchImpl = input.fetch ?? globalThis.fetch; | |
| 102 | return async (url: string, signal: AbortSignal): Promise<string> => { | |
| 103 | const response = await fetchImpl(url, { signal }); | |
| 104 | if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); | |
| 105 | const contentType = response.headers | |
| 106 | .get("content-type") | |
| 107 | ?.split(";", 1)[0] | |
| 108 | ?.trim() | |
| 109 | .toLowerCase(); | |
| 110 | const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); | |
| 111 | if (!mimeType) throw new Error("Project icon has no image type."); | |
| 112 | const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); | |
| 113 | signal.throwIfAborted(); | |
| 114 | const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; | |
| 115 | if (isImageDataUrl(dataUrl)) return dataUrl; | |
| 116 | if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); | |
| 117 | return input.downscale({ url, mimeType, bytes }, signal); | |
| 118 | }; | |
| 119 | } | |
| 120 | ||
| 121 | /** Stores small, self-contained images so startup never needs an old signed URL. */ | |
| 122 | export function createProjectFaviconCache(input: { | |
| 123 | readonly storage: ProjectFaviconStorage; | |
| 124 | readonly load: (url: string, signal: AbortSignal) => Promise<string>; | |
| 125 | }) { | |
| 126 | const entries = new Map<string, ProjectFaviconEntry>(); | |
| 127 | const environmentRevisions = new Map<EnvironmentId, number>(); | |
| 128 | let generation = 0; | |
| 129 | let hydration: Promise<void> | undefined; | |
| 130 | let clearing: Promise<void> | undefined; | |
| 131 | const pending = new Set<Promise<void>>(); | |
| 132 | ||
| 133 | const persist = (operation: () => Promise<void>) => { | |
| 134 | const task: Promise<void> = operation() | |
| 135 | .catch(() => { | |
| 136 | // Keep the in-memory image if local storage is full or unavailable. | |
| 137 | }) | |
| 138 | .finally(() => pending.delete(task)); | |
| 139 | pending.add(task); | |
| 140 | }; | |
| 141 | ||
| 142 | const remove = (key: string) => { | |
| 143 | const entry = entries.get(key); | |
| 144 | if (!entry) return; | |
| 145 | entries.delete(key); | |
| 146 | persist(() => input.storage.remove(key, entry)); | |
| 147 | }; | |
| 148 | ||
| 149 | const trim = () => { | |
| 150 | let bytes = 0; | |
| 151 | for (const entry of entries.values()) bytes += entry.dataUrl.length; | |
| 152 | while ( | |
| 153 | entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || | |
| 154 | bytes > PROJECT_FAVICON_CACHE_MAX_BYTES | |
| 155 | ) { | |
| 156 | const oldest = entries.entries().next().value; | |
| 157 | if (!oldest) break; | |
| 158 | bytes -= oldest[1].dataUrl.length; | |
| 159 | remove(oldest[0]); | |
| 160 | } | |
| 161 | }; | |
| 162 | ||
| 163 | const hydrate = () => | |
| 164 | (hydration ??= (async () => { | |
| 165 | try { | |
| 166 | for (const record of await input.storage.list()) { | |
| 167 | const entry = decodeEntry(record); | |
| 168 | if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); | |
| 169 | } | |
| 170 | trim(); | |
| 171 | } catch { | |
| 172 | // A missing, corrupt, or unavailable cache must not prevent startup. | |
| 173 | } | |
| 174 | })()); | |
| 175 | ||
| 176 | const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; | |
| 177 | ||
| 178 | const resolve = async ( | |
| 179 | target: ProjectFaviconTarget, | |
| 180 | url: string | null, | |
| 181 | signal: AbortSignal, | |
| 182 | ): Promise<string | null> => { | |
| 183 | await clearing; | |
| 184 | const startGeneration = generation; | |
| 185 | const startRevision = environmentRevisions.get(target.environmentId) ?? 0; | |
| 186 | await hydrate(); | |
| 187 | if (signal.aborted || url === null) return peek(target); | |
| 188 | const key = keyFor(target); | |
| 189 | if (isProjectFaviconFallbackUrl(url)) { | |
| 190 | remove(key); | |
| 191 | return null; | |
| 192 | } | |
| 193 | const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); | |
| 194 | const cached = entries.get(key); | |
| 195 | if (cached) { | |
| 196 | entries.delete(key); | |
| 197 | entries.set(key, cached); | |
| 198 | if (cached.revision === revision) return cached.dataUrl; | |
| 199 | } | |
| 200 | try { | |
| 201 | const dataUrl = await input.load(url, signal); | |
| 202 | if ( | |
| 203 | signal.aborted || | |
| 204 | startGeneration !== generation || | |
| 205 | startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) | |
| 206 | ) { | |
| 207 | return peek(target); | |
| 208 | } | |
| 209 | if (isImageDataUrl(dataUrl)) { | |
| 210 | const entry = { | |
| 211 | environmentId: target.environmentId, | |
| 212 | cwd: target.cwd, | |
| 213 | faviconPath: target.faviconPath || null, | |
| 214 | revision, | |
| 215 | dataUrl, | |
| 216 | }; | |
| 217 | entries.set(key, entry); | |
| 218 | persist(() => input.storage.put(key, entry)); | |
| 219 | trim(); | |
| 220 | return dataUrl; | |
| 221 | } | |
| 222 | } catch { | |
| 223 | // An outage or failed decode leaves the last successful image visible. | |
| 224 | } | |
| 225 | return peek(target) ?? url; | |
| 226 | }; | |
| 227 | ||
| 228 | const flush = async () => { | |
| 229 | await Promise.all(pending); | |
| 230 | }; | |
| 231 | ||
| 232 | // A download that started before the clear sees the revision change and is discarded; | |
| 233 | // one that starts during the clear waits for it, so it cannot repopulate storage. | |
| 234 | const clear = async (environmentId?: EnvironmentId) => { | |
| 235 | if (environmentId === undefined) generation += 1; | |
| 236 | else | |
| 237 | environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); | |
| 238 | const previous = clearing; | |
| 239 | const task = (async () => { | |
| 240 | await previous; | |
| 241 | await hydrate(); | |
| 242 | for (const [key, entry] of entries) { | |
| 243 | if (environmentId === undefined || entry.environmentId === environmentId) remove(key); | |
| 244 | } | |
| 245 | await flush(); | |
| 246 | })().finally(() => { | |
| 247 | if (clearing === task) clearing = undefined; | |
| 248 | }); | |
| 249 | clearing = task; | |
| 250 | await task; | |
| 251 | }; | |
| 252 | ||
| 253 | return { | |
| 254 | hydrate, | |
| 255 | peek, | |
| 256 | resolve, | |
| 257 | clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), | |
| 258 | clearAll: () => clear(), | |
| 259 | flush, | |
| 260 | }; | |
| 261 | } | |
| 262 | ||
| 263 | export type ProjectFaviconCache = ReturnType<typeof createProjectFaviconCache>; |
packages/client-runtime/src/state/assets.test.ts+163-2| ... | ... | @@ -1,11 +1,15 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import * as Option from "effect/Option"; | |
| 3 | 5 | import * as Layer from "effect/Layer"; |
| 4 | import { Atom } from "effect/unstable/reactivity"; | |
| 6 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 5 | 7 | |
| 6 | 8 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 9 | import { createProjectFaviconCache } from "../projectFaviconCache.ts"; | |
| 7 | 10 | import { |
| 8 | 11 | createAssetEnvironmentAtoms, |
| 12 | createProjectFaviconUrlAtomFamily, | |
| 9 | 13 | InvalidAssetCollectionKeyError, |
| 10 | 14 | parseAssetCollectionKey, |
| 11 | 15 | } from "./assets.ts"; |
| ... | ... | @@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { |
| 118 | 122 | ).not.toBe(assets.createUrls({ environmentId, resources })); |
| 119 | 123 | }); |
| 120 | 124 | }); |
| 125 | ||
| 126 | describe("project favicon URL cache", () => { | |
| 127 | it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { | |
| 128 | const image = "data:image/png;base64,aWNvbg=="; | |
| 129 | const replacement = "data:image/png;base64,bmV3"; | |
| 130 | const records = new Map<string, unknown>(); | |
| 131 | const storage = { | |
| 132 | list: async () => [...records.values()], | |
| 133 | put: async (key: string, entry: unknown) => { | |
| 134 | records.set(key, entry); | |
| 135 | }, | |
| 136 | remove: async (key: string) => { | |
| 137 | records.delete(key); | |
| 138 | }, | |
| 139 | }; | |
| 140 | const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; | |
| 141 | const previousCache = createProjectFaviconCache({ storage, load: async () => image }); | |
| 142 | await previousCache.resolve( | |
| 143 | target, | |
| 144 | "https://remote.test/api/assets/old/v1-icon.png", | |
| 145 | new AbortController().signal, | |
| 146 | ); | |
| 147 | await previousCache.flush(); | |
| 148 | const cache = createProjectFaviconCache({ storage, load: async () => replacement }); | |
| 149 | await cache.hydrate(); | |
| 150 | const registry = AtomRegistry.make(); | |
| 151 | const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>( | |
| 152 | AsyncResult.initial(), | |
| 153 | ); | |
| 154 | const connection = Atom.make<Option.Option<{ httpBaseUrl: string }>>(Option.none()); | |
| 155 | const favicon = createProjectFaviconUrlAtomFamily({ | |
| 156 | createUrl: () => result, | |
| 157 | preparedConnection: () => connection, | |
| 158 | imageCache: cache, | |
| 159 | })(target); | |
| 160 | const unmount = registry.mount(favicon); | |
| 161 | try { | |
| 162 | expect(registry.get(favicon)).toBe(image); | |
| 163 | let unsubscribe = () => {}; | |
| 164 | const refreshed = new Promise<void>((resolve) => { | |
| 165 | unsubscribe = registry.subscribe(favicon, (value) => { | |
| 166 | if (value === replacement) resolve(); | |
| 167 | }); | |
| 168 | }); | |
| 169 | registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); | |
| 170 | registry.set( | |
| 171 | result, | |
| 172 | AsyncResult.success({ | |
| 173 | relativeUrl: "/api/assets/new/v2-icon.png", | |
| 174 | expiresAt: 4_000_000_000_000, | |
| 175 | }), | |
| 176 | ); | |
| 177 | expect(registry.get(favicon)).toBe(image); | |
| 178 | await refreshed; | |
| 179 | unsubscribe(); | |
| 180 | expect(registry.get(favicon)).toBe(replacement); | |
| 181 | registry.set(connection, Option.none()); | |
| 182 | registry.set(result, AsyncResult.failure(Cause.die("offline"))); | |
| 183 | expect(registry.get(favicon)).toBe(replacement); | |
| 184 | } finally { | |
| 185 | unmount(); | |
| 186 | registry.dispose(); | |
| 187 | } | |
| 188 | }); | |
| 189 | ||
| 190 | it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { | |
| 191 | const registry = AtomRegistry.make(); | |
| 192 | const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>( | |
| 193 | AsyncResult.initial(), | |
| 194 | ); | |
| 195 | const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); | |
| 196 | const favicon = createProjectFaviconUrlAtomFamily({ | |
| 197 | createUrl: () => result, | |
| 198 | preparedConnection: () => connection, | |
| 199 | })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); | |
| 200 | let unmount = registry.mount(favicon); | |
| 201 | try { | |
| 202 | expect(registry.get(favicon)).toBeNull(); | |
| 203 | registry.set( | |
| 204 | result, | |
| 205 | AsyncResult.success({ | |
| 206 | expiresAt: 4_000_000_000_000, | |
| 207 | relativeUrl: "/api/assets/token-a/icon.svg", | |
| 208 | }), | |
| 209 | ); | |
| 210 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 211 | ||
| 212 | registry.set(connection, Option.none()); | |
| 213 | registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); | |
| 214 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 215 | unmount(); | |
| 216 | unmount = registry.mount(favicon); | |
| 217 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 218 | ||
| 219 | registry.set(result, AsyncResult.initial()); | |
| 220 | registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); | |
| 221 | expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); | |
| 222 | registry.set( | |
| 223 | result, | |
| 224 | AsyncResult.success({ | |
| 225 | expiresAt: 4_000_000_000_000, | |
| 226 | relativeUrl: "/api/assets/token-b/icon.svg", | |
| 227 | }), | |
| 228 | ); | |
| 229 | expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); | |
| 230 | ||
| 231 | registry.set( | |
| 232 | result, | |
| 233 | AsyncResult.success({ | |
| 234 | expiresAt: 4_000_000_000_000, | |
| 235 | relativeUrl: "/api/assets/token-c/project-favicon-missing", | |
| 236 | }), | |
| 237 | ); | |
| 238 | expect(registry.get(favicon)).toBe( | |
| 239 | "https://reconnected.test/api/assets/token-c/project-favicon-missing", | |
| 240 | ); | |
| 241 | registry.set(connection, Option.none()); | |
| 242 | expect(registry.get(favicon)).toBe( | |
| 243 | "https://reconnected.test/api/assets/token-c/project-favicon-missing", | |
| 244 | ); | |
| 245 | } finally { | |
| 246 | unmount(); | |
| 247 | registry.dispose(); | |
| 248 | } | |
| 249 | }); | |
| 250 | ||
| 251 | it("does not reuse another environment, workspace, or selected icon's cached URL", () => { | |
| 252 | const registry = AtomRegistry.make(); | |
| 253 | const result = Atom.make<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>( | |
| 254 | AsyncResult.success({ | |
| 255 | expiresAt: 4_000_000_000_000, | |
| 256 | relativeUrl: "/api/assets/token/icon.svg", | |
| 257 | }), | |
| 258 | ); | |
| 259 | const favicon = createProjectFaviconUrlAtomFamily({ | |
| 260 | createUrl: () => result, | |
| 261 | preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), | |
| 262 | }); | |
| 263 | const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; | |
| 264 | const unmount = registry.mount(favicon(target)); | |
| 265 | try { | |
| 266 | expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); | |
| 267 | registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); | |
| 268 | expect( | |
| 269 | registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), | |
| 270 | ).toBeNull(); | |
| 271 | expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); | |
| 272 | expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); | |
| 273 | expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( | |
| 274 | "https://remote.test/api/assets/token/icon.svg", | |
| 275 | ); | |
| 276 | } finally { | |
| 277 | unmount(); | |
| 278 | registry.dispose(); | |
| 279 | } | |
| 280 | }); | |
| 281 | }); |
packages/client-runtime/src/state/assets.ts+57| ... | ... | @@ -5,10 +5,17 @@ import { |
| 5 | 5 | EnvironmentId, |
| 6 | 6 | WS_METHODS, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import { | |
| 9 | getProjectFaviconResourceKey, | |
| 10 | isProjectFaviconFallbackUrl, | |
| 11 | } from "@t3tools/shared/projectFavicon"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Option from "effect/Option"; | |
| 8 | 14 | import * as Schema from "effect/Schema"; |
| 9 | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 10 | 16 | |
| 11 | 17 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 18 | import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; | |
| 12 | 19 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; |
| 13 | 20 | |
| 14 | 21 | const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; |
| ... | ... | @@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms<R, E>( |
| 118 | 125 | }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), |
| 119 | 126 | }; |
| 120 | 127 | } |
| 128 | ||
| 129 | /** | |
| 130 | * Keeps project icons visible while their environment reconnects. Each resource | |
| 131 | * owns its last resolved URL, including a confirmed missing-icon response. | |
| 132 | */ | |
| 133 | export function createProjectFaviconUrlAtomFamily(input: { | |
| 134 | readonly imageCache?: ProjectFaviconCache; | |
| 135 | readonly createUrl: (target: { | |
| 136 | readonly environmentId: EnvironmentId; | |
| 137 | readonly input: { readonly resource: AssetResource }; | |
| 138 | }) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>; | |
| 139 | readonly preparedConnection: ( | |
| 140 | environmentId: EnvironmentId, | |
| 141 | ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>; | |
| 142 | }) { | |
| 143 | const decodeKey = Schema.decodeUnknownSync( | |
| 144 | Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), | |
| 145 | ); | |
| 146 | const family = Atom.family((key: string) => { | |
| 147 | const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); | |
| 148 | const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; | |
| 149 | const request = input.createUrl({ environmentId, input: { resource } }); | |
| 150 | const resolvedUrl = Atom.make((get): string | null => { | |
| 151 | const result = get(request); | |
| 152 | const connection = get(input.preparedConnection(environmentId)); | |
| 153 | const state = assetUrlStateFromResult( | |
| 154 | result, | |
| 155 | Option.isSome(connection) ? connection.value.httpBaseUrl : null, | |
| 156 | ); | |
| 157 | return state._tag === "Success" ? state.url : Option.getOrNull(get.self<string | null>()); | |
| 158 | }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | |
| 159 | const cache = input.imageCache; | |
| 160 | if (!cache) return resolvedUrl; | |
| 161 | ||
| 162 | const target = { environmentId, cwd, faviconPath: path }; | |
| 163 | const image = Atom.make((get) => { | |
| 164 | get(request); | |
| 165 | const url = get(resolvedUrl); | |
| 166 | return Effect.promise((signal) => cache.resolve(target, url, signal)); | |
| 167 | }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | |
| 168 | ||
| 169 | return Atom.make((get): string | null => { | |
| 170 | const result = get(image); | |
| 171 | if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; | |
| 172 | return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); | |
| 173 | }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); | |
| 174 | }); | |
| 175 | return (target: ProjectFaviconTarget) => | |
| 176 | family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); | |
| 177 | } |
packages/shared/src/projectFavicon.ts+8| ... | ... | @@ -1,5 +1,13 @@ |
| 1 | 1 | export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; |
| 2 | 2 | |
| 3 | export function getProjectFaviconResourceKey( | |
| 4 | environmentId: string, | |
| 5 | workspaceRoot: string, | |
| 6 | faviconPath?: string | null, | |
| 7 | ) { | |
| 8 | return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); | |
| 9 | } | |
| 10 | ||
| 3 | 11 | export function getProjectFaviconCacheKey( |
| 4 | 12 | environmentId: string, |
| 5 | 13 | workspaceRoot: string, |