authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-06 03:50:02+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 18:50:02-07:00
logeee05575ebd514db36f61d7eb05d2258a10c96bd
treeac282bd9edcc2541db816c5d0866798b3c3433b9
parent050690d1bc048c1f94096c17a1c9231c3ad61a83
signature Signed by PGP key B5690EEEBB952194

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


20 files changed, 1207 insertions(+), 62 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+28-16
......@@ -5,9 +5,13 @@ import { View } from "react-native";
55import type { EnvironmentId } from "@t3tools/contracts";
66import {
77 getProjectFaviconCacheKey,
8 getProjectFaviconResourceKey,
89 isProjectFaviconFallbackUrl,
910} from "@t3tools/shared/projectFavicon";
10import { useAssetUrl } from "../state/assets";
11import { useAtomValue } from "@effect/atom-react";
12import { Atom } from "effect/unstable/reactivity";
13import { projectFaviconUrlAtom } from "../state/assets";
14
1115import {
1216 beginProjectFaviconRequest,
1317 createProjectFaviconRequest,
......@@ -16,6 +20,8 @@ import {
1620 markProjectFaviconLoaded,
1721} from "./projectFaviconCache";
1822
23const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
24
1925/* ─── Component ──────────────────────────────────────────────────────── */
2026export function ProjectFavicon(props: {
2127 readonly environmentId: EnvironmentId;
......@@ -26,20 +32,23 @@ export function ProjectFavicon(props: {
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: {
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: {
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
......@@ -32,6 +32,12 @@ function makeDatabase() {
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";
1515import * as Schema from "effect/Schema";
1616
1717import * as MobileDatabase from "../persistence/mobile-database";
18import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache";
1819
1920const 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: {
115116
116117export 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* () {
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* () {
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 created+79
......@@ -0,0 +1,79 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache";
3
4const 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}));
15vi.mock("expo-image", () => ({
16 Image: {
17 loadAsync: native.load,
18 writeToCacheAsync: native.write,
19 getCachePathAsync: native.path,
20 },
21}));
22vi.mock("expo-file-system", () => ({
23 File: class {
24 size = 24_000;
25 base64 = native.read;
26 delete = native.remove;
27 },
28}));
29
30import { downscaleProjectFavicon } from "./projectFaviconCache";
31
32const png = "iVBORw0KGgoAAAAA";
33const image = { url: "https://remote/icon.png" };
34
35beforeEach(() => {
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
45describe("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 @@
1import {
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";
8import * as Effect from "effect/Effect";
9
10import * as MobileDatabase from "../persistence/mobile-database";
11
12const CACHE_KIND = "project-favicon";
13const CACHE_SCHEMA_VERSION = 1;
14
15let 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 */
22export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
23 database = service;
24}
25
26const 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 */
38export 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. */
82export 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 = [
1616 "connection-vcs-refs",
1717] as const;
1818
19export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]);
19export const ClientCacheKind = Schema.Literals([
20 "shell",
21 "thread",
22 "server-config",
23 "vcs-refs",
24 "project-favicon",
25]);
2026export type ClientCacheKind = typeof ClientCacheKind.Type;
2127
2228export interface ClientCacheSummaryRow {
......@@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([
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<
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* () {
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"]
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 {
66import {
77 assetUrlStateFromResult,
88 createAssetEnvironmentAtoms,
9 createProjectFaviconUrlAtomFamily,
910 EMPTY_ASSET_URL_ATOM,
1011} from "@t3tools/client-runtime/state/assets";
1112import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
......@@ -15,14 +16,21 @@ import { useCallback } from "react";
1516
1617import { environmentCatalog } from "../connection/catalog";
1718import { connectionAtomRuntime } from "../connection/runtime";
19import { projectFaviconCache } from "../lib/projectFaviconCache";
1820import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
19import { usePreparedConnection } from "./session";
21import { environmentSession, usePreparedConnection } from "./session";
2022import { useAtomQueryRunner } from "./use-atom-query-runner";
2123
2224export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
2325
2426export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
2527
28export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
29 imageCache: projectFaviconCache,
30 createUrl: assetEnvironment.createUrl,
31 preparedConnection: environmentSession.preparedConnectionValueAtom,
32});
33
2634const 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";
33import { Atom } from "effect/unstable/reactivity";
44
55import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
6import { projectFaviconCache } from "../lib/projectFaviconCache";
67import * as Runtime from "../lib/runtime";
78
89export interface EnvironmentClientCacheSummary {
......@@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime
7172
7273export 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 created+85
......@@ -0,0 +1,85 @@
1import {
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
8const DATABASE_NAME = "t3code:project-favicons";
9const DATABASE_VERSION = 2;
10const STORE_NAME = "images";
11let database: Promise<IDBDatabase> | undefined;
12
13function 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
30function 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
38async 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. */
49export 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
74export 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"
55
66const 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
1111const hooks = vi.hoisted(() => {
......@@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({
5757 DynamicIcon: "dynamic-icon",
5858 iconNames: ["alarm-clock", "folder-code"],
5959}));
60vi.mock("../assets/assetUrls", () => ({
61 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
62 testState.lastResource = resource;
63 return { _tag: "Success", url: testState.faviconUrl };
60vi.mock("@effect/atom-react", () => ({
61 useAtomValue: () => testState.faviconUrl,
62}));
63vi.mock("../state/assets", () => ({
64 projectFaviconUrlAtom: (input: unknown) => {
65 testState.lastTarget = input;
6466 },
6567}));
6668
......@@ -212,10 +214,10 @@ describe("ProjectFavicon", () => {
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 @@
11import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts";
22import {
3 getProjectFaviconCacheKey,
3 getProjectFaviconResourceKey,
44 isProjectFaviconFallbackUrl,
55} from "@t3tools/shared/projectFavicon";
66import {
......@@ -30,12 +30,12 @@ import {
3030import type { IconName } from "lucide-react/dynamic";
3131import type { ComponentType } from "react";
3232import { lazy, Suspense, useState } from "react";
33import { useAssetUrlState } from "../assets/assetUrls";
33import { useAtomValue } from "@effect/atom-react";
34import { projectFaviconUrlAtom } from "../state/assets";
3435import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";
3536import { projectIconColorClassName } from "../projectIconColors";
3637import { cn } from "~/lib/utils";
3738
38const loadedProjectFaviconSrcs = new Map<string, string>();
3939const DynamicIcon = lazy(() =>
4040 import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
4141);
......@@ -103,8 +103,7 @@ export function ProjectFavicon(input: {
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: {
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: {
165163 );
166164}
167165
168export 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
180166function ProjectFaviconFallback({
181167 className,
182168 colorClassName,
......@@ -207,28 +193,23 @@ function ProjectFaviconFallback({
207193}
208194
209195function 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({
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", () => ({
5656 useThreadRecentHistory: () => EMPTY_HISTORY,
5757}));
5858
59vi.mock("~/state/session", () => ({
59vi.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
......@@ -33,6 +33,7 @@ import * as Option from "effect/Option";
3333import * as Ref from "effect/Ref";
3434import * as Schema from "effect/Schema";
3535import * as Semaphore from "effect/Semaphore";
36import { projectFaviconCache } from "../assets/projectFaviconCache";
3637
3738const DATABASE_NAME = "t3code:connection-runtime";
3839const DATABASE_VERSION = 4;
......@@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext(
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(
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 @@
1import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets";
1import {
2 createAssetEnvironmentAtoms,
3 createProjectFaviconUrlAtomFamily,
4} from "@t3tools/client-runtime/state/assets";
25
36import { connectionAtomRuntime } from "../connection/runtime";
7import { projectFaviconCache } from "../assets/projectFaviconCache";
8import { environmentSession } from "./session";
49
510export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
11
12export 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 @@
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 created+323
......@@ -0,0 +1,323 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
3
4import {
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
15const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" };
16const url = "https://remote.test/api/assets/token-a/vabc-icon.svg";
17const image = "data:image/png;base64,aWNvbg==";
18const replacement = "data:image/png;base64,bmV3";
19const signal = () => new AbortController().signal;
20
21function 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
29function 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
49describe("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
252describe("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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { mediaMimeType } from "@t3tools/shared/filePreview";
3import {
4 getProjectFaviconCacheKey,
5 getProjectFaviconResourceKey,
6 isProjectFaviconFallbackUrl,
7} from "@t3tools/shared/projectFavicon";
8import * as Encoding from "effect/Encoding";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11
12export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96;
13export 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. */
15export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024;
16export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024;
17export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128;
18
19export interface ProjectFaviconTarget {
20 readonly environmentId: EnvironmentId;
21 readonly cwd: string;
22 readonly faviconPath?: string | null | undefined;
23}
24
25const 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);
31const Entry = Schema.Struct({
32 environmentId: EnvironmentId,
33 cwd: Schema.String,
34 faviconPath: Schema.NullOr(Schema.String),
35 revision: Schema.String,
36 dataUrl: ImageDataUrl,
37});
38export type ProjectFaviconEntry = typeof Entry.Type;
39const decodeEntry = Schema.decodeUnknownOption(Entry);
40const isImageDataUrl = Schema.is(ImageDataUrl);
41
42function keyFor(target: ProjectFaviconTarget) {
43 return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath);
44}
45
46export 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
53async 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 */
90export 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. */
122export 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
263export type ProjectFaviconCache = ReturnType<typeof createProjectFaviconCache>;
packages/client-runtime/src/state/assets.test.ts+163-2
......@@ -1,11 +1,15 @@
11import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
2import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Option from "effect/Option";
35import * as Layer from "effect/Layer";
4import { Atom } from "effect/unstable/reactivity";
6import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
57
68import type { EnvironmentRegistry } from "../connection/registry.ts";
9import { createProjectFaviconCache } from "../projectFaviconCache.ts";
710import {
811 createAssetEnvironmentAtoms,
12 createProjectFaviconUrlAtomFamily,
913 InvalidAssetCollectionKeyError,
1014 parseAssetCollectionKey,
1115} from "./assets.ts";
......@@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => {
118122 ).not.toBe(assets.createUrls({ environmentId, resources }));
119123 });
120124});
125
126describe("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 {
55 EnvironmentId,
66 WS_METHODS,
77} from "@t3tools/contracts";
8import {
9 getProjectFaviconResourceKey,
10 isProjectFaviconFallbackUrl,
11} from "@t3tools/shared/projectFavicon";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
814import * as Schema from "effect/Schema";
915import { AsyncResult, Atom } from "effect/unstable/reactivity";
1016
1117import type { EnvironmentRegistry } from "../connection/registry.ts";
18import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
1219import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
1320
1421const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
......@@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms<R, E>(
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 */
133export 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 @@
11export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
22
3export function getProjectFaviconResourceKey(
4 environmentId: string,
5 workspaceRoot: string,
6 faviconPath?: string | null,
7) {
8 return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]);
9}
10
311export function getProjectFaviconCacheKey(
412 environmentId: string,
513 workspaceRoot: string,