History

fix(clients): persist project icons across reloads and reconnects (#10138)

Wout Stiens committed 2 months ago

Commit eee05571 parent 050690d20 files · +1207 −62Browse files

Unified · Split

20 files
  1. apps/mobile/src/components/ProjectFavicon.tsx +28 −16
  2. apps/mobile/src/connection/environment-cache-store.test.ts +6 −0
  3. apps/mobile/src/connection/environment-cache-store.ts +7 −4
  4. apps/mobile/src/lib/projectFaviconCache.test.ts +79 −0
  5. apps/mobile/src/lib/projectFaviconCache.ts +112 −0
  6. apps/mobile/src/persistence/mobile-database.ts +22 −1
  7. apps/mobile/src/state/assets.ts +9 −1
  8. apps/mobile/src/state/client-cache-state.ts +7 −1
  9. apps/web/src/assets/projectFaviconCache.ts +85 −0
  10. apps/web/src/components/ProjectFavicon.test.tsx +10 −8
  11. apps/web/src/components/ProjectFavicon.tsx +7 −27
  12. apps/web/src/components/preview/PreviewView.test.tsx +2 −1
  13. apps/web/src/connection/storage.ts +3 −0
  14. apps/web/src/state/assets.ts +12 −1
  15. packages/client-runtime/package.json +4 −0
  16. packages/client-runtime/src/projectFaviconCache.test.ts +323 −0
  17. packages/client-runtime/src/projectFaviconCache.ts +263 −0
  18. packages/client-runtime/src/state/assets.test.ts +163 −2
  19. packages/client-runtime/src/state/assets.ts +57 −0
  20. packages/shared/src/projectFavicon.ts +8 −0
apps/mobile/src/components/ProjectFavicon.tsx+28−16
@@ -5,9 +5,13 @@ import { View } from "react-native"; · 4 unmodified lines
55 import type { EnvironmentId } from "@t3tools/contracts";
66 import {
77 getProjectFaviconCacheKey,
8+ getProjectFaviconResourceKey,
89 isProjectFaviconFallbackUrl,
910 } 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+
1115 import {
1216 beginProjectFaviconRequest,
1317 createProjectFaviconRequest,
@@ -16,6 +20,8 @@ import { · 2 unmodified lines
1620 markProjectFaviconLoaded,
1721 } from "./projectFaviconCache";
1822
23+const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
24+
1925 /* ─── Component ──────────────────────────────────────────────────────── */
2026 export function ProjectFavicon(props: {
2127 readonly environmentId: EnvironmentId;
@@ -26,20 +32,23 @@ export function ProjectFavicon(props: { · 4 unmodified lines
2632 readonly faviconPath?: string | null;
2733 }) {
2834 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,
3540 cwd: props.workspaceRoot,
36− ...(props.faviconPath ? { path: props.faviconPath } : {}),
37− },
41+ faviconPath: props.faviconPath,
42+ }),
3843 );
3944 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.
4047 const cacheKey =
4148 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)
4352 : null;
4453
4554 return (
@@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { · 29 unmodified lines
7584 }, [faviconRequest]);
7685
7786 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",
7990 );
8091
8192 const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
@@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { · 22 unmodified lines
104115 {requestIsActive ? (
105116 <Image
106117 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"}
112124 recyclingKey={faviconRequest.cacheKey}
113125 accessibilityLabel={`${props.projectTitle} favicon`}
114126 style={{
apps/mobile/src/connection/environment-cache-store.test.ts+6−0
@@ -32,6 +32,12 @@ function makeDatabase() { · 31 unmodified lines
3232 const database = MobileDatabase.of({
3333 loadCache: (environmentId, kind, cacheKey) =>
3434 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+ ),
3541 saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
3642 Effect.sync(() => {
3743 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"; · 14 unmodified lines
1515 import * as Schema from "effect/Schema";
1616
1717 import * as MobileDatabase from "../persistence/mobile-database";
18+import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache";
1819
1920 const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
2021 // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
@@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: { · 94 unmodified lines
115116
116117 export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
117118 const database = yield* MobileDatabase.MobileDatabase;
119+ attachProjectFaviconDatabase(database);
118120 return EnvironmentCacheStore.of({
119121 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
120122 loadDecodedCache({
@@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { · 5 unmodified lines
126128 decode: decodeStoredShellSnapshot,
127129 select: (stored) =>
128130 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
129− }),
131+ }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))),
130132 ),
131133 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
132134 const payload = yield* encodeStoredShellSnapshot({
@@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { · 104 unmodified lines
237239 .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
238240 ),
239241 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+ ),
243246 ),
244247 });
245248 });
apps/mobile/src/lib/projectFaviconCache.test.ts+79−0
@@ -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+112−0
@@ -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 = [ · 15 unmodified lines
1616 "connection-vcs-refs",
1717 ] as const;
1818
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+]);
2026 export type ClientCacheKind = typeof ClientCacheKind.Type;
2127
2228 export interface ClientCacheSummaryRow {
@@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ · 21 unmodified lines
4450 "open",
4551 "migrate",
4652 "load-cache",
53+ "list-cache",
4754 "save-cache",
4855 "remove-cache",
4956 "clear-cache-kind",
@@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< · 142 unmodified lines
192199 kind: ClientCacheKind,
193200 cacheKey: string,
194201 ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
202+ readonly listCache: (
203+ kind: ClientCacheKind,
204+ ) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>;
195205 readonly saveCache: (
196206 environmentId: EnvironmentId,
197207 kind: ClientCacheKind,
@@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { · 94 unmodified lines
292302 catch: databaseError("load-cache"),
293303 }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),
294304 ),
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+ ),
295315 saveCache: Effect.fn("MobileDatabase.saveCache")(
296316 (environmentId, kind, cacheKey, schemaVersion, payload) =>
297317 Effect.tryPromise({
@@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] · 107 unmodified lines
405425 const fail = Effect.fail(error);
406426 return MobileDatabase.of({
407427 loadCache: () => fail,
428+ listCache: () => fail,
408429 saveCache: () => fail,
409430 removeCache: () => fail,
410431 clearCacheKind: () => fail,
apps/mobile/src/state/assets.ts+9−1
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 import {
77 assetUrlStateFromResult,
88 createAssetEnvironmentAtoms,
9+ createProjectFaviconUrlAtomFamily,
910 EMPTY_ASSET_URL_ATOM,
1011 } from "@t3tools/client-runtime/state/assets";
1112 import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
@@ -15,14 +16,21 @@ import { useCallback } from "react"; · 3 unmodified lines
1516
1617 import { environmentCatalog } from "../connection/catalog";
1718 import { connectionAtomRuntime } from "../connection/runtime";
19+import { projectFaviconCache } from "../lib/projectFaviconCache";
1820 import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
19−import { usePreparedConnection } from "./session";
21+import { environmentSession, usePreparedConnection } from "./session";
2022 import { useAtomQueryRunner } from "./use-atom-query-runner";
2123
2224 export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
2325
2426 export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
2527
28+export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
29+ imageCache: projectFaviconCache,
30+ createUrl: assetEnvironment.createUrl,
31+ preparedConnection: environmentSession.preparedConnectionValueAtom,
32+});
33+
2634 const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
2735 Atom.withLabel("mobile-asset-connection-state:empty"),
2836 );
apps/mobile/src/state/client-cache-state.ts+7−1
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
33 import { Atom } from "effect/unstable/reactivity";
44
55 import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
6+import { projectFaviconCache } from "../lib/projectFaviconCache";
67 import * as Runtime from "../lib/runtime";
78
89 export interface EnvironmentClientCacheSummary {
@@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime · 62 unmodified lines
7172
7273 export const clearClientCacheAtom = clientCacheRuntime
7374 .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),
7581 Effect.flatMap((database) =>
7682 scope.type === "all"
7783 ? database.clearAllCaches
apps/web/src/assets/projectFaviconCache.ts+85−0
@@ -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" · 4 unmodified lines
55
66 const testState = vi.hoisted(() => ({
77 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
8− lastResource: null as unknown,
8+ lastTarget: null as unknown,
99 }));
1010
1111 const hooks = vi.hoisted(() => {
@@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ · 45 unmodified lines
5757 DynamicIcon: "dynamic-icon",
5858 iconNames: ["alarm-clock", "folder-code"],
5959 }));
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;
6466 },
6567 }));
6668
@@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { · 145 unmodified lines
212214 faviconPath: "brand/icon.svg",
213215 });
214216
215− expect(testState.lastResource).toEqual({
216− _tag: "project-favicon",
217+ expect(testState.lastTarget).toMatchObject({
218+ environmentId: "environment-test",
217219 cwd: "/workspace-test",
218− path: "brand/icon.svg",
220+ faviconPath: "brand/icon.svg",
219221 });
220222 });
221223 });
apps/web/src/components/ProjectFavicon.tsx+7−27
@@ -1,6 +1,6 @@
11 import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
22 import {
3− getProjectFaviconCacheKey,
3+ getProjectFaviconResourceKey,
44 isProjectFaviconFallbackUrl,
55 } from "@t3tools/shared/projectFavicon";
66 import {
@@ -30,12 +30,12 @@ import { · 23 unmodified lines
3030 import type { IconName } from "lucide-react/dynamic";
3131 import type { ComponentType } from "react";
3232 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";
3435 import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
3536 import { projectIconColorClassName } from "../projectIconColors";
3637 import { cn } from "~/lib/utils";
3738
38−const loadedProjectFaviconSrcs = new Map<string, string>();
3939 const DynamicIcon = lazy(() =>
4040 import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
4141 );
@@ -103,8 +103,7 @@ export function ProjectFavicon(input: { · 61 unmodified lines
103103 className?: string | undefined;
104104 fallbackIcon?: ComponentType<{ className?: string }>;
105105 }) {
106− const state = useProjectFaviconAsset(input);
107− const src = state._tag === "Success" ? state.url : null;
106+ const src = useAtomValue(projectFaviconUrlAtom(input));
108107 if (input.projectIcon?.kind === "emoji") {
109108 return <ProjectFaviconFallback className={input.className} emoji={input.projectIcon.emoji} />;
110109 }
@@ -150,12 +149,11 @@ export function ProjectFavicon(input: { · 39 unmodified lines
150149 );
151150 }
152151
153− const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);
152+ const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath);
154153
155154 return (
156155 <ProjectFaviconImage
157156 key={cacheKey}
158− cacheKey={cacheKey}
159157 src={src}
160158 className={input.className}
161159 fallbackIcon={FallbackIcon}
@@ -165,18 +163,6 @@ export function ProjectFavicon(input: { · 3 unmodified lines
165163 );
166164 }
167165
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−
180166 function ProjectFaviconFallback({
181167 className,
182168 colorClassName,
@@ -207,28 +193,23 @@ function ProjectFaviconFallback({ · 24 unmodified lines
207193 }
208194
209195 function ProjectFaviconImage({
210− cacheKey,
211196 src,
212197 className,
213198 fallbackIcon: FallbackIcon,
214199 fallbackEmoji,
215200 fallbackColorClassName,
216201 }: {
217− readonly cacheKey: string;
218202 readonly src: string;
219203 readonly className?: string | undefined;
220204 readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;
221205 readonly fallbackEmoji?: string | undefined;
222206 readonly fallbackColorClassName?: string | undefined;
223207 }) {
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,
226210 );
227211 const isLoading = displayedSrc !== src;
228212 const handleLoadError = (failedSrc: string) => {
229− if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) {
230− loadedProjectFaviconSrcs.delete(cacheKey);
231− }
232213 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
233214 };
234215
@@ -256,7 +237,6 @@ function ProjectFaviconImage({ · 21 unmodified lines
256237 alt=""
257238 className="hidden"
258239 onLoad={() => {
259− loadedProjectFaviconSrcs.set(cacheKey, src);
260240 setDisplayedSrc(src);
261241 }}
262242 onError={() => handleLoadError(src)}
apps/web/src/components/preview/PreviewView.test.tsx+2−1
@@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ · 55 unmodified lines
5656 useThreadRecentHistory: () => EMPTY_HISTORY,
5757 }));
5858
59−vi.mock("~/state/session", () => ({
59+vi.mock("~/state/session", async (importOriginal) => ({
60+ ...(await importOriginal<typeof import("~/state/session")>()),
6061 readPreparedConnection: mocks.readPreparedConnection,
6162 }));
6263
apps/web/src/connection/storage.ts+3−0
@@ -33,6 +33,7 @@ import * as Option from "effect/Option"; · 32 unmodified lines
3333 import * as Ref from "effect/Ref";
3434 import * as Schema from "effect/Schema";
3535 import * as Semaphore from "effect/Semaphore";
36+import { projectFaviconCache } from "../assets/projectFaviconCache";
3637
3738 const DATABASE_NAME = "t3code:connection-runtime";
3839 const DATABASE_VERSION = 4;
@@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( · 422 unmodified lines
461462 const cacheStore = EnvironmentCacheStore.of({
462463 loadShell: (environmentId) =>
463464 readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe(
465+ Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())),
464466 Effect.flatMap((raw) => {
465467 if (typeof raw !== "string") {
466468 return Effect.succeed(Option.none());
@@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( · 171 unmodified lines
638640 clear: (environmentId) =>
639641 Effect.all(
640642 [
643+ Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)),
641644 removeDatabaseValue(database, SHELL_STORE_NAME, environmentId),
642645 removeDatabaseValuesInRange(
643646 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";
25
36 import { connectionAtomRuntime } from "../connection/runtime";
7+import { projectFaviconCache } from "../assets/projectFaviconCache";
8+import { environmentSession } from "./session";
49
510 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−0
@@ -3,6 +3,10 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "exports": {
6+ "./project-favicon-cache": {
7+ "types": "./src/projectFaviconCache.ts",
8+ "default": "./src/projectFaviconCache.ts"
9+ },
610 "./connection": {
711 "types": "./src/connection/index.ts",
812 "default": "./src/connection/index.ts"
packages/client-runtime/src/projectFaviconCache.test.ts+323−0
@@ -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+263−0
@@ -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 @@
11 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";
35 import * as Layer from "effect/Layer";
4−import { Atom } from "effect/unstable/reactivity";
6+import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
57
68 import type { EnvironmentRegistry } from "../connection/registry.ts";
9+import { createProjectFaviconCache } from "../projectFaviconCache.ts";
710 import {
811 createAssetEnvironmentAtoms,
12+ createProjectFaviconUrlAtomFamily,
913 InvalidAssetCollectionKeyError,
1014 parseAssetCollectionKey,
1115 } from "./assets.ts";
@@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { · 106 unmodified lines
118122 ).not.toBe(assets.createUrls({ environmentId, resources }));
119123 });
120124 });
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−0
@@ -5,10 +5,17 @@ import { · 4 unmodified lines
55 EnvironmentId,
66 WS_METHODS,
77 } 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";
814 import * as Schema from "effect/Schema";
915 import { AsyncResult, Atom } from "effect/unstable/reactivity";
1016
1117 import type { EnvironmentRegistry } from "../connection/registry.ts";
18+import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
1219 import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
1320
1421 const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
@@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms<R, E>( · 103 unmodified lines
118125 }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])),
119126 };
120127 }
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−0
@@ -1,5 +1,13 @@
11 export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
22
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+
311 export function getProjectFaviconCacheKey(
412 environmentId: string,
513 workspaceRoot: string,