Skip to content
Source
Sign inSign up
Open History to see the last commit

browserFaviconStore.ts

354 lines12.2 KB

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