| 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,10 +1,12 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it, describe, expect } from "@effect/vitest"; | 2 | import { it, describe, expect } from "@effect/vitest"; |
| 3 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 7 | import * as PlatformError from "effect/PlatformError"; | 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import { TestClock } from "effect/testing"; | ||
| 8 | 10 | ||
| 9 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 11 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 10 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | 12 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| ... | @@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => | ... | @@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => |
| 49 | 51 | ||
| 50 | it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { | 52 | it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { |
| 51 | describe("resolvePath", () => { | 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 | it.effect("prefers well-known favicon files", () => | 112 | it.effect("prefers well-known favicon files", () => |
| 53 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 54 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; | 114 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; |
apps/server/src/project/ProjectFaviconResolver.ts+77-3| ... | @@ -6,8 +6,11 @@ | ... | @@ -6,8 +6,11 @@ |
| 6 | * | 6 | * |
| 7 | * @module ProjectFaviconResolver | 7 | * @module ProjectFaviconResolver |
| 8 | */ | 8 | */ |
| 9 | import * as Cache from "effect/Cache"; | ||
| 9 | import * as Context from "effect/Context"; | 10 | import * as Context from "effect/Context"; |
| 11 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | 14 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| ... | @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; | ... | @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; |
| 18 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 21 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 19 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; | 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 | // Well-known favicon paths checked in order. | 48 | // Well-known favicon paths checked in order. |
| 22 | const FAVICON_CANDIDATES = [ | 49 | const FAVICON_CANDIDATES = [ |
| 23 | "favicon.svg", | 50 | "favicon.svg", |
| ... | @@ -178,9 +205,10 @@ export const make = Effect.gen(function* () { | ... | @@ -178,9 +205,10 @@ export const make = Effect.gen(function* () { |
| 178 | return null; | 205 | return null; |
| 179 | }); | 206 | }); |
| 180 | 207 | ||
| 181 | const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( | 208 | const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( |
| 182 | "ProjectFaviconResolver.resolvePath", | 209 | cwd: string, |
| 183 | )(function* (cwd, faviconPath) { | 210 | faviconPath?: string, |
| 211 | ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> { | ||
| 184 | const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( | 212 | const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( |
| 185 | Effect.mapError( | 213 | Effect.mapError( |
| 186 | (cause) => | 214 | (cause) => |
| ... | @@ -267,6 +295,52 @@ export const make = Effect.gen(function* () { | ... | @@ -267,6 +295,52 @@ export const make = Effect.gen(function* () { |
| 267 | return null; | 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 | return ProjectFaviconResolver.of({ resolvePath }); | 344 | return ProjectFaviconResolver.of({ resolvePath }); |
| 271 | }); | 345 | }); |
| 272 | 346 |