| author | |
| committer | |
| log | 643b21edaa08eb1d19d633dbb0b275164bfa60be |
| tree | 549e7ffda8dcefd7faebff76ea01ad13303b3ced |
| parent | 261380f91f764673c7fcefcb6a3f775526dbb776 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { it, describe, expect } from "@effect/vitest"; |
| 3 | import * as Duration from "effect/Duration"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Path from "effect/Path"; |
| 7 | 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import { TestClock } from "effect/testing"; | |
| 8 | 10 | |
| 9 | 11 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 10 | 12 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| ... | ... | @@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => |
| 49 | 51 | |
| 50 | 52 | it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { |
| 51 | 53 | 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 | ||
| 52 | 112 | it.effect("prefers well-known favicon files", () => |
| 53 | 113 | Effect.gen(function* () { |
| 54 | 114 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; |
apps/server/src/project/ProjectFaviconResolver.ts+77-3| ... | ... | @@ -6,8 +6,11 @@ |
| 6 | 6 | * |
| 7 | 7 | * @module ProjectFaviconResolver |
| 8 | 8 | */ |
| 9 | import * as Cache from "effect/Cache"; | |
| 9 | 10 | import * as Context from "effect/Context"; |
| 11 | import * as Duration from "effect/Duration"; | |
| 10 | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | |
| 11 | 14 | import * as FileSystem from "effect/FileSystem"; |
| 12 | 15 | import * as Layer from "effect/Layer"; |
| 13 | 16 | import * as Option from "effect/Option"; |
| ... | ... | @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; |
| 18 | 21 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 19 | 22 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; |
| 20 | 23 | |
| 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. | |
| 27 | const FAVICON_CACHE_CAPACITY = 512; | |
| 28 | const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); | |
| 29 | const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); | |
| 30 | ||
| 31 | function faviconCacheKey(cwd: string, faviconPath?: string): string { | |
| 32 | return `${faviconPath ?? ""}\0${cwd}`; | |
| 33 | } | |
| 34 | ||
| 35 | function 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 | ||
| 21 | 48 | // Well-known favicon paths checked in order. |
| 22 | 49 | const FAVICON_CANDIDATES = [ |
| 23 | 50 | "favicon.svg", |
| ... | ... | @@ -178,9 +205,10 @@ export const make = Effect.gen(function* () { |
| 178 | 205 | return null; |
| 179 | 206 | }); |
| 180 | 207 | |
| 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> { | |
| 184 | 212 | const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( |
| 185 | 213 | Effect.mapError( |
| 186 | 214 | (cause) => |
| ... | ... | @@ -267,6 +295,52 @@ export const make = Effect.gen(function* () { |
| 267 | 295 | return null; |
| 268 | 296 | }); |
| 269 | 297 | |
| 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 | ||
| 270 | 344 | return ProjectFaviconResolver.of({ resolvePath }); |
| 271 | 345 | }); |
| 272 | 346 |