import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { onPreviewSessionsChanged, readThreadPreviewState, type ThreadPreviewState, } from "~/previewStateStore"; import { readPreparedConnection } from "~/state/session"; import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; import { resolveStorage } from "./lib/storage"; export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string }; export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; export const BROWSER_HISTORY_MAX_PROJECTS = 20; const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; const MAX_VALID_DATE_MS = 8_640_000_000_000_000; export function isValidHistoryTimestamp(value: unknown): value is number { return ( typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS ); } export function normalizeHistoryUrl(raw: string): string | null { let parsed: URL; try { parsed = new URL(normalizePreviewUrl(raw)); } catch { return null; } parsed.username = parsed.password = ""; return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; } function titleLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(visitLookupKey(normalized, environmentHostname)); if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1); return parsed.href; } function visitLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(normalized); const host = normalizeHostname(parsed.hostname); const environmentHost = environmentHostname && normalizeHostname(environmentHostname); if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost) parsed.hostname = "local"; return parsed.href; } function isStableLocalUrl(normalized: string): boolean { const host = normalizeHostname(new URL(normalized).hostname); return isLocalLoopbackHost(host) || host === "0.0.0.0"; } export function upsertHistoryEntry( entries: ReadonlyArray, url: string, at: number, options?: { insertOrdered?: boolean; environmentHostname?: string | null }, ): BrowserHistoryEntry[] { const key = visitLookupKey(url, options?.environmentHostname); const existing = entries.find( (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key, ); const rest = entries.filter( (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key, ); const visitedAt = options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at; const storedUrl = existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url; const entry: BrowserHistoryEntry = existing ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt } : { url, lastVisitedAt: visitedAt }; if (!options?.insertOrdered) return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt); const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry); return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); } export function evictExcessProjects( byProjectKey: Record, ): Record { const keys = Object.keys(byProjectKey); if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey; const kept = keys .toSorted( (a, b) => (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0), ) .slice(0, BROWSER_HISTORY_MAX_PROJECTS); return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []])); } export function migratePersistedBrowserHistoryState(persistedState: unknown): { byProjectKey: Record; } { if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} }; const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} }; const byProjectKey: Record = {}; for (const [projectKey, value] of Object.entries(raw as Record)) { if (!Array.isArray(value)) continue; const seenUrls = new Set(); const entries = value .flatMap((candidate) => { if (!candidate || typeof candidate !== "object") return []; const { url, lastVisitedAt, title } = candidate as Record; if (typeof url !== "string") return []; const normalizedUrl = normalizeHistoryUrl(url); if (!normalizedUrl) return []; if (!isValidHistoryTimestamp(lastVisitedAt)) return []; return [ { url: normalizedUrl, lastVisitedAt, ...(typeof title === "string" && title.length > 0 ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) } : {}), }, ]; }) .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt) .filter((entry) => { const key = visitLookupKey(entry.url); if (seenUrls.has(key)) return false; seenUrls.add(key); return true; }) .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); if (entries.length > 0) byProjectKey[projectKey] = entries; } return { byProjectKey: evictExcessProjects(byProjectKey) }; } const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1"; const PENDING_MAX_PER_THREAD = 10; const PENDING_MAX_THREADS = 20; type PendingVisit = { url: string; at: number; environmentHostname: string | null }; interface BrowserHistoryStoreState { byProjectKey: Record; projectKeyByThreadKey: Record; pendingVisitsByThreadKey: Record; recordVisit: ( projectKey: string, url: string, at: number, options?: { insertOrdered?: boolean; environmentHostname?: string | null }, ) => void; setTitleForUrl: ( projectKey: string, url: string, title: string, environmentHostname?: string | null, ) => void; removeUrl: (projectKey: string, url: string) => void; registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void; } function addPendingByThread( pendingByThreadKey: Record, threadKey: string, item: T, ): Record { const existing = pendingByThreadKey[threadKey] ?? []; const next = { ...pendingByThreadKey }; next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD); const keys = Object.keys(next); if (keys.length > PENDING_MAX_THREADS) { const oldestKey = keys[0]; if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey]; } return next; } export const useBrowserHistoryStore = create()( persist( (set, get) => ({ byProjectKey: {}, projectKeyByThreadKey: {}, pendingVisitsByThreadKey: {}, recordVisit: (projectKey, url, at, options) => { const normalized = normalizeHistoryUrl(url); if (!normalized) return; set((state) => { return { byProjectKey: evictExcessProjects({ ...state.byProjectKey, [projectKey]: upsertHistoryEntry( state.byProjectKey[projectKey] ?? [], normalized, at, options, ), }), }; }); }, setTitleForUrl: (projectKey, url, title, environmentHostname) => { const normalized = normalizeHistoryUrl(url); const state = get(); const entries = state.byProjectKey[projectKey]; const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH); if (!normalized || !entries || trimmed.length === 0) return; const key = titleLookupKey(normalized, environmentHostname); const index = entries.findIndex( (candidate) => titleLookupKey(candidate.url, environmentHostname) === key, ); if (index === -1 || entries[index]?.title === trimmed) return; set({ byProjectKey: { ...state.byProjectKey, [projectKey]: entries.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: trimmed } : candidate, ), }, }); }, removeUrl: (projectKey, url) => { const normalized = normalizeHistoryUrl(url); const state = get(); const entries = state.byProjectKey[projectKey]; if (!normalized || !entries) return; const next = entries.filter((candidate) => candidate.url !== normalized); if (next.length === entries.length) return; if (next.length === 0) { const { [projectKey]: _removed, ...rest } = state.byProjectKey; set({ byProjectKey: rest }); return; } set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } }); }, registerThreadProject: (ref, projectKey) => { const threadKey = scopedThreadKey(ref); const state = get(); const pendingVisits = state.pendingVisitsByThreadKey[threadKey]; if (state.projectKeyByThreadKey[threadKey] === projectKey && !pendingVisits) return; const nextPendingVisits = { ...state.pendingVisitsByThreadKey }; delete nextPendingVisits[threadKey]; set({ projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey }, pendingVisitsByThreadKey: nextPendingVisits, }); for (const visit of pendingVisits ?? []) get().recordVisit(projectKey, visit.url, visit.at, { insertOrdered: true, environmentHostname: visit.environmentHostname, }); recordPreviewTitles(ref, readThreadPreviewState(ref)); }, }), { name: BROWSER_HISTORY_STORAGE_KEY, version: 1, storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), partialize: (state) => ({ byProjectKey: state.byProjectKey, projectKeyByThreadKey: state.projectKeyByThreadKey, }), migrate: migratePersistedBrowserHistoryState, merge: mergeBrowserHistoryState, }, ), ); export function mergeBrowserHistoryState( persistedState: unknown, currentState: BrowserHistoryStoreState, ): BrowserHistoryStoreState { const migrated = migratePersistedBrowserHistoryState(persistedState); return { ...currentState, ...migrated, projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey), pendingVisitsByThreadKey: {}, }; } function migratePersistedThreadProjectKeys( persistedState: unknown, byProjectKey: Record, ): Record { if (!persistedState || typeof persistedState !== "object") return {}; const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; return Object.fromEntries( Object.entries(raw as Record) .filter( (entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] in byProjectKey, ) .slice(-100), ); } export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void { const threadKey = scopedThreadKey(ref); const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[threadKey]; const visitAt = at ?? Date.now(); const connection = readPreparedConnection(ref.environmentId); const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; if (!projectKey) { useBrowserHistoryStore.setState({ pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, { url, at: visitAt, environmentHostname, }), }); return; } state.recordVisit(projectKey, url, visitAt, { environmentHostname }); // A page can settle before the address bar records its visit. recordPreviewTitles(ref, readThreadPreviewState(ref)); } /** * Titles only update entries already in the thread's project history, so they * are applied both when a page settles and when its visit is recorded. */ function recordPreviewTitles(ref: ScopedThreadRef, preview: ThreadPreviewState): void { const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; if (!projectKey || !state.byProjectKey[projectKey]?.length) return; const connection = readPreparedConnection(ref.environmentId); const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null; for (const session of Object.values(preview.sessions)) { const status = session.navStatus; if (status._tag !== "Success" || !status.title) continue; state.setTitleForUrl(projectKey, status.url, status.title, environmentHostname); } } let previewTitlesStarted = false; export function startPreviewTitleRecording(): void { if (previewTitlesStarted) return; previewTitlesStarted = true; onPreviewSessionsChanged(recordPreviewTitles); } export function removeUrlForThread(ref: ScopedThreadRef, url: string): void { const state = useBrowserHistoryStore.getState(); const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; if (!projectKey) return; state.removeUrl(projectKey, url); } const EMPTY_HISTORY: ReadonlyArray = []; export function useThreadRecentHistory( ref: ScopedThreadRef, limit: number, ): ReadonlyArray { return useBrowserHistoryStore( useShallow((state) => { const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)]; const entries = projectKey ? state.byProjectKey[projectKey] : undefined; return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY; }), ); } export function resetBrowserHistoryForTests(): void { useBrowserHistoryStore.setState({ byProjectKey: {}, projectKeyByThreadKey: {}, pendingVisitsByThreadKey: {}, }); useBrowserHistoryStore.persist.clearStorage(); } // Compat shim: appInit starts this from the app-bootstrap layer. Until then the // store starts it so preview titles keep being recorded. startPreviewTitleRecording();