authorgravatar for will@autimo.comWill Sheldon <will@autimo.com> 2026-09-01 12:19:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 19:19:20+00:00
log643b21edaa08eb1d19d633dbb0b275164bfa60be
tree549e7ffda8dcefd7faebff76ea01ad13303b3ced
parent261380f91f764673c7fcefcb6a3f775526dbb776
signature Signed by PGP key B5690EEEBB952194

fix(server): cache project favicon resolution (#9080)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

2 files changed, 137 insertions(+), 3 deletions(-)

apps/server/src/project/ProjectFaviconResolver.test.ts+60
......@@ -1,10 +1,12 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { it, describe, expect } from "@effect/vitest";
3import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
45import * as FileSystem from "effect/FileSystem";
56import * as Layer from "effect/Layer";
67import * as Path from "effect/Path";
78import * as PlatformError from "effect/PlatformError";
9import { TestClock } from "effect/testing";
810
911import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1012import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
......@@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
4951
5052it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
5153 describe("resolvePath", () => {
54 it.effect("serves repeated resolves from cache instead of re-walking candidates", () =>
55 Effect.gen(function* () {
56 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
57 const cwd = yield* makeTempDir;
58 yield* writeTextFile(cwd, "public/favicon.svg", "<svg>public</svg>");
59
60 const resolved = yield* resolver.resolvePath(cwd);
61 expect(resolved?.endsWith("public/favicon.svg")).toBe(true);
62
63 // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked
64 // the candidate list again would switch to it. Staying on the original
65 // answer is only possible from cache.
66 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
67
68 for (const _attempt of [1, 2, 3]) {
69 expect(yield* resolver.resolvePath(cwd)).toBe(resolved);
70 }
71
72 yield* TestClock.adjust(Duration.minutes(11));
73
74 expect((yield* resolver.resolvePath(cwd))?.endsWith("/favicon.svg")).toBe(true);
75 expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved);
76 }).pipe(Effect.provide(TestClock.layer())),
77 );
78
79 it.effect("falls back at once when a cached favicon is deleted", () =>
80 Effect.gen(function* () {
81 const fileSystem = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
84 const cwd = yield* makeTempDir;
85 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
86
87 expect(yield* resolver.resolvePath(cwd)).not.toBeNull();
88
89 yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie);
90
91 // Still inside the positive TTL: the cached path must not be served.
92 expect(yield* resolver.resolvePath(cwd)).toBeNull();
93 }).pipe(Effect.provide(TestClock.layer())),
94 );
95
96 it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () =>
97 Effect.gen(function* () {
98 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
99 const cwd = yield* makeTempDir;
100
101 expect(yield* resolver.resolvePath(cwd)).toBeNull();
102
103 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
104 expect(yield* resolver.resolvePath(cwd)).toBeNull();
105
106 yield* TestClock.adjust(Duration.minutes(2));
107
108 expect(yield* resolver.resolvePath(cwd)).not.toBeNull();
109 }).pipe(Effect.provide(TestClock.layer())),
110 );
111
52112 it.effect("prefers well-known favicon files", () =>
53113 Effect.gen(function* () {
54114 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
apps/server/src/project/ProjectFaviconResolver.ts+77-3
......@@ -6,8 +6,11 @@
66 *
77 * @module ProjectFaviconResolver
88 */
9import * as Cache from "effect/Cache";
910import * as Context from "effect/Context";
11import * as Duration from "effect/Duration";
1012import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
1114import * as FileSystem from "effect/FileSystem";
1215import * as Layer from "effect/Layer";
1316import * as Option from "effect/Option";
......@@ -18,6 +21,30 @@ import * as Schema from "effect/Schema";
1821import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1922import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
2023
24// Resolution walks up to 12 well-known paths plus 7 source files, so a miss
25// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon
26// asset URL, and a project's icon does not move, so the answer is cached.
27const FAVICON_CACHE_CAPACITY = 512;
28const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10);
29const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1);
30
31function faviconCacheKey(cwd: string, faviconPath?: string): string {
32 return `${faviconPath ?? ""}\0${cwd}`;
33}
34
35function parseFaviconCacheKey(key: string): {
36 readonly cwd: string;
37 readonly faviconPath?: string;
38} {
39 const separatorIndex = key.indexOf("\0");
40 if (separatorIndex === -1) {
41 return { cwd: key };
42 }
43 const faviconPath = key.slice(0, separatorIndex);
44 const cwd = key.slice(separatorIndex + 1);
45 return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath };
46}
47
2148// Well-known favicon paths checked in order.
2249const FAVICON_CANDIDATES = [
2350 "favicon.svg",
......@@ -178,9 +205,10 @@ export const make = Effect.gen(function* () {
178205 return null;
179206 });
180207
181 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
182 "ProjectFaviconResolver.resolvePath",
183 )(function* (cwd, faviconPath) {
208 const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* (
209 cwd: string,
210 faviconPath?: string,
211 ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> {
184212 const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe(
185213 Effect.mapError(
186214 (cause) =>
......@@ -267,6 +295,52 @@ export const make = Effect.gen(function* () {
267295 return null;
268296 });
269297
298 const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>(
299 (key) => {
300 const { cwd, faviconPath } = parseFaviconCacheKey(key);
301 return resolvePathUncached(cwd, faviconPath);
302 },
303 {
304 capacity: FAVICON_CACHE_CAPACITY,
305 timeToLive: Exit.match({
306 onSuccess: (value: string | null) =>
307 value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL,
308 onFailure: () => Duration.zero,
309 }),
310 },
311 );
312
313 const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
314 "ProjectFaviconResolver.resolvePath",
315 )(function* (cwd, faviconPath) {
316 const key = faviconCacheKey(cwd, faviconPath);
317 const cached = yield* Cache.get(faviconCache, key);
318 if (cached === null) {
319 return null;
320 }
321
322 // A hit still confirms the file with one stat rather than the ~20 probes a
323 // full walk costs, so a deleted icon falls back at once instead of after
324 // the TTL.
325 const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe(
326 Effect.mapError(
327 (cause) =>
328 new ProjectFaviconResolutionError({
329 operation: "stat-candidate",
330 workspaceRoot: cwd,
331 absolutePath: cached,
332 cause,
333 }),
334 ),
335 );
336 if (Option.isSome(stats) && stats.value.type === "File") {
337 return cached;
338 }
339
340 yield* Cache.invalidate(faviconCache, key);
341 return yield* Cache.get(faviconCache, key);
342 });
343
270344 return ProjectFaviconResolver.of({ resolvePath });
271345});
272346