| 1 | import { |
| 2 | scopedProjectKey, |
| 3 | scopedThreadKey, |
| 4 | scopeProjectRef, |
| 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import { useMemo } from "react"; |
| 9 | import { create } from "zustand"; |
| 10 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 11 | |
| 12 | import { normalizeHostname } from "~/browser/browserTargetResolver"; |
| 13 | import { useThreadShell } from "~/state/entities"; |
| 14 | import { usePreparedConnection } from "~/state/session"; |
| 15 | |
| 16 | import { |
| 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"; |
| 27 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; |
| 28 | |
| 29 | const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1"; |
| 30 | const MAX_PENDING_ORIGINS_PER_THREAD = 10; |
| 31 | const MAX_PENDING_THREADS = 20; |
| 32 | const MAX_REGISTERED_THREADS = 100; |
| 33 | |
| 34 | type PendingFavicon = DesktopPreviewFavicon; |
| 35 | type PendingFaviconsByOrigin = Record<string, PendingFavicon>; |
| 36 | |
| 37 | export 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 | |
| 51 | function pendingOriginKey(pageUrl: string): string | null { |
| 52 | return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null; |
| 53 | } |
| 54 | |
| 55 | function 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 | |
| 82 | export 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 | |
| 122 | export 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 | |
| 168 | export function mergeBrowserFaviconState( |
| 169 | persistedState: unknown, |
| 170 | currentState: BrowserFaviconStoreState, |
| 171 | ): BrowserFaviconStoreState { |
| 172 | return { |
| 173 | ...currentState, |
| 174 | ...migratePersistedBrowserFaviconState(persistedState), |
| 175 | }; |
| 176 | } |
| 177 | |
| 178 | export 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 | |
| 201 | export 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 | |
| 214 | export 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 | |
| 240 | export 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 | |
| 274 | export 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 | |
| 302 | export 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 | |
| 313 | export 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 | |
| 335 | export function resetBrowserFaviconsForTests(): void { |
| 336 | useBrowserFaviconStore.setState({ |
| 337 | byKey: {}, |
| 338 | pendingByThreadKey: {}, |
| 339 | projectRefByThreadKey: {}, |
| 340 | }); |
| 341 | useBrowserFaviconStore.persist.clearStorage(); |
| 342 | } |