1import {
2 scopedProjectKey,
3 scopedThreadKey,
4 scopeProjectRef,
5} from "@t3tools/client-runtime/environment";
6import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { useMemo } from "react";
9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";
11
12import { normalizeHostname } from "~/browser/browserTargetResolver";
13import { useThreadShell } from "~/state/entities";
14import { usePreparedConnection } from "~/state/session";
15
16import {
17 BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
18 type BrowserFaviconEntry,
19 canCanonicalizeFaviconWithoutEnvironment,
20 evictExcessFavicons,
21 faviconKey,
22 faviconStorageLocation,
23 isStorableFaviconDataUrl,
24 isValidFaviconCapturedAt,
25 migratePersistedBrowserFaviconState,
26} from "./browserFaviconLogic";
27import { createMemoryStorage, type StateStorage } from "./lib/storage";
28
29const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1";
30const MAX_PENDING_ORIGINS_PER_THREAD = 10;
31const MAX_PENDING_THREADS = 20;
32const MAX_REGISTERED_THREADS = 100;
33
34type PendingFavicon = DesktopPreviewFavicon;
35type PendingFaviconsByOrigin = Record<string, PendingFavicon>;
36
37export interface BrowserFaviconStoreState {
38 byKey: Record<string, BrowserFaviconEntry>;
39 /** Capture buffering only. */
40 pendingByThreadKey: Record<string, PendingFaviconsByOrigin>;
41 /** Non-persisted fallback for draft/background threads without a hydrated shell. */
42 projectRefByThreadKey: Record<string, ScopedProjectRef>;
43 recordFavicon: (
44 key: string,
45 dataUrl: string,
46 capturedAt: number,
47 aliases?: ReadonlyArray<string>,
48 ) => void;
49}
50
51function pendingOriginKey(pageUrl: string): string | null {
52 return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null;
53}
54
55function addPendingFavicon(
56 pendingByThreadKey: Record<string, PendingFaviconsByOrigin>,
57 threadKey: string,
58 favicon: PendingFavicon,
59): Record<string, PendingFaviconsByOrigin> {
60 const originKey = pendingOriginKey(favicon.pageUrl);
61 if (!originKey) return pendingByThreadKey;
62 const current = pendingByThreadKey[threadKey] ?? {};
63 const existing = current[originKey];
64 if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey;
65 const nextForThread = {
66 ...current,
67 [originKey]: favicon,
68 };
69 const boundedForThread = Object.fromEntries(
70 Object.entries(nextForThread)
71 .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt)
72 .slice(0, MAX_PENDING_ORIGINS_PER_THREAD),
73 );
74
75 const withoutThread = { ...pendingByThreadKey };
76 delete withoutThread[threadKey];
77 return Object.fromEntries(
78 [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS),
79 );
80}
81
82export function resolveBrowserFaviconStorage(): StateStorage {
83 const fallback = createMemoryStorage();
84 const shadowedNames = new Set<string>();
85 let primary: Storage;
86 try {
87 if (typeof window === "undefined") return fallback;
88 primary = window.localStorage;
89 } catch {
90 return fallback;
91 }
92 return {
93 getItem: (name) => {
94 if (shadowedNames.has(name)) return fallback.getItem(name);
95 try {
96 return primary.getItem(name);
97 } catch {
98 return fallback.getItem(name);
99 }
100 },
101 setItem: (name, value) => {
102 fallback.setItem(name, value);
103 try {
104 primary.setItem(name, value);
105 shadowedNames.delete(name);
106 } catch {
107 shadowedNames.add(name);
108 }
109 },
110 removeItem: (name) => {
111 fallback.removeItem(name);
112 try {
113 primary.removeItem(name);
114 shadowedNames.delete(name);
115 } catch {
116 shadowedNames.add(name);
117 }
118 },
119 };
120}
121
122export const useBrowserFaviconStore = create<BrowserFaviconStoreState>()(
123 persist(
124 (set) => ({
125 byKey: {},
126 pendingByThreadKey: {},
127 projectRefByThreadKey: {},
128 recordFavicon: (key, dataUrl, capturedAt, aliases = []) =>
129 set((state) => {
130 if (!isStorableFaviconDataUrl(dataUrl)) return state;
131 if (!isValidFaviconCapturedAt(capturedAt)) return state;
132 const existing = state.byKey[key];
133 const aliasCandidates =
134 capturedAt > (existing?.capturedAt ?? -1)
135 ? [...aliases, ...(existing?.aliases ?? [])]
136 : [...(existing?.aliases ?? []), ...aliases];
137 const nextAliases = [...new Set(aliasCandidates)].slice(
138 0,
139 BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
140 );
141 const aliasesUnchanged =
142 nextAliases.length === (existing?.aliases?.length ?? 0) &&
143 nextAliases.every((alias, index) => alias === existing?.aliases?.[index]);
144 if (existing && capturedAt <= existing.capturedAt && aliasesUnchanged) return state;
145 return {
146 byKey: evictExcessFavicons({
147 ...state.byKey,
148 [key]: {
149 dataUrl: capturedAt > (existing?.capturedAt ?? -1) ? dataUrl : existing!.dataUrl,
150 capturedAt: Math.max(capturedAt, existing?.capturedAt ?? -1),
151 ...(nextAliases.length > 0 ? { aliases: nextAliases } : {}),
152 },
153 }),
154 };
155 }),
156 }),
157 {
158 name: BROWSER_FAVICON_STORAGE_KEY,
159 version: 1,
160 storage: createJSONStorage(resolveBrowserFaviconStorage),
161 partialize: (state) => ({ byKey: state.byKey }),
162 migrate: migratePersistedBrowserFaviconState,
163 merge: mergeBrowserFaviconState,
164 },
165 ),
166);
167
168export function mergeBrowserFaviconState(
169 persistedState: unknown,
170 currentState: BrowserFaviconStoreState,
171): BrowserFaviconStoreState {
172 return {
173 ...currentState,
174 ...migratePersistedBrowserFaviconState(persistedState),
175 };
176}
177
178export function registerFaviconProjectForThread(
179 threadRef: ScopedThreadRef,
180 projectRef: ScopedProjectRef,
181): void {
182 const threadKey = scopedThreadKey(threadRef);
183 const state = useBrowserFaviconStore.getState();
184 const current = state.projectRefByThreadKey[threadKey];
185 if (
186 current?.environmentId === projectRef.environmentId &&
187 current.projectId === projectRef.projectId
188 ) {
189 return;
190 }
191 useBrowserFaviconStore.setState({
192 projectRefByThreadKey: Object.fromEntries(
193 [
194 ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey),
195 [threadKey, projectRef],
196 ].slice(-MAX_REGISTERED_THREADS),
197 ),
198 });
199}
200
201export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null {
202 const shell = useThreadShell(threadRef);
203 const shellProjectId = shell?.projectId ?? null;
204 const shellProjectRef = useMemo(
205 () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null),
206 [shellProjectId, threadRef.environmentId],
207 );
208 const registered = useBrowserFaviconStore(
209 (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null,
210 );
211 return shellProjectRef ?? registered;
212}
213
214export function recordFaviconForProject(
215 projectRef: ScopedProjectRef,
216 favicon: DesktopPreviewFavicon,
217 environmentHostname: string | null,
218): boolean {
219 if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) {
220 return false;
221 }
222 const location = faviconStorageLocation(
223 scopedProjectKey(projectRef),
224 favicon.pageUrl,
225 environmentHostname,
226 );
227 if (!location) return false;
228 const state = useBrowserFaviconStore.getState();
229 const existing = state.byKey[location.key];
230 if (
231 existing &&
232 existing.capturedAt >= favicon.capturedAt &&
233 location.aliases.every((alias) => existing.aliases?.includes(alias))
234 )
235 return true;
236 state.recordFavicon(location.key, favicon.dataUrl, favicon.capturedAt, location.aliases);
237 return true;
238}
239
240export function recordFaviconForThread(
241 threadRef: ScopedThreadRef,
242 favicon: DesktopPreviewFavicon,
243 projectRef: ScopedProjectRef | null,
244 environmentHostname: string | undefined,
245): boolean {
246 if (
247 !isStorableFaviconDataUrl(favicon.dataUrl) ||
248 !isValidFaviconCapturedAt(favicon.capturedAt) ||
249 !pendingOriginKey(favicon.pageUrl)
250 )
251 return false;
252 const hostname =
253 environmentHostname !== undefined
254 ? environmentHostname
255 : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
256 ? null
257 : undefined;
258 if (
259 projectRef &&
260 hostname !== undefined &&
261 recordFaviconForProject(projectRef, favicon, hostname)
262 ) {
263 return true;
264 }
265 const threadKey = scopedThreadKey(threadRef);
266 const state = useBrowserFaviconStore.getState();
267 const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon);
268 if (pendingByThreadKey !== state.pendingByThreadKey) {
269 useBrowserFaviconStore.setState({ pendingByThreadKey });
270 }
271 return false;
272}
273
274export function flushPendingFaviconsForThread(
275 threadRef: ScopedThreadRef,
276 projectRef: ScopedProjectRef,
277 environmentHostname: string | undefined,
278): boolean {
279 const threadKey = scopedThreadKey(threadRef);
280 const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey];
281 if (!pending) return true;
282 const remaining = Object.fromEntries(
283 Object.entries(pending).filter(([, favicon]) => {
284 const hostname =
285 environmentHostname !== undefined
286 ? environmentHostname
287 : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
288 ? null
289 : undefined;
290 return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname);
291 }),
292 );
293 useBrowserFaviconStore.setState((state) => {
294 const pendingByThreadKey = { ...state.pendingByThreadKey };
295 if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey];
296 else pendingByThreadKey[threadKey] = remaining;
297 return { pendingByThreadKey };
298 });
299 return Object.keys(remaining).length === 0;
300}
301
302export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null {
303 const projectRef = useFaviconProjectRefForThread(threadRef);
304 const preparedConnection = usePreparedConnection(threadRef.environmentId);
305 const environmentHostname = Option.isSome(preparedConnection)
306 ? new URL(preparedConnection.value.httpBaseUrl).hostname
307 : null;
308 return useBrowserFaviconStore((state) =>
309 lookupFavicon(state.byKey, projectRef, url, environmentHostname),
310 );
311}
312
313export function lookupFavicon(
314 byKey: Record<string, BrowserFaviconEntry>,
315 projectRef: ScopedProjectRef | null,
316 url: string,
317 environmentHostname: string | null,
318): string | null {
319 const key = projectRef
320 ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname)
321 : null;
322 if (!key) return null;
323 const direct = byKey[key];
324 if (direct) return direct.dataUrl;
325 try {
326 const requestedHost = normalizeHostname(new URL(url).hostname);
327 const localKey = faviconKey(scopedProjectKey(projectRef!), url, requestedHost);
328 const localEntry = localKey ? byKey[localKey] : null;
329 return localEntry?.aliases?.includes(requestedHost) ? localEntry.dataUrl : null;
330 } catch {
331 return null;
332 }
333}
334
335export function resetBrowserFaviconsForTests(): void {
336 useBrowserFaviconStore.setState({
337 byKey: {},
338 pendingByThreadKey: {},
339 projectRefByThreadKey: {},
340 });
341 useBrowserFaviconStore.persist.clearStorage();
342}