342 lines · 11.2 KB · TypeScript
RawDownload
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<string, PendingFavicon>; export interface BrowserFaviconStoreState {  byKey: Record<string, BrowserFaviconEntry>;  /** Capture buffering only. */  pendingByThreadKey: Record<string, PendingFaviconsByOrigin>;  /** Non-persisted fallback for draft/background threads without a hydrated shell. */  projectRefByThreadKey: Record<string, ScopedProjectRef>;  recordFavicon: (    key: string,    dataUrl: string,    capturedAt: number,    aliases?: ReadonlyArray<string>,  ) => void;} function pendingOriginKey(pageUrl: string): string | null {  return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null;} function addPendingFavicon(  pendingByThreadKey: Record<string, PendingFaviconsByOrigin>,  threadKey: string,  favicon: PendingFavicon,): Record<string, PendingFaviconsByOrigin> {  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<string>();  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<BrowserFaviconStoreState>()(  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<string, BrowserFaviconEntry>,  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();}