1import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
2
3import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
4
5export type BrowserFaviconEntry = {
6 dataUrl: string;
7 capturedAt: number;
8 aliases?: ReadonlyArray<string>;
9};
10
11export const BROWSER_FAVICON_MAX_ENTRIES = 40;
12const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096;
13const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
14export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4;
15const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255;
16
17function formatFaviconHost(host: string): string {
18 return host.includes(":") ? `[${host}]` : host;
19}
20
21export function canCanonicalizeFaviconWithoutEnvironment(url: string): boolean {
22 try {
23 const parsed = new URL(url);
24 const host = normalizeHostname(parsed.hostname);
25 return (
26 (parsed.protocol === "http:" || parsed.protocol === "https:") &&
27 (isLocalLoopbackHost(host) || host === "0.0.0.0")
28 );
29 } catch {
30 return false;
31 }
32}
33
34export function isValidFaviconCapturedAt(value: unknown): value is number {
35 return (
36 typeof value === "number" &&
37 Number.isFinite(value) &&
38 value >= 0 &&
39 value <= FAVICON_CAPTURED_AT_MAX &&
40 value <= Date.now() + BROWSER_FAVICON_MAX_FUTURE_SKEW_MS
41 );
42}
43
44function migratePersistedFaviconKey(key: string): string | null {
45 if (key.length === 0 || key.length > BROWSER_FAVICON_MAX_KEY_LENGTH) return null;
46 const separator = key.indexOf(" ");
47 if (separator <= 0) return null;
48 const scope = key.slice(0, separator);
49 const origin = key.slice(separator + 1);
50 try {
51 const parsed = new URL(origin);
52 if (
53 (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
54 parsed.pathname !== "/" ||
55 parsed.search !== "" ||
56 parsed.hash !== "" ||
57 parsed.username !== "" ||
58 parsed.password !== ""
59 )
60 return null;
61 if (normalizeHostname(parsed.hostname) !== "local") return key;
62 const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
63 return `${scope} ${parsed.protocol}//localhost:${port}`;
64 } catch {
65 return null;
66 }
67}
68
69function persistedFaviconAlias(value: unknown): string | null {
70 if (typeof value !== "string" || value.length > BROWSER_FAVICON_MAX_ALIAS_LENGTH) return null;
71 const normalized = normalizeHostname(value);
72 if (!normalized || normalized !== value) return null;
73 try {
74 const parsed = new URL(`http://${formatFaviconHost(normalized)}`);
75 return normalizeHostname(parsed.hostname) === normalized ? normalized : null;
76 } catch {
77 return null;
78 }
79}
80
81export function faviconKey(
82 projectRefKey: string,
83 url: string,
84 environmentHostname: string | null,
85): string | null {
86 if (projectRefKey.length === 0) return null;
87 try {
88 const parsed = new URL(url);
89 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
90 const host = normalizeHostname(parsed.hostname);
91 const canonicalHost =
92 isLocalLoopbackHost(host) ||
93 host === "0.0.0.0" ||
94 (environmentHostname !== null && host === normalizeHostname(environmentHostname))
95 ? "localhost"
96 : host;
97 const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
98 return `${projectRefKey} ${parsed.protocol}//${formatFaviconHost(canonicalHost)}:${port}`;
99 } catch {
100 return null;
101 }
102}
103
104export function faviconStorageLocation(
105 projectRefKey: string,
106 url: string,
107 environmentHostname: string | null,
108): { readonly aliases: ReadonlyArray<string>; readonly key: string } | null {
109 const canonicalKey = faviconKey(projectRefKey, url, environmentHostname);
110 if (!canonicalKey) return null;
111 if (environmentHostname === null) return { aliases: [], key: canonicalKey };
112 try {
113 const parsed = new URL(url);
114 const host = normalizeHostname(parsed.hostname);
115 const normalizedEnvironmentHostname = normalizeHostname(environmentHostname);
116 if (
117 normalizedEnvironmentHostname.length === 0 ||
118 (!isLocalLoopbackHost(host) && host !== "0.0.0.0" && host !== normalizedEnvironmentHostname)
119 ) {
120 return { aliases: [], key: canonicalKey };
121 }
122 return {
123 aliases:
124 isLocalLoopbackHost(normalizedEnvironmentHostname) ||
125 normalizedEnvironmentHostname === "0.0.0.0"
126 ? []
127 : [normalizedEnvironmentHostname],
128 key: canonicalKey,
129 };
130 } catch {
131 return { aliases: [], key: canonicalKey };
132 }
133}
134
135export function isStorableFaviconDataUrl(value: unknown): value is string {
136 if (
137 typeof value !== "string" ||
138 !value.startsWith("data:image/png;base64,") ||
139 value.length > FAVICON_DATA_URL_MAX_LENGTH
140 ) {
141 return false;
142 }
143 const payload = value.slice("data:image/png;base64,".length);
144 return (
145 payload.length > 0 &&
146 payload.length % 4 !== 1 &&
147 !/[^a-z0-9+/=]/i.test(payload) &&
148 /^[a-z0-9+/]*={0,2}$/i.test(payload)
149 );
150}
151
152export function evictExcessFavicons(
153 byKey: Record<string, BrowserFaviconEntry>,
154): Record<string, BrowserFaviconEntry> {
155 const keys = Object.keys(byKey);
156 if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey;
157 return Object.fromEntries(
158 keys
159 .toSorted((left, right) => (byKey[right]?.capturedAt ?? 0) - (byKey[left]?.capturedAt ?? 0))
160 .slice(0, BROWSER_FAVICON_MAX_ENTRIES)
161 .map((key) => [key, byKey[key] as BrowserFaviconEntry]),
162 );
163}
164
165export function migratePersistedBrowserFaviconState(persistedState: unknown): {
166 byKey: Record<string, BrowserFaviconEntry>;
167} {
168 if (!persistedState || typeof persistedState !== "object") return { byKey: {} };
169 const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null;
170 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} };
171 const byKey: Record<string, BrowserFaviconEntry> = {};
172 for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
173 const migratedKey = migratePersistedFaviconKey(key);
174 if (!migratedKey) continue;
175 if (!value || typeof value !== "object") continue;
176 const { dataUrl, capturedAt } = value as Record<string, unknown>;
177 if (!isStorableFaviconDataUrl(dataUrl)) continue;
178 if (!isValidFaviconCapturedAt(capturedAt)) continue;
179 const rawAliases = (value as Record<string, unknown>).aliases;
180 const aliases = Array.isArray(rawAliases)
181 ? [...new Set(rawAliases.map(persistedFaviconAlias).filter((alias) => alias !== null))].slice(
182 0,
183 BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
184 )
185 : [];
186 const existing = byKey[migratedKey];
187 const newest = !existing || capturedAt > existing.capturedAt;
188 const mergedAliases = [
189 ...new Set([
190 ...(newest ? aliases : (existing?.aliases ?? [])),
191 ...(newest ? (existing?.aliases ?? []) : aliases),
192 ]),
193 ].slice(0, BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY);
194 byKey[migratedKey] = {
195 dataUrl: newest ? dataUrl : existing.dataUrl,
196 capturedAt: newest ? capturedAt : existing.capturedAt,
197 ...(mergedAliases.length > 0 ? { aliases: mergedAliases } : {}),
198 };
199 }
200 return { byKey: evictExcessFavicons(byKey) };
201}