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