1import { EnvironmentId } from "@t3tools/contracts";
2import { mediaMimeType } from "@t3tools/shared/filePreview";
3import {
4 getProjectFaviconCacheKey,
5 getProjectFaviconResourceKey,
6 isProjectFaviconFallbackUrl,
7} from "@t3tools/shared/projectFavicon";
8import * as Base64 from "effect/encoding/Base64";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11
12export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96;
13export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024;
14/** Larger sources are not worth decoding for an icon and are left to the remote URL. */
15export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024;
16export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024;
17export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128;
18
19export interface ProjectFaviconTarget {
20 readonly environmentId: EnvironmentId;
21 readonly cwd: string;
22 readonly faviconPath?: string | null | undefined;
23}
24
25const ImageDataUrl = Schema.String.check(
26 Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH),
27 Schema.isPattern(
28 /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/,
29 ),
30);
31const Entry = Schema.Struct({
32 environmentId: EnvironmentId,
33 cwd: Schema.String,
34 faviconPath: Schema.NullOr(Schema.String),
35 revision: Schema.String,
36 dataUrl: ImageDataUrl,
37});
38export type ProjectFaviconEntry = typeof Entry.Type;
39const decodeEntry = Schema.decodeUnknownOption(Entry);
40const isImageDataUrl = Schema.is(ImageDataUrl);
41
42function keyFor(target: ProjectFaviconTarget) {
43 return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath);
44}
45
46export interface ProjectFaviconStorage {
47 /** Every persisted record; entries that fail validation are ignored. */
48 readonly list: () => Promise<ReadonlyArray<unknown>>;
49 readonly put: (key: string, entry: ProjectFaviconEntry) => Promise<void>;
50 readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise<void>;
51}
52
53async function readBounded(response: Response, maxBytes: number) {
54 const declared = Number(response.headers.get("content-length"));
55 if (declared > maxBytes) throw new Error("Project icon is too large to decode.");
56 if (!response.body) {
57 const bytes = new Uint8Array<ArrayBuffer>(await response.arrayBuffer());
58 if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode.");
59 return bytes;
60 }
61 const reader = response.body.getReader();
62 const chunks: Array<Uint8Array> = [];
63 let total = 0;
64 try {
65 for (;;) {
66 const { done, value } = await reader.read();
67 if (done) break;
68 total += value.byteLength;
69 if (total > maxBytes) throw new Error("Project icon is too large to decode.");
70 chunks.push(value);
71 }
72 } finally {
73 reader.cancel().catch(() => {});
74 }
75 const bytes = new Uint8Array<ArrayBuffer>(new ArrayBuffer(total));
76 let offset = 0;
77 for (const chunk of chunks) {
78 bytes.set(chunk, offset);
79 offset += chunk.byteLength;
80 }
81 return bytes;
82}
83
84/**
85 * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs
86 * and small bitmaps are stored exactly as served. Larger bitmaps go through the
87 * platform downscaler; larger SVGs stay remote because rasterizing them without
88 * intrinsic dimensions is unreliable.
89 */
90export function createProjectFaviconImageLoader(input: {
91 readonly fetch?: typeof fetch;
92 readonly downscale: (
93 image: {
94 readonly url: string;
95 readonly mimeType: string;
96 readonly bytes: Uint8Array<ArrayBuffer>;
97 },
98 signal: AbortSignal,
99 ) => Promise<string>;
100}) {
101 const fetchImpl = input.fetch ?? globalThis.fetch;
102 return async (url: string, signal: AbortSignal): Promise<string> => {
103 const response = await fetchImpl(url, { signal });
104 if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`);
105 const contentType = response.headers
106 .get("content-type")
107 ?.split(";", 1)[0]
108 ?.trim()
109 .toLowerCase();
110 const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url);
111 if (!mimeType) throw new Error("Project icon has no image type.");
112 const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES);
113 signal.throwIfAborted();
114 const dataUrl = `data:${mimeType};base64,${Base64.encode(bytes)}`;
115 if (isImageDataUrl(dataUrl)) return dataUrl;
116 if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit.");
117 return input.downscale({ url, mimeType, bytes }, signal);
118 };
119}
120
121/** Stores small, self-contained images so startup never needs an old signed URL. */
122export function createProjectFaviconCache(input: {
123 readonly storage: ProjectFaviconStorage;
124 readonly load: (url: string, signal: AbortSignal) => Promise<string>;
125}) {
126 const entries = new Map<string, ProjectFaviconEntry>();
127 const environmentRevisions = new Map<EnvironmentId, number>();
128 let generation = 0;
129 let hydration: Promise<void> | undefined;
130 let clearing: Promise<void> | undefined;
131 const pending = new Set<Promise<void>>();
132
133 const persist = (operation: () => Promise<void>) => {
134 const task: Promise<void> = operation()
135 .catch(() => {
136 // Keep the in-memory image if local storage is full or unavailable.
137 })
138 .finally(() => pending.delete(task));
139 pending.add(task);
140 };
141
142 const remove = (key: string) => {
143 const entry = entries.get(key);
144 if (!entry) return;
145 entries.delete(key);
146 persist(() => input.storage.remove(key, entry));
147 };
148
149 const trim = () => {
150 let bytes = 0;
151 for (const entry of entries.values()) bytes += entry.dataUrl.length;
152 while (
153 entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES ||
154 bytes > PROJECT_FAVICON_CACHE_MAX_BYTES
155 ) {
156 const oldest = entries.entries().next().value;
157 if (!oldest) break;
158 bytes -= oldest[1].dataUrl.length;
159 remove(oldest[0]);
160 }
161 };
162
163 const hydrate = () =>
164 (hydration ??= (async () => {
165 try {
166 for (const record of await input.storage.list()) {
167 const entry = decodeEntry(record);
168 if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value);
169 }
170 trim();
171 } catch {
172 // A missing, corrupt, or unavailable cache must not prevent startup.
173 }
174 })());
175
176 const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null;
177
178 const resolve = async (
179 target: ProjectFaviconTarget,
180 url: string | null,
181 signal: AbortSignal,
182 ): Promise<string | null> => {
183 await clearing;
184 const startGeneration = generation;
185 const startRevision = environmentRevisions.get(target.environmentId) ?? 0;
186 await hydrate();
187 if (signal.aborted || url === null) return peek(target);
188 const key = keyFor(target);
189 if (isProjectFaviconFallbackUrl(url)) {
190 remove(key);
191 return null;
192 }
193 const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url);
194 const cached = entries.get(key);
195 if (cached) {
196 entries.delete(key);
197 entries.set(key, cached);
198 if (cached.revision === revision) return cached.dataUrl;
199 }
200 try {
201 const dataUrl = await input.load(url, signal);
202 if (
203 signal.aborted ||
204 startGeneration !== generation ||
205 startRevision !== (environmentRevisions.get(target.environmentId) ?? 0)
206 ) {
207 return peek(target);
208 }
209 if (isImageDataUrl(dataUrl)) {
210 const entry = {
211 environmentId: target.environmentId,
212 cwd: target.cwd,
213 faviconPath: target.faviconPath || null,
214 revision,
215 dataUrl,
216 };
217 entries.set(key, entry);
218 persist(() => input.storage.put(key, entry));
219 trim();
220 return dataUrl;
221 }
222 } catch {
223 // An outage or failed decode leaves the last successful image visible.
224 }
225 return peek(target) ?? url;
226 };
227
228 const flush = async () => {
229 await Promise.all(pending);
230 };
231
232 // A download that started before the clear sees the revision change and is discarded;
233 // one that starts during the clear waits for it, so it cannot repopulate storage.
234 const clear = async (environmentId?: EnvironmentId) => {
235 if (environmentId === undefined) generation += 1;
236 else
237 environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1);
238 const previous = clearing;
239 const task = (async () => {
240 await previous;
241 await hydrate();
242 for (const [key, entry] of entries) {
243 if (environmentId === undefined || entry.environmentId === environmentId) remove(key);
244 }
245 await flush();
246 })().finally(() => {
247 if (clearing === task) clearing = undefined;
248 });
249 clearing = task;
250 await task;
251 };
252
253 return {
254 hydrate,
255 peek,
256 resolve,
257 clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId),
258 clearAll: () => clear(),
259 flush,
260 };
261}
262
263export type ProjectFaviconCache = ReturnType<typeof createProjectFaviconCache>;