1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import { create } from "zustand";
4import { createJSONStorage, persist } from "zustand/middleware";
5import { useShallow } from "zustand/react/shallow";
6
7import { normalizePreviewUrl } from "@t3tools/shared/preview";
8import { readPreparedConnection } from "~/state/session";
9
10import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
11import { resolveStorage } from "./lib/storage";
12
13export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: string };
14
15export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50;
16export const BROWSER_HISTORY_MAX_PROJECTS = 20;
17const BROWSER_HISTORY_MAX_URL_LENGTH = 2048;
18export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512;
19const MAX_VALID_DATE_MS = 8_640_000_000_000_000;
20
21export function isValidHistoryTimestamp(value: unknown): value is number {
22 return (
23 typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= MAX_VALID_DATE_MS
24 );
25}
26
27export function normalizeHistoryUrl(raw: string): string | null {
28 let parsed: URL;
29 try {
30 parsed = new URL(normalizePreviewUrl(raw));
31 } catch {
32 return null;
33 }
34 parsed.username = parsed.password = "";
35 return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href;
36}
37
38function titleLookupKey(normalized: string, environmentHostname?: string | null): string {
39 const parsed = new URL(visitLookupKey(normalized, environmentHostname));
40 if (parsed.pathname !== "/" && parsed.pathname.endsWith("/"))
41 parsed.pathname = parsed.pathname.slice(0, -1);
42 return parsed.href;
43}
44
45function visitLookupKey(normalized: string, environmentHostname?: string | null): string {
46 const parsed = new URL(normalized);
47 const host = normalizeHostname(parsed.hostname);
48 const environmentHost = environmentHostname && normalizeHostname(environmentHostname);
49 if (isLocalLoopbackHost(host) || host === "0.0.0.0" || host === environmentHost)
50 parsed.hostname = "local";
51 return parsed.href;
52}
53
54function isStableLocalUrl(normalized: string): boolean {
55 const host = normalizeHostname(new URL(normalized).hostname);
56 return isLocalLoopbackHost(host) || host === "0.0.0.0";
57}
58
59export function upsertHistoryEntry(
60 entries: ReadonlyArray<BrowserHistoryEntry>,
61 url: string,
62 at: number,
63 options?: { insertOrdered?: boolean; environmentHostname?: string | null },
64): BrowserHistoryEntry[] {
65 const key = visitLookupKey(url, options?.environmentHostname);
66 const existing = entries.find(
67 (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) === key,
68 );
69 const rest = entries.filter(
70 (candidate) => visitLookupKey(candidate.url, options?.environmentHostname) !== key,
71 );
72 const visitedAt =
73 options?.insertOrdered && existing && existing.lastVisitedAt > at ? existing.lastVisitedAt : at;
74 const storedUrl =
75 existing && (isStableLocalUrl(existing.url) || !isStableLocalUrl(url)) ? existing.url : url;
76 const entry: BrowserHistoryEntry = existing
77 ? { ...existing, url: storedUrl, lastVisitedAt: visitedAt }
78 : { url, lastVisitedAt: visitedAt };
79 if (!options?.insertOrdered)
80 return [entry, ...rest].slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
81 const index = rest.findIndex((candidate) => candidate.lastVisitedAt < entry.lastVisitedAt);
82 const next = index === -1 ? [...rest, entry] : rest.toSpliced(index, 0, entry);
83 return next.slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
84}
85
86export function evictExcessProjects(
87 byProjectKey: Record<string, BrowserHistoryEntry[]>,
88): Record<string, BrowserHistoryEntry[]> {
89 const keys = Object.keys(byProjectKey);
90 if (keys.length <= BROWSER_HISTORY_MAX_PROJECTS) return byProjectKey;
91 const kept = keys
92 .toSorted(
93 (a, b) =>
94 (byProjectKey[b]?.[0]?.lastVisitedAt ?? 0) - (byProjectKey[a]?.[0]?.lastVisitedAt ?? 0),
95 )
96 .slice(0, BROWSER_HISTORY_MAX_PROJECTS);
97 return Object.fromEntries(kept.map((key) => [key, byProjectKey[key] ?? []]));
98}
99
100export function migratePersistedBrowserHistoryState(persistedState: unknown): {
101 byProjectKey: Record<string, BrowserHistoryEntry[]>;
102} {
103 if (!persistedState || typeof persistedState !== "object") return { byProjectKey: {} };
104 const raw = (persistedState as { byProjectKey?: unknown }).byProjectKey;
105 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byProjectKey: {} };
106 const byProjectKey: Record<string, BrowserHistoryEntry[]> = {};
107 for (const [projectKey, value] of Object.entries(raw as Record<string, unknown>)) {
108 if (!Array.isArray(value)) continue;
109 const seenUrls = new Set<string>();
110 const entries = value
111 .flatMap<BrowserHistoryEntry>((candidate) => {
112 if (!candidate || typeof candidate !== "object") return [];
113 const { url, lastVisitedAt, title } = candidate as Record<string, unknown>;
114 if (typeof url !== "string") return [];
115 const normalizedUrl = normalizeHistoryUrl(url);
116 if (!normalizedUrl) return [];
117 if (!isValidHistoryTimestamp(lastVisitedAt)) return [];
118 return [
119 {
120 url: normalizedUrl,
121 lastVisitedAt,
122 ...(typeof title === "string" && title.length > 0
123 ? { title: title.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH) }
124 : {}),
125 },
126 ];
127 })
128 .toSorted((a, b) => b.lastVisitedAt - a.lastVisitedAt)
129 .filter((entry) => {
130 const key = visitLookupKey(entry.url);
131 if (seenUrls.has(key)) return false;
132 seenUrls.add(key);
133 return true;
134 })
135 .slice(0, BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT);
136 if (entries.length > 0) byProjectKey[projectKey] = entries;
137 }
138 return { byProjectKey: evictExcessProjects(byProjectKey) };
139}
140
141const BROWSER_HISTORY_STORAGE_KEY = "t3code:browser-history:v1";
142
143const PENDING_MAX_PER_THREAD = 10;
144const PENDING_MAX_THREADS = 20;
145
146type PendingVisit = { url: string; at: number; environmentHostname: string | null };
147type PendingTitle = { url: string; title: string; environmentHostname: string | null | undefined };
148
149interface BrowserHistoryStoreState {
150 byProjectKey: Record<string, BrowserHistoryEntry[]>;
151 projectKeyByThreadKey: Record<string, string>;
152 pendingVisitsByThreadKey: Record<string, PendingVisit[]>;
153 pendingTitlesByThreadKey: Record<string, PendingTitle[]>;
154 recordVisit: (
155 projectKey: string,
156 url: string,
157 at: number,
158 options?: { insertOrdered?: boolean; environmentHostname?: string | null },
159 ) => void;
160 setTitleForUrl: (
161 projectKey: string,
162 url: string,
163 title: string,
164 environmentHostname?: string | null,
165 ) => void;
166 removeUrl: (projectKey: string, url: string) => void;
167 registerThreadProject: (ref: ScopedThreadRef, projectKey: string) => void;
168}
169
170function addPendingByThread<T>(
171 pendingByThreadKey: Record<string, T[]>,
172 threadKey: string,
173 item: T,
174): Record<string, T[]> {
175 const existing = pendingByThreadKey[threadKey] ?? [];
176 const next = { ...pendingByThreadKey };
177 next[threadKey] = [...existing, item].slice(-PENDING_MAX_PER_THREAD);
178 const keys = Object.keys(next);
179 if (keys.length > PENDING_MAX_THREADS) {
180 const oldestKey = keys[0];
181 if (oldestKey !== undefined && oldestKey !== threadKey) delete next[oldestKey];
182 }
183 return next;
184}
185
186export const useBrowserHistoryStore = create<BrowserHistoryStoreState>()(
187 persist(
188 (set, get) => ({
189 byProjectKey: {},
190 projectKeyByThreadKey: {},
191 pendingVisitsByThreadKey: {},
192 pendingTitlesByThreadKey: {},
193 recordVisit: (projectKey, url, at, options) => {
194 const normalized = normalizeHistoryUrl(url);
195 if (!normalized) return;
196 set((state) => {
197 return {
198 byProjectKey: evictExcessProjects({
199 ...state.byProjectKey,
200 [projectKey]: upsertHistoryEntry(
201 state.byProjectKey[projectKey] ?? [],
202 normalized,
203 at,
204 options,
205 ),
206 }),
207 };
208 });
209 },
210 setTitleForUrl: (projectKey, url, title, environmentHostname) => {
211 const normalized = normalizeHistoryUrl(url);
212 const state = get();
213 const entries = state.byProjectKey[projectKey];
214 const trimmed = title.trim().slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH);
215 if (!normalized || !entries || trimmed.length === 0) return;
216 const key = titleLookupKey(normalized, environmentHostname);
217 const index = entries.findIndex(
218 (candidate) => titleLookupKey(candidate.url, environmentHostname) === key,
219 );
220 if (index === -1 || entries[index]?.title === trimmed) return;
221 set({
222 byProjectKey: {
223 ...state.byProjectKey,
224 [projectKey]: entries.map((candidate, candidateIndex) =>
225 candidateIndex === index ? { ...candidate, title: trimmed } : candidate,
226 ),
227 },
228 });
229 },
230 removeUrl: (projectKey, url) => {
231 const normalized = normalizeHistoryUrl(url);
232 const state = get();
233 const entries = state.byProjectKey[projectKey];
234 if (!normalized || !entries) return;
235 const next = entries.filter((candidate) => candidate.url !== normalized);
236 if (next.length === entries.length) return;
237 if (next.length === 0) {
238 const { [projectKey]: _removed, ...rest } = state.byProjectKey;
239 set({ byProjectKey: rest });
240 return;
241 }
242 set({ byProjectKey: { ...state.byProjectKey, [projectKey]: next } });
243 },
244 registerThreadProject: (ref, projectKey) => {
245 const threadKey = scopedThreadKey(ref);
246 const state = get();
247 const pendingVisits = state.pendingVisitsByThreadKey[threadKey];
248 const pendingTitles = state.pendingTitlesByThreadKey[threadKey];
249 if (
250 state.projectKeyByThreadKey[threadKey] === projectKey &&
251 !pendingVisits &&
252 !pendingTitles
253 ) {
254 return;
255 }
256 const nextPendingVisits = { ...state.pendingVisitsByThreadKey };
257 const nextPendingTitles = { ...state.pendingTitlesByThreadKey };
258 delete nextPendingVisits[threadKey];
259 delete nextPendingTitles[threadKey];
260 set({
261 projectKeyByThreadKey: { ...state.projectKeyByThreadKey, [threadKey]: projectKey },
262 pendingVisitsByThreadKey: nextPendingVisits,
263 pendingTitlesByThreadKey: nextPendingTitles,
264 });
265 for (const visit of pendingVisits ?? [])
266 get().recordVisit(projectKey, visit.url, visit.at, {
267 insertOrdered: true,
268 environmentHostname: visit.environmentHostname,
269 });
270 for (const pendingTitle of pendingTitles ?? [])
271 get().setTitleForUrl(
272 projectKey,
273 pendingTitle.url,
274 pendingTitle.title,
275 pendingTitle.environmentHostname,
276 );
277 },
278 }),
279 {
280 name: BROWSER_HISTORY_STORAGE_KEY,
281 version: 1,
282 storage: createJSONStorage(() =>
283 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
284 ),
285 partialize: (state) => ({
286 byProjectKey: state.byProjectKey,
287 projectKeyByThreadKey: state.projectKeyByThreadKey,
288 }),
289 migrate: migratePersistedBrowserHistoryState,
290 merge: mergeBrowserHistoryState,
291 },
292 ),
293);
294
295export function mergeBrowserHistoryState(
296 persistedState: unknown,
297 currentState: BrowserHistoryStoreState,
298): BrowserHistoryStoreState {
299 const migrated = migratePersistedBrowserHistoryState(persistedState);
300 return {
301 ...currentState,
302 ...migrated,
303 projectKeyByThreadKey: migratePersistedThreadProjectKeys(persistedState, migrated.byProjectKey),
304 pendingVisitsByThreadKey: {},
305 pendingTitlesByThreadKey: {},
306 };
307}
308
309function migratePersistedThreadProjectKeys(
310 persistedState: unknown,
311 byProjectKey: Record<string, BrowserHistoryEntry[]>,
312): Record<string, string> {
313 if (!persistedState || typeof persistedState !== "object") return {};
314 const raw = (persistedState as { projectKeyByThreadKey?: unknown }).projectKeyByThreadKey;
315 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
316 return Object.fromEntries(
317 Object.entries(raw as Record<string, unknown>)
318 .filter(
319 (entry): entry is [string, string] =>
320 typeof entry[1] === "string" && entry[1] in byProjectKey,
321 )
322 .slice(-100),
323 );
324}
325
326export function recordVisitForThread(ref: ScopedThreadRef, url: string, at?: number): void {
327 const threadKey = scopedThreadKey(ref);
328 const state = useBrowserHistoryStore.getState();
329 const projectKey = state.projectKeyByThreadKey[threadKey];
330 const visitAt = at ?? Date.now();
331 const connection = readPreparedConnection(ref.environmentId);
332 const environmentHostname = connection ? new URL(connection.httpBaseUrl).hostname : null;
333 if (!projectKey) {
334 useBrowserHistoryStore.setState({
335 pendingVisitsByThreadKey: addPendingByThread(state.pendingVisitsByThreadKey, threadKey, {
336 url,
337 at: visitAt,
338 environmentHostname,
339 }),
340 });
341 return;
342 }
343 state.recordVisit(projectKey, url, visitAt, { environmentHostname });
344}
345
346export function setTitleForThreadUrl(
347 ref: ScopedThreadRef,
348 url: string,
349 title: string,
350 environmentHostname?: string | null,
351): void {
352 const threadKey = scopedThreadKey(ref);
353 const state = useBrowserHistoryStore.getState();
354 const projectKey = state.projectKeyByThreadKey[threadKey];
355 if (!projectKey) {
356 useBrowserHistoryStore.setState({
357 pendingTitlesByThreadKey: addPendingByThread(state.pendingTitlesByThreadKey, threadKey, {
358 url,
359 title,
360 environmentHostname,
361 }),
362 });
363 return;
364 }
365 state.setTitleForUrl(projectKey, url, title, environmentHostname);
366}
367
368export function removeUrlForThread(ref: ScopedThreadRef, url: string): void {
369 const state = useBrowserHistoryStore.getState();
370 const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
371 if (!projectKey) return;
372 state.removeUrl(projectKey, url);
373}
374
375const EMPTY_HISTORY: ReadonlyArray<BrowserHistoryEntry> = [];
376
377export function useThreadRecentHistory(
378 ref: ScopedThreadRef,
379 limit: number,
380): ReadonlyArray<BrowserHistoryEntry> {
381 return useBrowserHistoryStore(
382 useShallow((state) => {
383 const projectKey = state.projectKeyByThreadKey[scopedThreadKey(ref)];
384 const entries = projectKey ? state.byProjectKey[projectKey] : undefined;
385 return entries && entries.length > 0 ? entries.slice(0, limit) : EMPTY_HISTORY;
386 }),
387 );
388}
389
390export function resetBrowserHistoryForTests(): void {
391 useBrowserHistoryStore.setState({
392 byProjectKey: {},
393 projectKeyByThreadKey: {},
394 pendingVisitsByThreadKey: {},
395 pendingTitlesByThreadKey: {},
396 });
397 useBrowserHistoryStore.persist.clearStorage();
398}