authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-07-30 14:13:50-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 14:13:50-04:00
log1d77cec991971d3113918e896b1c871e8b044da7
tree99685a2dac59fd9b9f487e73c5df9c9bc5347e4e
parent4ba4871af76081f0e830a91b8f747ff2de53ba7d
signature Signed by PGP key B5690EEEBB952194

fix: cache project favicons across web and mobile (#4767)


9 files changed, 552 insertions(+), 48 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+45-12
...@@ -1,14 +1,21 @@...@@ -1,14 +1,21 @@
1import { SymbolView } from "./AppSymbol";1import { SymbolView } from "./AppSymbol";
2import { Image } from "expo-image";2import { Image } from "expo-image";
3import { useState } from "react";3import { useLayoutEffect, useMemo, useState } from "react";
4import { View } from "react-native";4import { View } from "react-native";
5import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
6import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";6import {
7 getProjectFaviconCacheKey,
8 isProjectFaviconFallbackUrl,
9} from "@t3tools/shared/projectFavicon";
7import { useThemeColor } from "../lib/useThemeColor";10import { useThemeColor } from "../lib/useThemeColor";
8import { useAssetUrl } from "../state/assets";11import { useAssetUrl } from "../state/assets";
912import {
10/* ─── Favicon cache (matches web pattern) ────────────────────────────── */13 beginProjectFaviconRequest,
11const loadedFaviconUrls = new Set<string>();14 createProjectFaviconRequest,
15 hasLoadedProjectFavicon,
16 markProjectFaviconFailed,
17 markProjectFaviconLoaded,
18} from "./projectFaviconCache";
1219
13/* ─── Component ──────────────────────────────────────────────────────── */20/* ─── Component ──────────────────────────────────────────────────────── */
14export function ProjectFavicon(props: {21export function ProjectFavicon(props: {
...@@ -26,10 +33,15 @@ export function ProjectFavicon(props: {...@@ -26,10 +33,15 @@ export function ProjectFavicon(props: {
26 : { _tag: "project-favicon", cwd: props.workspaceRoot },33 : { _tag: "project-favicon", cwd: props.workspaceRoot },
27 );34 );
28 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;35 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
36 const cacheKey =
37 renderableFaviconUrl && props.workspaceRoot
38 ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
39 : null;
2940
30 return (41 return (
31 <ProjectFaviconImage42 <ProjectFaviconImage
32 key={faviconUrl}43 key={cacheKey}
44 cacheKey={cacheKey}
33 faviconUrl={renderableFaviconUrl}45 faviconUrl={renderableFaviconUrl}
34 open={props.open}46 open={props.open}
35 projectTitle={props.projectTitle}47 projectTitle={props.projectTitle}
...@@ -39,18 +51,32 @@ export function ProjectFavicon(props: {...@@ -39,18 +51,32 @@ export function ProjectFavicon(props: {
39}51}
4052
41function ProjectFaviconImage(props: {53function ProjectFaviconImage(props: {
54 readonly cacheKey: string | null;
42 readonly faviconUrl: string | null;55 readonly faviconUrl: string | null;
43 readonly open?: boolean;56 readonly open?: boolean;
44 readonly projectTitle: string;57 readonly projectTitle: string;
45 readonly size: number;58 readonly size: number;
46}) {59}) {
47 const iconMuted = useThemeColor("--color-icon-subtle");60 const iconMuted = useThemeColor("--color-icon-subtle");
61 const faviconRequest = useMemo(
62 () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
63 [props.cacheKey, props.faviconUrl],
64 );
65 const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null);
66 useLayoutEffect(() => {
67 if (faviconRequest === null) return;
68
69 const endRequest = beginProjectFaviconRequest(faviconRequest);
70 setActiveFaviconRequest(faviconRequest);
71 return endRequest;
72 }, [faviconRequest]);
4873
49 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>74 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
50 props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading",75 hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
51 );76 );
5277
53 const showImage = props.faviconUrl !== null && status === "loaded";78 const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
79 const showImage = requestIsActive && status === "loaded";
5480
55 return (81 return (
56 <View82 <View
...@@ -72,11 +98,15 @@ function ProjectFaviconImage(props: {...@@ -72,11 +98,15 @@ function ProjectFaviconImage(props: {
72 ) : null}98 ) : null}
7399
74 {/* Favicon image (hidden until loaded) */}100 {/* Favicon image (hidden until loaded) */}
75 {props.faviconUrl ? (101 {requestIsActive ? (
76 <Image102 <Image
103 key={faviconRequest.faviconUrl}
77 source={{104 source={{
78 uri: props.faviconUrl,105 uri: faviconRequest.faviconUrl,
106 cacheKey: faviconRequest.cacheKey,
79 }}107 }}
108 cachePolicy="memory-disk"
109 recyclingKey={faviconRequest.cacheKey}
80 accessibilityLabel={`${props.projectTitle} favicon`}110 accessibilityLabel={`${props.projectTitle} favicon`}
81 style={{111 style={{
82 width: props.size,112 width: props.size,
...@@ -86,10 +116,13 @@ function ProjectFaviconImage(props: {...@@ -86,10 +116,13 @@ function ProjectFaviconImage(props: {
86 }}116 }}
87 contentFit="contain"117 contentFit="contain"
88 onLoad={() => {118 onLoad={() => {
89 if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl);119 if (!markProjectFaviconLoaded(faviconRequest)) return;
90 setStatus("loaded");120 setStatus("loaded");
91 }}121 }}
92 onError={() => setStatus("error")}122 onError={() => {
123 if (!markProjectFaviconFailed(faviconRequest)) return;
124 setStatus("error");
125 }}
93 />126 />
94 ) : null}127 ) : null}
95 </View>128 </View>
apps/mobile/src/components/projectFaviconCache.test.ts created+108
...@@ -0,0 +1,108 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 beginProjectFaviconRequest,
5 createProjectFaviconRequest,
6 hasLoadedProjectFavicon,
7 markProjectFaviconFailed,
8 markProjectFaviconLoaded,
9} from "./projectFaviconCache";
10
11describe("project favicon cache", () => {
12 it("ignores callbacks from a superseded URL", () => {
13 const cacheKey = "environment-1:/workspace:v1-favicon.svg";
14 const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg";
15 const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg";
16
17 const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl);
18 const endExpiredRequest = beginProjectFaviconRequest(expiredRequest);
19 markProjectFaviconLoaded(expiredRequest);
20 const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl);
21 const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest);
22
23 expect(markProjectFaviconLoaded(expiredRequest)).toBe(false);
24 expect(markProjectFaviconFailed(expiredRequest)).toBe(false);
25 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
26 expect(markProjectFaviconFailed(refreshedRequest)).toBe(true);
27 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
28
29 endRefreshedRequest();
30 endExpiredRequest();
31 });
32
33 it("evicts the URL that actually failed", () => {
34 const cacheKey = "environment-1:/workspace:v2-favicon.svg";
35 const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg";
36 const request = createProjectFaviconRequest(cacheKey, faviconUrl);
37 const endRequest = beginProjectFaviconRequest(request);
38
39 markProjectFaviconLoaded(request);
40
41 expect(markProjectFaviconFailed(request)).toBe(true);
42 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
43
44 endRequest();
45 });
46
47 it("does not supersede a request until the next request begins", () => {
48 const cacheKey = "environment-1:/workspace:v3-favicon.svg";
49 const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg";
50 const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg";
51 const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl);
52 const endCommittedRequest = beginProjectFaviconRequest(committedRequest);
53
54 createProjectFaviconRequest(cacheKey, abandonedUrl);
55
56 expect(markProjectFaviconLoaded(committedRequest)).toBe(true);
57 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
58
59 endCommittedRequest();
60 });
61
62 it("requires a cache key before creating a URL-bearing request", () => {
63 const firstUrl = "https://environment.example/api/assets/first/favicon.svg";
64 const secondUrl = "https://environment.example/api/assets/second/favicon.svg";
65
66 expect(createProjectFaviconRequest(null, firstUrl)).toBeNull();
67 expect(createProjectFaviconRequest(null, secondUrl)).toBeNull();
68 });
69
70 it("restores the remaining active URL when a newer request ends", () => {
71 const cacheKey = "environment-1:/workspace:v4-favicon.svg";
72 const firstRequest = createProjectFaviconRequest(
73 cacheKey,
74 "https://environment.example/api/assets/first/v4-favicon.svg",
75 );
76 const secondRequest = createProjectFaviconRequest(
77 cacheKey,
78 "https://environment.example/api/assets/second/v4-favicon.svg",
79 );
80 const endFirstRequest = beginProjectFaviconRequest(firstRequest);
81 const endSecondRequest = beginProjectFaviconRequest(secondRequest);
82
83 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
84 endSecondRequest();
85 expect(markProjectFaviconLoaded(firstRequest)).toBe(true);
86 endFirstRequest();
87 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
88 });
89
90 it("bounds remembered loaded revisions", () => {
91 const firstCacheKey = "environment-1:/workspace:revision-0";
92 let lastCacheKey = firstCacheKey;
93
94 for (let revision = 0; revision < 300; revision++) {
95 lastCacheKey = `environment-1:/workspace:revision-${revision}`;
96 const request = createProjectFaviconRequest(
97 lastCacheKey,
98 `https://environment.example/api/assets/revision-${revision}/favicon.svg`,
99 );
100 const endRequest = beginProjectFaviconRequest(request);
101 markProjectFaviconLoaded(request);
102 endRequest();
103 }
104
105 expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false);
106 expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true);
107 });
108});
apps/mobile/src/components/projectFaviconCache.ts created+94
...@@ -0,0 +1,94 @@
1export interface ProjectFaviconRequest {
2 readonly cacheKey: string;
3 readonly faviconUrl: string;
4}
5
6interface ActiveFaviconRequests {
7 readonly urls: Map<string, number>;
8 currentUrl: string;
9}
10
11const MAX_LOADED_FAVICONS = 256;
12const activeFaviconRequests = new Map<string, ActiveFaviconRequests>();
13const loadedFaviconKeys = new Map<string, true>();
14
15export function createProjectFaviconRequest(
16 cacheKey: string,
17 faviconUrl: string,
18): ProjectFaviconRequest;
19export function createProjectFaviconRequest(
20 cacheKey: string | null,
21 faviconUrl: string | null,
22): ProjectFaviconRequest | null;
23export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) {
24 if (!cacheKey || !faviconUrl) return null;
25 return { cacheKey, faviconUrl };
26}
27
28export function beginProjectFaviconRequest(request: ProjectFaviconRequest) {
29 let activeRequests = activeFaviconRequests.get(request.cacheKey);
30 if (!activeRequests) {
31 activeRequests = { currentUrl: request.faviconUrl, urls: new Map() };
32 activeFaviconRequests.set(request.cacheKey, activeRequests);
33 }
34
35 const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0;
36 activeRequests.urls.delete(request.faviconUrl);
37 activeRequests.urls.set(request.faviconUrl, activeCount + 1);
38 activeRequests.currentUrl = request.faviconUrl;
39
40 let ended = false;
41 return () => {
42 if (ended) return;
43 ended = true;
44
45 const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1;
46 if (remainingCount > 0) {
47 activeRequests.urls.set(request.faviconUrl, remainingCount);
48 return;
49 }
50
51 activeRequests.urls.delete(request.faviconUrl);
52 if (activeRequests.urls.size === 0) {
53 if (activeFaviconRequests.get(request.cacheKey) === activeRequests) {
54 activeFaviconRequests.delete(request.cacheKey);
55 }
56 return;
57 }
58
59 if (activeRequests.currentUrl === request.faviconUrl) {
60 activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!;
61 }
62 };
63}
64
65export function hasLoadedProjectFavicon(cacheKey: string | null) {
66 return cacheKey !== null && loadedFaviconKeys.has(cacheKey);
67}
68
69function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) {
70 return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl;
71}
72
73function rememberLoadedProjectFavicon(cacheKey: string) {
74 loadedFaviconKeys.delete(cacheKey);
75 loadedFaviconKeys.set(cacheKey, true);
76
77 if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) {
78 loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!);
79 }
80}
81
82export function markProjectFaviconLoaded(request: ProjectFaviconRequest) {
83 if (!isCurrentProjectFaviconRequest(request)) return false;
84
85 rememberLoadedProjectFavicon(request.cacheKey);
86 return true;
87}
88
89export function markProjectFaviconFailed(request: ProjectFaviconRequest) {
90 if (!isCurrentProjectFaviconRequest(request)) return false;
91
92 loadedFaviconKeys.delete(request.cacheKey);
93 return true;
94}
apps/server/src/assets/AssetAccess.test.ts+45-1
...@@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";3import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
4import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";9import * as Path from "effect/Path";
9import * as PlatformError from "effect/PlatformError";10import * as PlatformError from "effect/PlatformError";
11import * as TestClock from "effect/testing/TestClock";
1012
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";13import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
...@@ -214,12 +216,21 @@ describe("AssetAccess", () => {...@@ -214,12 +216,21 @@ describe("AssetAccess", () => {
214 prefix: "t3-asset-favicon-",216 prefix: "t3-asset-favicon-",
215 });217 });
216 const faviconPath = path.join(root, "favicon.svg");218 const faviconPath = path.join(root, "favicon.svg");
217 yield* fileSystem.writeFileString(faviconPath, "<svg />");219 const initialFavicon = "<svg>a</svg>";
220 const updatedFavicon = "<svg>b</svg>";
221 expect(updatedFavicon).toHaveLength(initialFavicon.length);
222 yield* fileSystem.writeFileString(faviconPath, initialFavicon);
218 const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath);223 const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath);
219224
220 const faviconResult = yield* issueAssetUrl({225 const faviconResult = yield* issueAssetUrl({
221 resource: { _tag: "project-favicon", cwd: root },226 resource: { _tag: "project-favicon", cwd: root },
222 });227 });
228 expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
229 expect(
230 yield* issueAssetUrl({
231 resource: { _tag: "project-favicon", cwd: root },
232 }),
233 ).toEqual(faviconResult);
223 const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);234 const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
224 const faviconSeparatorIndex = faviconSuffix.indexOf("/");235 const faviconSeparatorIndex = faviconSuffix.indexOf("/");
225 expect(236 expect(
...@@ -229,6 +240,14 @@ describe("AssetAccess", () => {...@@ -229,6 +240,14 @@ describe("AssetAccess", () => {
229 ),240 ),
230 ).toEqual({ kind: "file", path: canonicalFaviconPath });241 ).toEqual({ kind: "file", path: canonicalFaviconPath });
231242
243 yield* fileSystem.writeFileString(faviconPath, updatedFavicon);
244 const updatedFaviconResult = yield* issueAssetUrl({
245 resource: { _tag: "project-favicon", cwd: root },
246 });
247 expect(
248 updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")),
249 ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/")));
250
232 yield* fileSystem.remove(faviconPath);251 yield* fileSystem.remove(faviconPath);
233 const fallbackResult = yield* issueAssetUrl({252 const fallbackResult = yield* issueAssetUrl({
234 resource: { _tag: "project-favicon", cwd: root },253 resource: { _tag: "project-favicon", cwd: root },
...@@ -245,6 +264,31 @@ describe("AssetAccess", () => {...@@ -245,6 +264,31 @@ describe("AssetAccess", () => {
245 }).pipe(Effect.provide(testLayer)),264 }).pipe(Effect.provide(testLayer)),
246 );265 );
247266
267 it.effect("buckets project favicon expiry after content hashing", () =>
268 Effect.gen(function* () {
269 const crypto = yield* Crypto.Crypto;
270 const fileSystem = yield* FileSystem.FileSystem;
271 const path = yield* Path.Path;
272 const root = yield* fileSystem.makeTempDirectoryScoped({
273 prefix: "t3-asset-favicon-expiry-",
274 });
275 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg />");
276
277 const bucketMs = 30 * 60 * 1000;
278 yield* TestClock.setTime(bucketMs - 1);
279 const crossingCrypto = Crypto.make({
280 randomBytes: (size) => new Uint8Array(size),
281 digest: (algorithm, data) =>
282 TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))),
283 });
284 const result = yield* issueAssetUrl({
285 resource: { _tag: "project-favicon", cwd: root },
286 }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto));
287
288 expect(result.expiresAt).toBe(3 * bucketMs);
289 }).pipe(Effect.provide(testLayer)),
290 );
291
248 it.effect("preserves structured project favicon resolution causes", () =>292 it.effect("preserves structured project favicon resolution causes", () =>
249 Effect.gen(function* () {293 Effect.gen(function* () {
250 const fileSystem = yield* FileSystem.FileSystem;294 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+49-14
...@@ -21,7 +21,9 @@ import {...@@ -21,7 +21,9 @@ import {
21} from "@t3tools/shared/filePreview";21} from "@t3tools/shared/filePreview";
22import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";22import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
23import * as Clock from "effect/Clock";23import * as Clock from "effect/Clock";
24import * as Crypto from "effect/Crypto";
24import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
26import * as Encoding from "effect/Encoding";
25import * as FileSystem from "effect/FileSystem";27import * as FileSystem from "effect/FileSystem";
26import * as Option from "effect/Option";28import * as Option from "effect/Option";
27import * as Path from "effect/Path";29import * as Path from "effect/Path";
...@@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets";...@@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets";
4446
45const SIGNING_SECRET_NAME = "asset-access-signing-key";47const SIGNING_SECRET_NAME = "asset-access-signing-key";
46const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;48const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
49const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
50const PROJECT_FAVICON_VERSION_PREFIX = "v";
47const PREVIEW_ASSET_EXTENSIONS = new Set([51const PREVIEW_ASSET_EXTENSIONS = new Set([
48 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,52 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
49 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,53 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
...@@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
169 const fileSystem = yield* FileSystem.FileSystem;173 const fileSystem = yield* FileSystem.FileSystem;
170 const path = yield* Path.Path;174 const path = yield* Path.Path;
171 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;175 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
172 const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;176 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
173 let claims: AssetClaims;177 let claims: AssetClaims;
174 let fileName: string;178 let fileName: string;
175179
...@@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
293 ),297 ),
294 );298 );
295 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;299 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
296 if (300 const canonicalFaviconPath = relativePath
297 relativePath &&301 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
298 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(302 Effect.mapError(
299 Effect.mapError(303 (cause) =>
300 (cause) =>304 new AssetProjectFaviconInspectionError({
301 new AssetProjectFaviconInspectionError({305 resource: input.resource,
302 resource: input.resource,306 cause,
303 cause,307 }),
304 }),308 ),
305 ),309 )
306 ))310 : null;
307 ) {311 if (relativePath && !canonicalFaviconPath) {
308 return yield* new AssetProjectFaviconNotFoundError({312 return yield* new AssetProjectFaviconNotFoundError({
309 resource: input.resource,313 resource: input.resource,
310 });314 });
...@@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
324 relativePath,328 relativePath,
325 expiresAt,329 expiresAt,
326 };330 };
327 fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;331 if (relativePath && canonicalFaviconPath) {
332 const crypto = yield* Crypto.Crypto;
333 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(
334 Effect.mapError(
335 (cause) =>
336 new AssetProjectFaviconInspectionError({
337 resource: input.resource,
338 cause,
339 }),
340 ),
341 );
342 const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe(
343 Effect.map(Encoding.encodeHex),
344 Effect.mapError(
345 (cause) =>
346 new AssetProjectFaviconInspectionError({
347 resource: input.resource,
348 cause,
349 }),
350 ),
351 );
352 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`;
353 } else {
354 fileName = PROJECT_FAVICON_FALLBACK_MARKER;
355 }
328 break;356 break;
329 }357 }
330 }358 }
...@@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
339 }),367 }),
340 ),368 ),
341 );369 );
370 if (claims.kind === "project-favicon") {
371 const issuedAt = yield* Clock.currentTimeMillis;
372 expiresAt =
373 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *
374 PROJECT_FAVICON_TOKEN_BUCKET_MS;
375 claims = { ...claims, expiresAt };
376 }
342 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));377 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
343 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;378 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
344 return {379 return {
apps/web/src/components/ProjectFavicon.test.tsx created+128
...@@ -0,0 +1,128 @@
1import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import type { EnvironmentId } from "@t3tools/contracts";
4
5const testState = vi.hoisted(() => ({
6 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
7}));
8
9const hooks = vi.hoisted(() => {
10 let cursor = 0;
11 let slots: unknown[] = [];
12 const nextIndex = () => cursor++;
13
14 return {
15 beginRender() {
16 cursor = 0;
17 },
18 reset() {
19 cursor = 0;
20 slots = [];
21 },
22 useMemoCache(size: number): unknown[] {
23 const index = nextIndex();
24 if (!slots[index]) {
25 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
26 }
27 return slots[index] as unknown[];
28 },
29 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
30 const index = nextIndex();
31 if (index >= slots.length) {
32 slots[index] =
33 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
34 }
35 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
36 const previous = slots[index] as T;
37 slots[index] =
38 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
39 };
40 return [slots[index] as T, setValue];
41 },
42 };
43});
44
45vi.mock("react", async (importOriginal) => {
46 const actual = await importOriginal<typeof import("react")>();
47 return {
48 ...actual,
49 useState: hooks.useState,
50 };
51});
52
53vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
54vi.mock("../assets/assetUrls", () => ({
55 useAssetUrl: () => testState.faviconUrl,
56}));
57
58import { ProjectFavicon } from "./ProjectFavicon";
59
60type ProjectFaviconImageProps = {
61 readonly cacheKey: string;
62 readonly src: string;
63 readonly className?: string | undefined;
64 readonly fallbackIcon: ComponentType<{ className?: string }>;
65};
66
67type ImageElement = ReactElement<{
68 readonly src: string;
69 readonly onLoad?: () => void;
70 readonly onError?: () => void;
71}>;
72
73type ProjectFaviconImageElement = ReactElement<{
74 readonly children: [ReactElement | null, ImageElement | null, ImageElement | null];
75}>;
76
77function resolveImageComponent(): {
78 readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement;
79 readonly props: ProjectFaviconImageProps;
80} {
81 hooks.beginRender();
82 const element = ProjectFavicon({
83 environmentId: "environment-test" as EnvironmentId,
84 cwd: "/workspace-test",
85 }) as ReactElement<ProjectFaviconImageProps>;
86 hooks.reset();
87
88 return {
89 Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement,
90 props: element.props,
91 };
92}
93
94function renderImage(
95 Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement,
96 props: ProjectFaviconImageProps,
97): ProjectFaviconImageElement {
98 hooks.beginRender();
99 return Component(props);
100}
101
102describe("ProjectFavicon", () => {
103 beforeEach(() => {
104 hooks.reset();
105 });
106
107 it("falls back when the displayed favicon fails without discarding a valid older image early", () => {
108 const { Component, props } = resolveImageComponent();
109 const initialLoadingImage = renderImage(Component, props).props.children[2];
110 initialLoadingImage?.props.onLoad?.();
111
112 const refreshedProps = {
113 ...props,
114 src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg",
115 };
116 const refreshing = renderImage(Component, refreshedProps).props.children;
117 expect(refreshing[1]?.props.src).toBe(props.src);
118 refreshing[2]?.props.onError?.();
119
120 const afterRefreshError = renderImage(Component, refreshedProps).props.children;
121 expect(afterRefreshError[1]?.props.src).toBe(props.src);
122 afterRefreshError[1]?.props.onError?.();
123
124 const afterDisplayedError = renderImage(Component, refreshedProps).props.children;
125 expect(afterDisplayedError[0]).not.toBeNull();
126 expect(afterDisplayedError[1]).toBeNull();
127 });
128});
apps/web/src/components/ProjectFavicon.tsx+41-20
...@@ -1,12 +1,15 @@...@@ -1,12 +1,15 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";2import {
3 getProjectFaviconCacheKey,
4 isProjectFaviconFallbackUrl,
5} from "@t3tools/shared/projectFavicon";
3import { FolderIcon } from "lucide-react";6import { FolderIcon } from "lucide-react";
4import type { ComponentType } from "react";7import type { ComponentType } from "react";
5import { useState } from "react";8import { useState } from "react";
6import { useAssetUrl } from "../assets/assetUrls";9import { useAssetUrl } from "../assets/assetUrls";
7import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
811
9const loadedProjectFaviconSrcs = new Set<string>();12const loadedProjectFaviconSrcs = new Map<string, string>();
1013
11export function ProjectFavicon(input: {14export function ProjectFavicon(input: {
12 environmentId: EnvironmentId;15 environmentId: EnvironmentId;
...@@ -24,9 +27,12 @@ export function ProjectFavicon(input: {...@@ -24,9 +27,12 @@ export function ProjectFavicon(input: {
24 return <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />;27 return <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />;
25 }28 }
2629
30 const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);
31
27 return (32 return (
28 <ProjectFaviconImage33 <ProjectFaviconImage
29 key={src}34 key={cacheKey}
35 cacheKey={cacheKey}
30 src={src}36 src={src}
31 className={input.className}37 className={input.className}
32 fallbackIcon={FallbackIcon}38 fallbackIcon={FallbackIcon}
...@@ -45,37 +51,52 @@ function ProjectFaviconFallback({...@@ -45,37 +51,52 @@ function ProjectFaviconFallback({
45}51}
4652
47function ProjectFaviconImage({53function ProjectFaviconImage({
54 cacheKey,
48 src,55 src,
49 className,56 className,
50 fallbackIcon: FallbackIcon,57 fallbackIcon: FallbackIcon,
51}: {58}: {
59 readonly cacheKey: string;
52 readonly src: string;60 readonly src: string;
53 readonly className?: string | undefined;61 readonly className?: string | undefined;
54 readonly fallbackIcon: ComponentType<{ className?: string }>;62 readonly fallbackIcon: ComponentType<{ className?: string }>;
55}) {63}) {
56 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>64 const [displayedSrc, setDisplayedSrc] = useState<string | null>(
57 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",65 () => loadedProjectFaviconSrcs.get(cacheKey) ?? null,
58 );66 );
67 const isLoading = displayedSrc !== src;
68 const handleLoadError = (failedSrc: string) => {
69 if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) {
70 loadedProjectFaviconSrcs.delete(cacheKey);
71 }
72 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
73 };
5974
60 return (75 return (
61 <>76 <>
62 {status !== "loaded" ? (77 {displayedSrc === null ? (
63 <ProjectFaviconFallback className={className} icon={FallbackIcon} />78 <ProjectFaviconFallback className={className} icon={FallbackIcon} />
64 ) : null}79 ) : null}
65 <img80 {displayedSrc ? (
66 src={src}81 <img
67 alt=""82 src={displayedSrc}
68 className={cn(83 alt=""
69 "size-3.5 shrink-0 rounded-sm object-contain",84 className={cn("size-3.5 shrink-0 rounded-sm object-contain", className)}
70 status !== "loaded" && "hidden",85 onError={() => handleLoadError(displayedSrc)}
71 className,86 />
72 )}87 ) : null}
73 onLoad={() => {88 {isLoading ? (
74 loadedProjectFaviconSrcs.add(src);89 <img
75 setStatus("loaded");90 src={src}
76 }}91 alt=""
77 onError={() => setStatus("error")}92 className="hidden"
78 />93 onLoad={() => {
94 loadedProjectFaviconSrcs.set(cacheKey, src);
95 setDisplayedSrc(src);
96 }}
97 onError={() => handleLoadError(src)}
98 />
99 ) : null}
79 </>100 </>
80 );101 );
81}102}
packages/shared/src/projectFavicon.test.ts+25-1
...@@ -1,8 +1,32 @@...@@ -1,8 +1,32 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts";3import {
4 getProjectFaviconCacheKey,
5 isProjectFaviconFallbackUrl,
6 PROJECT_FAVICON_FALLBACK_MARKER,
7} from "./projectFavicon.ts";
48
5describe("project favicon", () => {9describe("project favicon", () => {
10 it("uses the project and versioned filename as the cache identity", () => {
11 const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg";
12 const refreshedUrl =
13 "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg";
14
15 expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe(
16 getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl),
17 );
18 expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe(
19 getProjectFaviconCacheKey(
20 "environment-1",
21 "/workspace",
22 "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg",
23 ),
24 );
25 expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe(
26 getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl),
27 );
28 });
29
6 it("identifies fallback asset URLs by their dedicated filename", () => {30 it("identifies fallback asset URLs by their dedicated filename", () => {
7 expect(31 expect(
8 isProjectFaviconFallbackUrl(32 isProjectFaviconFallbackUrl(
packages/shared/src/projectFavicon.ts+17
...@@ -1,5 +1,22 @@...@@ -1,5 +1,22 @@
1export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";1export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
22
3export function getProjectFaviconCacheKey(
4 environmentId: string,
5 workspaceRoot: string,
6 url: string,
7) {
8 let revision = url;
9
10 try {
11 const pathname = new URL(url, "https://t3.invalid").pathname;
12 revision = pathname.slice(pathname.lastIndexOf("/") + 1);
13 } catch {
14 // Keep the full value as a safe fallback for malformed URLs.
15 }
16
17 return JSON.stringify([environmentId, workspaceRoot, revision]);
18}
19
3export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean {20export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean {
4 if (!url) return false;21 if (!url) return false;
522