import { scopedProjectKey, scopedThreadKey, scopeProjectRef, } from "@t3tools/client-runtime/environment"; import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { useMemo } from "react"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { normalizeHostname } from "~/browser/browserTargetResolver"; import { useThreadShell } from "~/state/entities"; import { usePreparedConnection } from "~/state/session"; import { BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY, type BrowserFaviconEntry, canCanonicalizeFaviconWithoutEnvironment, evictExcessFavicons, faviconKey, faviconStorageLocation, isStorableFaviconDataUrl, isValidFaviconCapturedAt, migratePersistedBrowserFaviconState, } from "./browserFaviconLogic"; import { createMemoryStorage, type StateStorage } from "./lib/storage"; const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1"; const MAX_PENDING_ORIGINS_PER_THREAD = 10; const MAX_PENDING_THREADS = 20; const MAX_REGISTERED_THREADS = 100; type PendingFavicon = DesktopPreviewFavicon; type PendingFaviconsByOrigin = Record; export interface BrowserFaviconStoreState { byKey: Record; /** Capture buffering only. */ pendingByThreadKey: Record; /** Non-persisted fallback for draft/background threads without a hydrated shell. */ projectRefByThreadKey: Record; recordFavicon: ( key: string, dataUrl: string, capturedAt: number, aliases?: ReadonlyArray, ) => void; } function pendingOriginKey(pageUrl: string): string | null { return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null; } function addPendingFavicon( pendingByThreadKey: Record, threadKey: string, favicon: PendingFavicon, ): Record { const originKey = pendingOriginKey(favicon.pageUrl); if (!originKey) return pendingByThreadKey; const current = pendingByThreadKey[threadKey] ?? {}; const existing = current[originKey]; if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey; const nextForThread = { ...current, [originKey]: favicon, }; const boundedForThread = Object.fromEntries( Object.entries(nextForThread) .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt) .slice(0, MAX_PENDING_ORIGINS_PER_THREAD), ); const withoutThread = { ...pendingByThreadKey }; delete withoutThread[threadKey]; return Object.fromEntries( [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS), ); } export function resolveBrowserFaviconStorage(): StateStorage { const fallback = createMemoryStorage(); const shadowedNames = new Set(); let primary: Storage; try { if (typeof window === "undefined") return fallback; primary = window.localStorage; } catch { return fallback; } return { getItem: (name) => { if (shadowedNames.has(name)) return fallback.getItem(name); try { return primary.getItem(name); } catch { return fallback.getItem(name); } }, setItem: (name, value) => { fallback.setItem(name, value); try { primary.setItem(name, value); shadowedNames.delete(name); } catch { shadowedNames.add(name); } }, removeItem: (name) => { fallback.removeItem(name); try { primary.removeItem(name); shadowedNames.delete(name); } catch { shadowedNames.add(name); } }, }; } export const useBrowserFaviconStore = create()( persist( (set) => ({ byKey: {}, pendingByThreadKey: {}, projectRefByThreadKey: {}, recordFavicon: (key, dataUrl, capturedAt, aliases = []) => set((state) => { if (!isStorableFaviconDataUrl(dataUrl)) return state; if (!isValidFaviconCapturedAt(capturedAt)) return state; const existing = state.byKey[key]; const aliasCandidates = capturedAt > (existing?.capturedAt ?? -1) ? [...aliases, ...(existing?.aliases ?? [])] : [...(existing?.aliases ?? []), ...aliases]; const nextAliases = [...new Set(aliasCandidates)].slice( 0, BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY, ); const aliasesUnchanged = nextAliases.length === (existing?.aliases?.length ?? 0) && nextAliases.every((alias, index) => alias === existing?.aliases?.[index]); if (existing && capturedAt <= existing.capturedAt && aliasesUnchanged) return state; return { byKey: evictExcessFavicons({ ...state.byKey, [key]: { dataUrl: capturedAt > (existing?.capturedAt ?? -1) ? dataUrl : existing!.dataUrl, capturedAt: Math.max(capturedAt, existing?.capturedAt ?? -1), ...(nextAliases.length > 0 ? { aliases: nextAliases } : {}), }, }), }; }), }), { name: BROWSER_FAVICON_STORAGE_KEY, version: 1, storage: createJSONStorage(resolveBrowserFaviconStorage), partialize: (state) => ({ byKey: state.byKey }), migrate: migratePersistedBrowserFaviconState, merge: mergeBrowserFaviconState, }, ), ); export function mergeBrowserFaviconState( persistedState: unknown, currentState: BrowserFaviconStoreState, ): BrowserFaviconStoreState { return { ...currentState, ...migratePersistedBrowserFaviconState(persistedState), }; } export function registerFaviconProjectForThread( threadRef: ScopedThreadRef, projectRef: ScopedProjectRef, ): void { const threadKey = scopedThreadKey(threadRef); const state = useBrowserFaviconStore.getState(); const current = state.projectRefByThreadKey[threadKey]; if ( current?.environmentId === projectRef.environmentId && current.projectId === projectRef.projectId ) { return; } useBrowserFaviconStore.setState({ projectRefByThreadKey: Object.fromEntries( [ ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey), [threadKey, projectRef], ].slice(-MAX_REGISTERED_THREADS), ), }); } export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null { const shell = useThreadShell(threadRef); const shellProjectId = shell?.projectId ?? null; const shellProjectRef = useMemo( () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null), [shellProjectId, threadRef.environmentId], ); const registered = useBrowserFaviconStore( (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null, ); return shellProjectRef ?? registered; } export function recordFaviconForProject( projectRef: ScopedProjectRef, favicon: DesktopPreviewFavicon, environmentHostname: string | null, ): boolean { if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) { return false; } const location = faviconStorageLocation( scopedProjectKey(projectRef), favicon.pageUrl, environmentHostname, ); if (!location) return false; const state = useBrowserFaviconStore.getState(); const existing = state.byKey[location.key]; if ( existing && existing.capturedAt >= favicon.capturedAt && location.aliases.every((alias) => existing.aliases?.includes(alias)) ) return true; state.recordFavicon(location.key, favicon.dataUrl, favicon.capturedAt, location.aliases); return true; } export function recordFaviconForThread( threadRef: ScopedThreadRef, favicon: DesktopPreviewFavicon, projectRef: ScopedProjectRef | null, environmentHostname: string | undefined, ): boolean { if ( !isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt) || !pendingOriginKey(favicon.pageUrl) ) return false; const hostname = environmentHostname !== undefined ? environmentHostname : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl) ? null : undefined; if ( projectRef && hostname !== undefined && recordFaviconForProject(projectRef, favicon, hostname) ) { return true; } const threadKey = scopedThreadKey(threadRef); const state = useBrowserFaviconStore.getState(); const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon); if (pendingByThreadKey !== state.pendingByThreadKey) { useBrowserFaviconStore.setState({ pendingByThreadKey }); } return false; } export function flushPendingFaviconsForThread( threadRef: ScopedThreadRef, projectRef: ScopedProjectRef, environmentHostname: string | undefined, ): boolean { const threadKey = scopedThreadKey(threadRef); const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey]; if (!pending) return true; const remaining = Object.fromEntries( Object.entries(pending).filter(([, favicon]) => { const hostname = environmentHostname !== undefined ? environmentHostname : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl) ? null : undefined; return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname); }), ); useBrowserFaviconStore.setState((state) => { const pendingByThreadKey = { ...state.pendingByThreadKey }; if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey]; else pendingByThreadKey[threadKey] = remaining; return { pendingByThreadKey }; }); return Object.keys(remaining).length === 0; } export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null { const projectRef = useFaviconProjectRefForThread(threadRef); const preparedConnection = usePreparedConnection(threadRef.environmentId); const environmentHostname = Option.isSome(preparedConnection) ? new URL(preparedConnection.value.httpBaseUrl).hostname : null; return useBrowserFaviconStore((state) => lookupFavicon(state.byKey, projectRef, url, environmentHostname), ); } export function lookupFavicon( byKey: Record, projectRef: ScopedProjectRef | null, url: string, environmentHostname: string | null, ): string | null { const key = projectRef ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname) : null; if (!key) return null; const direct = byKey[key]; if (direct) return direct.dataUrl; try { const requestedHost = normalizeHostname(new URL(url).hostname); const localKey = faviconKey(scopedProjectKey(projectRef!), url, requestedHost); const localEntry = localKey ? byKey[localKey] : null; return localEntry?.aliases?.includes(requestedHost) ? localEntry.dataUrl : null; } catch { return null; } } export function resetBrowserFaviconsForTests(): void { useBrowserFaviconStore.setState({ byKey: {}, pendingByThreadKey: {}, projectRefByThreadKey: {}, }); useBrowserFaviconStore.persist.clearStorage(); }