WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web+appview): theme caching layer (ATB-56) (#96)

* feat(web+appview): theme caching layer (ATB-56)

Add in-memory TTL cache for resolved theme data on the web server to
avoid redundant AppView API calls on every page request.

- New ThemeCache class (theme-cache.ts): TTL entries for policy (single)
and themes (keyed by uri:colorScheme to keep light/dark isolated)
- resolveTheme now accepts an optional ThemeCache; checks cache before
each fetch, populates after successful CID validation; stale CID on
cache hit falls through to a fresh fetch rather than serving stale data
- createThemeMiddleware creates one ThemeCache at startup (shared across
all requests); accepts configurable cacheTtlMs (default 5 min)
- THEME_CACHE_TTL_MS env var exposed via WebConfig.themeCacheTtlMs
- AppView theme endpoints now set Cache-Control: public, max-age=300;
GET /api/themes/:rkey also sets ETag from the theme record CID

* fix(web+appview): address code review feedback on theme caching (ATB-56)

Critical: Cache-Control on GET /themes was set before DB queries, causing
CDNs to cache error responses for 5 minutes. Moved to immediately before
each success return, matching the existing pattern in GET /:rkey.

Important fixes:
- Add THEME_CACHE_TTL_MS to turbo.json env array (Turbo blocks env vars
not declared here, causing tests to receive NaN TTL via turbo)
- Guard parseInt result with Number.isNaN fallback in config (invalid
env value would produce an immortal cache with no operator feedback)
- Add ThemeCache.deleteTheme(): evict stale entry when CID mismatch is
detected so failed re-fetches don't loop per-request indefinitely
- CachedTheme.tokens: Record<string,string> (was unknown) — eliminates
downstream casts and prevents numeric token values entering the cache
- Remove unused re-export of cache types from theme-resolution.ts

Suggestions applied:
- JSDoc on CachedPolicy.availableThemes[].cid explaining live vs pinned refs
- getPolicy()/getTheme() now return Readonly<T> to prevent external mutation
- Comment on ThemeCache construction in middleware explaining why it must
be outside the request handler

Test additions:
- 503 from GET /themes must NOT include Cache-Control header (regression)
- stale CID + failed fresh fetch: eviction means next request retries cleanly
- cache repopulated after stale-CID recovery: third call makes no fetches
- deleteTheme() targeted eviction tests
- Fixed misleading comment in policy-cache-hit test

authored by

Malpercio and committed by
GitHub
40671287 62842536

+617 -55
+42
apps/appview/src/routes/__tests__/themes.test.ts
··· 139 139 140 140 const res = await app.request("/themes"); 141 141 expect(res.status).toBe(503); 142 + // Error responses must NOT be cached by CDNs/proxies 143 + expect(res.headers.get("Cache-Control")).toBeNull(); 144 + }); 145 + 146 + it("sets Cache-Control: public, max-age=300 on successful response", async () => { 147 + const res = await app.request("/themes"); 148 + expect(res.status).toBe(200); 149 + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); 142 150 }); 143 151 }); 144 152 ··· 219 227 const res = await app.request("/themes/any-rkey"); 220 228 expect(res.status).toBe(503); 221 229 }); 230 + 231 + it("sets Cache-Control: public, max-age=300 and ETag from CID on successful response", async () => { 232 + await ctx.db.insert(themes).values({ 233 + did: ctx.config.forumDid, 234 + rkey: "3lblcachetest", 235 + cid: "bafycachetest123", 236 + name: "Cache Test Theme", 237 + colorScheme: "light", 238 + tokens: { "color-bg": "#fff" }, 239 + createdAt: new Date(), 240 + indexedAt: new Date(), 241 + }); 242 + 243 + const res = await app.request("/themes/3lblcachetest"); 244 + expect(res.status).toBe(200); 245 + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); 246 + expect(res.headers.get("ETag")).toBe('"bafycachetest123"'); 247 + }); 222 248 }); 223 249 224 250 // ── GET /api/theme-policy ──────────────────────────────── ··· 289 315 290 316 const res = await app.request("/theme-policy"); 291 317 expect(res.status).toBe(503); 318 + }); 319 + 320 + it("sets Cache-Control: public, max-age=300 on successful response", async () => { 321 + await ctx.db.insert(themePolicies).values({ 322 + did: ctx.config.forumDid, 323 + rkey: "self", 324 + cid: "bafypolicycache", 325 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 326 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 327 + allowUserChoice: true, 328 + indexedAt: new Date(), 329 + }); 330 + 331 + const res = await app.request("/theme-policy"); 332 + expect(res.status).toBe(200); 333 + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); 292 334 }); 293 335 });
+6
apps/appview/src/routes/themes.ts
··· 48 48 .where(eq(themePolicies.did, ctx.config.forumDid)); 49 49 50 50 if (availableRows.length === 0) { 51 + c.header("Cache-Control", "public, max-age=300"); 51 52 return c.json({ themes: [] }); 52 53 } 53 54 ··· 57 58 .filter((rkey): rkey is string => !!rkey); 58 59 59 60 if (rkeys.length === 0) { 61 + c.header("Cache-Control", "public, max-age=300"); 60 62 return c.json({ themes: [] }); 61 63 } 62 64 ··· 72 74 ) 73 75 .limit(100); 74 76 77 + c.header("Cache-Control", "public, max-age=300"); 75 78 return c.json({ themes: themeList.map(serializeThemeSummary) }); 76 79 } catch (error) { 77 80 return handleRouteError(c, error, "Failed to retrieve themes", { ··· 102 105 return c.json({ error: "Theme not found" }, 404); 103 106 } 104 107 108 + c.header("Cache-Control", "public, max-age=300"); 109 + c.header("ETag", `"${theme.cid}"`); 105 110 return c.json(serializeThemeFull(theme)); 106 111 } catch (error) { 107 112 return handleRouteError(c, error, "Failed to retrieve theme", { ··· 134 139 .from(themePolicyAvailableThemes) 135 140 .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 136 141 142 + c.header("Cache-Control", "public, max-age=300"); 137 143 return c.json({ 138 144 defaultLightThemeUri: policy.defaultLightThemeUri, 139 145 defaultDarkThemeUri: policy.defaultDarkThemeUri,
+155
apps/web/src/lib/__tests__/theme-cache.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 + import { ThemeCache, type CachedPolicy, type CachedTheme } from "../theme-cache.js"; 3 + 4 + const TTL_MS = 5 * 60 * 1000; // 5 minutes 5 + 6 + const MOCK_POLICY: CachedPolicy = { 7 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 8 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 9 + allowUserChoice: true, 10 + availableThemes: [ 11 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, 12 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 13 + ], 14 + }; 15 + 16 + const MOCK_THEME: CachedTheme = { 17 + cid: "bafylight", 18 + tokens: { "color-bg": "#fff" }, 19 + cssOverrides: null, 20 + fontUrls: null, 21 + }; 22 + 23 + describe("ThemeCache — policy", () => { 24 + let cache: ThemeCache; 25 + 26 + beforeEach(() => { 27 + vi.useFakeTimers(); 28 + cache = new ThemeCache(TTL_MS); 29 + }); 30 + 31 + afterEach(() => { 32 + vi.useRealTimers(); 33 + }); 34 + 35 + it("returns null when policy has not been set", () => { 36 + expect(cache.getPolicy()).toBeNull(); 37 + }); 38 + 39 + it("returns policy immediately after setting", () => { 40 + cache.setPolicy(MOCK_POLICY); 41 + expect(cache.getPolicy()).toEqual(MOCK_POLICY); 42 + }); 43 + 44 + it("returns null after TTL expires", () => { 45 + cache.setPolicy(MOCK_POLICY); 46 + vi.advanceTimersByTime(TTL_MS + 1); 47 + expect(cache.getPolicy()).toBeNull(); 48 + }); 49 + 50 + it("returns policy just before TTL expires", () => { 51 + cache.setPolicy(MOCK_POLICY); 52 + vi.advanceTimersByTime(TTL_MS - 1); 53 + expect(cache.getPolicy()).toEqual(MOCK_POLICY); 54 + }); 55 + 56 + it("can be refreshed before expiry (re-set resets TTL)", () => { 57 + cache.setPolicy(MOCK_POLICY); 58 + vi.advanceTimersByTime(TTL_MS - 1); 59 + cache.setPolicy({ ...MOCK_POLICY, allowUserChoice: false }); 60 + vi.advanceTimersByTime(TTL_MS - 1); 61 + // Total elapsed: (TTL-1) + (TTL-1) = 2*TTL - 2ms, but the re-set happened at TTL-1 62 + // so the new entry expires at (TTL-1) + TTL = 2*TTL - 1ms from start 63 + const result = cache.getPolicy(); 64 + expect(result).not.toBeNull(); 65 + expect(result!.allowUserChoice).toBe(false); 66 + }); 67 + }); 68 + 69 + describe("ThemeCache — themes", () => { 70 + let cache: ThemeCache; 71 + 72 + beforeEach(() => { 73 + vi.useFakeTimers(); 74 + cache = new ThemeCache(TTL_MS); 75 + }); 76 + 77 + afterEach(() => { 78 + vi.useRealTimers(); 79 + }); 80 + 81 + it("returns null on cache miss", () => { 82 + expect( 83 + cache.getTheme("at://did:plc:forum/space.atbb.forum.theme/3lbllight", "light") 84 + ).toBeNull(); 85 + }); 86 + 87 + it("returns theme immediately after setting", () => { 88 + const uri = "at://did:plc:forum/space.atbb.forum.theme/3lbllight"; 89 + cache.setTheme(uri, "light", MOCK_THEME); 90 + expect(cache.getTheme(uri, "light")).toEqual(MOCK_THEME); 91 + }); 92 + 93 + it("returns null after TTL expires", () => { 94 + const uri = "at://did:plc:forum/space.atbb.forum.theme/3lbllight"; 95 + cache.setTheme(uri, "light", MOCK_THEME); 96 + vi.advanceTimersByTime(TTL_MS + 1); 97 + expect(cache.getTheme(uri, "light")).toBeNull(); 98 + }); 99 + 100 + it("treats light and dark as separate cache entries", () => { 101 + const uri = "at://did:plc:forum/space.atbb.forum.theme/shared"; 102 + const lightTheme: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null }; 103 + const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null }; 104 + 105 + cache.setTheme(uri, "light", lightTheme); 106 + cache.setTheme(uri, "dark", darkTheme); 107 + 108 + expect(cache.getTheme(uri, "light")).toEqual(lightTheme); 109 + expect(cache.getTheme(uri, "dark")).toEqual(darkTheme); 110 + }); 111 + 112 + it("different URIs are stored independently", () => { 113 + const lightUri = "at://did/col/light"; 114 + const darkUri = "at://did/col/dark"; 115 + const lightTheme: CachedTheme = { cid: "bafylight", tokens: { "color-bg": "#fff" }, cssOverrides: null, fontUrls: null }; 116 + const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null }; 117 + 118 + cache.setTheme(lightUri, "light", lightTheme); 119 + cache.setTheme(darkUri, "dark", darkTheme); 120 + 121 + expect(cache.getTheme(lightUri, "light")).toEqual(lightTheme); 122 + expect(cache.getTheme(darkUri, "dark")).toEqual(darkTheme); 123 + expect(cache.getTheme(lightUri, "dark")).toBeNull(); 124 + expect(cache.getTheme(darkUri, "light")).toBeNull(); 125 + }); 126 + 127 + it("deleteTheme removes a specific entry before TTL", () => { 128 + const uri = "at://did/col/theme"; 129 + cache.setTheme(uri, "light", MOCK_THEME); 130 + cache.deleteTheme(uri, "light"); 131 + expect(cache.getTheme(uri, "light")).toBeNull(); 132 + }); 133 + 134 + it("deleteTheme only removes the targeted uri:colorScheme entry", () => { 135 + const uri = "at://did/col/theme"; 136 + const darkTheme: CachedTheme = { cid: "bafydark", tokens: { "color-bg": "#111" }, cssOverrides: null, fontUrls: null }; 137 + cache.setTheme(uri, "light", MOCK_THEME); 138 + cache.setTheme(uri, "dark", darkTheme); 139 + cache.deleteTheme(uri, "light"); 140 + expect(cache.getTheme(uri, "light")).toBeNull(); 141 + expect(cache.getTheme(uri, "dark")).toEqual(darkTheme); 142 + }); 143 + 144 + it("evicts stale entry from the map on expired access", () => { 145 + const uri = "at://did/col/theme"; 146 + cache.setTheme(uri, "light", MOCK_THEME); 147 + vi.advanceTimersByTime(TTL_MS + 1); 148 + // Access after expiry 149 + expect(cache.getTheme(uri, "light")).toBeNull(); 150 + // After eviction, setting a new entry works correctly 151 + const newTheme = { ...MOCK_THEME, cid: "bafynew" }; 152 + cache.setTheme(uri, "light", newTheme); 153 + expect(cache.getTheme(uri, "light")).toEqual(newTheme); 154 + }); 155 + });
+204
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 5 5 FALLBACK_THEME, 6 6 resolveTheme, 7 7 } from "../theme-resolution.js"; 8 + import { ThemeCache } from "../theme-cache.js"; 8 9 import { logger } from "../logger.js"; 9 10 10 11 vi.mock("../logger.js", () => ({ ··· 319 320 expect(mockFetch).toHaveBeenCalledTimes(1); 320 321 }); 321 322 323 + it("no cache provided — behaves identically to pre-cache implementation", async () => { 324 + mockFetch 325 + .mockResolvedValueOnce(policyResponse()) 326 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 327 + const result = await resolveTheme(APPVIEW, undefined, undefined, undefined); 328 + expect(result.tokens["color-bg"]).toBe("#fff"); 329 + expect(mockFetch).toHaveBeenCalledTimes(2); 330 + }); 331 + 322 332 it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => { 323 333 // Live refs have no CID — canonical atbb.space presets ship this way. 324 334 // The CID integrity check must be skipped when expectedCid is null. ··· 344 354 ); 345 355 }); 346 356 }); 357 + 358 + describe("resolveTheme — cache integration", () => { 359 + const mockFetch = vi.fn(); 360 + const APPVIEW = "http://localhost:3001"; 361 + const TTL_MS = 60_000; 362 + 363 + beforeEach(() => { 364 + vi.stubGlobal("fetch", mockFetch); 365 + }); 366 + 367 + afterEach(() => { 368 + mockFetch.mockReset(); 369 + vi.unstubAllGlobals(); 370 + }); 371 + 372 + function policyResponse() { 373 + return { 374 + ok: true, 375 + json: () => 376 + Promise.resolve({ 377 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 378 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 379 + allowUserChoice: true, 380 + availableThemes: [ 381 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, 382 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 383 + ], 384 + }), 385 + }; 386 + } 387 + 388 + function themeResponse(colorScheme: "light" | "dark", cid: string) { 389 + return { 390 + ok: true, 391 + json: () => 392 + Promise.resolve({ 393 + cid, 394 + tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, 395 + cssOverrides: null, 396 + fontUrls: null, 397 + }), 398 + }; 399 + } 400 + 401 + it("policy cache hit skips policy fetch on second call", async () => { 402 + const cache = new ThemeCache(TTL_MS); 403 + mockFetch 404 + .mockResolvedValueOnce(policyResponse()) 405 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 406 + 407 + await resolveTheme(APPVIEW, undefined, undefined, cache); 408 + await resolveTheme(APPVIEW, undefined, undefined, cache); 409 + 410 + // Both policy and theme are cached after the first call — second call makes no fetches 411 + expect(mockFetch).toHaveBeenCalledTimes(2); // policy (1) + theme (1), both from first call 412 + }); 413 + 414 + it("theme cache hit skips theme fetch on second call", async () => { 415 + const cache = new ThemeCache(TTL_MS); 416 + mockFetch 417 + .mockResolvedValueOnce(policyResponse()) 418 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 419 + 420 + await resolveTheme(APPVIEW, undefined, undefined, cache); 421 + // Second call: policy is cached, theme is cached — zero fetches 422 + mockFetch.mockClear(); 423 + await resolveTheme(APPVIEW, undefined, undefined, cache); 424 + 425 + expect(mockFetch).not.toHaveBeenCalled(); 426 + }); 427 + 428 + it("cache returns correct tokens on second call without fetch", async () => { 429 + const cache = new ThemeCache(TTL_MS); 430 + mockFetch 431 + .mockResolvedValueOnce(policyResponse()) 432 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 433 + 434 + const first = await resolveTheme(APPVIEW, undefined, undefined, cache); 435 + const second = await resolveTheme(APPVIEW, undefined, undefined, cache); 436 + 437 + expect(second.tokens["color-bg"]).toBe("#fff"); 438 + expect(second.tokens).toEqual(first.tokens); 439 + }); 440 + 441 + it("light and dark are cached independently — color scheme determines which is served", async () => { 442 + const cache = new ThemeCache(TTL_MS); 443 + mockFetch 444 + .mockResolvedValueOnce(policyResponse()) 445 + .mockResolvedValueOnce(themeResponse("light", "bafylight")) 446 + // Dark request: policy is cached, but dark theme is not yet 447 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 448 + 449 + const light = await resolveTheme(APPVIEW, undefined, undefined, cache); 450 + const dark = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined, cache); 451 + 452 + expect(light.colorScheme).toBe("light"); 453 + expect(light.tokens["color-bg"]).toBe("#fff"); 454 + expect(dark.colorScheme).toBe("dark"); 455 + expect(dark.tokens["color-bg"]).toBe("#111"); 456 + // policy (1) + light theme (1) + dark theme (1) = 3 fetches 457 + expect(mockFetch).toHaveBeenCalledTimes(3); 458 + }); 459 + 460 + it("stale cache CID triggers eviction, fresh fetch, and logs warning", async () => { 461 + const cache = new ThemeCache(TTL_MS); 462 + mockFetch 463 + .mockResolvedValueOnce(policyResponse()) 464 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 465 + await resolveTheme(APPVIEW, undefined, undefined, cache); 466 + 467 + // Update cached policy to reflect a new CID (simulates admin updating the theme) 468 + cache.setPolicy({ 469 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 470 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 471 + allowUserChoice: true, 472 + availableThemes: [ 473 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, 474 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 475 + ], 476 + }); 477 + 478 + mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); 479 + 480 + const mockLogger = vi.mocked(logger); 481 + const result = await resolveTheme(APPVIEW, undefined, undefined, cache); 482 + 483 + expect(mockLogger.warn).toHaveBeenCalledWith( 484 + expect.stringContaining("stale CID"), 485 + expect.objectContaining({ expectedCid: "bafynew", cachedCid: "bafylight" }) 486 + ); 487 + expect(result.tokens["color-bg"]).toBe("#fff"); 488 + expect(mockFetch).toHaveBeenCalledTimes(3); // initial policy+theme + 1 fresh theme 489 + }); 490 + 491 + it("stale CID + failed fresh fetch falls back and evicts so next request retries", async () => { 492 + const cache = new ThemeCache(TTL_MS); 493 + mockFetch 494 + .mockResolvedValueOnce(policyResponse()) 495 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 496 + await resolveTheme(APPVIEW, undefined, undefined, cache); 497 + 498 + // Update policy to reflect a new CID 499 + cache.setPolicy({ 500 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 501 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 502 + allowUserChoice: true, 503 + availableThemes: [ 504 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, 505 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 506 + ], 507 + }); 508 + 509 + // Fresh fetch fails (AppView outage) 510 + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); 511 + const fallbackResult = await resolveTheme(APPVIEW, undefined, undefined, cache); 512 + 513 + // Falls back to FALLBACK_THEME — stale data is not served 514 + expect(fallbackResult.tokens).toEqual(FALLBACK_THEME.tokens); 515 + 516 + // On the NEXT request: stale entry was evicted, so a fresh fetch is attempted again 517 + // (rather than re-detecting stale CID and looping forever) 518 + mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); 519 + const recoveredResult = await resolveTheme(APPVIEW, undefined, undefined, cache); 520 + 521 + expect(recoveredResult.tokens["color-bg"]).toBe("#fff"); 522 + expect(mockFetch).toHaveBeenCalledTimes(4); // initial 2 + failed fetch + recovered fetch 523 + }); 524 + 525 + it("cache repopulated after stale-CID fresh fetch — third call makes no fetches", async () => { 526 + const cache = new ThemeCache(TTL_MS); 527 + mockFetch 528 + .mockResolvedValueOnce(policyResponse()) 529 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 530 + await resolveTheme(APPVIEW, undefined, undefined, cache); 531 + 532 + cache.setPolicy({ 533 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 534 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 535 + allowUserChoice: true, 536 + availableThemes: [ 537 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, 538 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 539 + ], 540 + }); 541 + 542 + mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); 543 + await resolveTheme(APPVIEW, undefined, undefined, cache); // triggers fresh fetch, repopulates cache 544 + 545 + mockFetch.mockClear(); 546 + await resolveTheme(APPVIEW, undefined, undefined, cache); // should be a full cache hit 547 + 548 + expect(mockFetch).not.toHaveBeenCalled(); 549 + }); 550 + });
+6
apps/web/src/lib/config.ts
··· 4 4 port: number; 5 5 appviewUrl: string; 6 6 logLevel: LogLevel; 7 + /** In-memory theme cache TTL in milliseconds. Defaults to 5 minutes. */ 8 + themeCacheTtlMs: number; 7 9 } 8 10 9 11 export function loadConfig(): WebConfig { ··· 11 13 port: parseInt(process.env.WEB_PORT ?? "3001", 10), 12 14 appviewUrl: process.env.APPVIEW_URL ?? "http://localhost:3000", 13 15 logLevel: (process.env.LOG_LEVEL as LogLevel) ?? "info", 16 + themeCacheTtlMs: (() => { 17 + const parsed = parseInt(process.env.THEME_CACHE_TTL_MS ?? "300000", 10); 18 + return Number.isNaN(parsed) ? 300_000 : parsed; 19 + })(), 14 20 }; 15 21 }
+77
apps/web/src/lib/theme-cache.ts
··· 1 + export interface CachedPolicy { 2 + defaultLightThemeUri: string | null; 3 + defaultDarkThemeUri: string | null; 4 + allowUserChoice: boolean; 5 + availableThemes: Array<{ 6 + uri: string; 7 + /** 8 + * Present for pinned refs (locked to an exact version). 9 + * Absent for live refs (e.g. canonical atbb.space presets) — those 10 + * resolve to the current record at the URI without CID verification. 11 + */ 12 + cid?: string; 13 + }>; 14 + } 15 + 16 + export interface CachedTheme { 17 + cid: string; 18 + /** All token values are CSS strings at the cache boundary. */ 19 + tokens: Record<string, string>; 20 + cssOverrides: string | null; 21 + fontUrls: string[] | null; 22 + } 23 + 24 + interface CacheEntry<T> { 25 + data: T; 26 + expiresAt: number; 27 + } 28 + 29 + /** 30 + * In-memory TTL cache for resolved theme data on the web server. 31 + * 32 + * Themes change rarely. A single instance is created per server startup 33 + * (inside createThemeMiddleware) and shared across all requests. 34 + * 35 + * Policy: single entry, keyed to the forum. 36 + * Themes: map keyed by `${at-uri}:${colorScheme}` — colorScheme in the key 37 + * ensures light and dark cached entries are never confused, even if the same 38 + * AT-URI is used for both defaults. 39 + */ 40 + export class ThemeCache { 41 + private policy: CacheEntry<CachedPolicy> | null = null; 42 + private themes = new Map<string, CacheEntry<CachedTheme>>(); 43 + 44 + constructor(readonly ttlMs: number) {} 45 + 46 + getPolicy(): Readonly<CachedPolicy> | null { 47 + if (!this.policy || Date.now() > this.policy.expiresAt) { 48 + this.policy = null; 49 + return null; 50 + } 51 + return this.policy.data; 52 + } 53 + 54 + setPolicy(data: CachedPolicy): void { 55 + this.policy = { data, expiresAt: Date.now() + this.ttlMs }; 56 + } 57 + 58 + getTheme(uri: string, colorScheme: "light" | "dark"): Readonly<CachedTheme> | null { 59 + const key = `${uri}:${colorScheme}`; 60 + const entry = this.themes.get(key); 61 + if (!entry || Date.now() > entry.expiresAt) { 62 + this.themes.delete(key); 63 + return null; 64 + } 65 + return entry.data; 66 + } 67 + 68 + setTheme(uri: string, colorScheme: "light" | "dark", data: CachedTheme): void { 69 + const key = `${uri}:${colorScheme}`; 70 + this.themes.set(key, { data, expiresAt: Date.now() + this.ttlMs }); 71 + } 72 + 73 + /** Evict a stale theme entry so the next request fetches fresh data. */ 74 + deleteTheme(uri: string, colorScheme: "light" | "dark"): void { 75 + this.themes.delete(`${uri}:${colorScheme}`); 76 + } 77 + }
+74 -47
apps/web/src/lib/theme-resolution.ts
··· 1 1 import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2 2 import { isProgrammingError } from "./errors.js"; 3 3 import { logger } from "./logger.js"; 4 + import { ThemeCache, type CachedPolicy, type CachedTheme } from "./theme-cache.js"; 4 5 5 6 export type ResolvedTheme = { 6 7 tokens: Record<string, string>; ··· 50 51 return parts[4] ?? null; 51 52 } 52 53 53 - interface ThemePolicyResponse { 54 - defaultLightThemeUri: string | null; 55 - defaultDarkThemeUri: string | null; 56 - allowUserChoice: boolean; 57 - availableThemes: Array<{ uri: string; cid?: string }>; 58 - } 59 - 60 - interface ThemeResponse { 61 - cid: string; 62 - tokens: Record<string, unknown>; 63 - cssOverrides: string | null; 64 - fontUrls: string[] | null; 65 - } 66 54 67 55 /** 68 56 * Resolves which theme to render for a request using the waterfall: 69 57 * 1. User preference — not yet implemented (TODO: Theme Phase 4) 70 58 * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 71 - * 3. Forum default — fetched from GET /api/theme-policy 59 + * 3. Forum default — fetched from GET /api/theme-policy (cached in memory) 72 60 * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 73 61 * 62 + * Pass a ThemeCache instance to enable in-memory TTL caching of policy and 63 + * theme data. The cache is checked before each network request and populated 64 + * after a successful fetch + CID validation. 65 + * 74 66 * Never throws — always returns a usable theme. 75 67 */ 76 68 export async function resolveTheme( 77 69 appviewUrl: string, 78 70 cookieHeader: string | undefined, 79 - colorSchemeHint: string | undefined 71 + colorSchemeHint: string | undefined, 72 + cache?: ThemeCache 80 73 ): Promise<ResolvedTheme> { 81 74 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 82 75 // TODO: user preference (Theme Phase 4) 83 76 84 - // ── Step 1: Fetch theme policy ───────────────────────────────────────────── 85 - let policyRes: Response; 86 - try { 87 - policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 88 - if (!policyRes.ok) { 89 - logger.warn("Theme policy fetch returned non-ok status — using fallback", { 77 + // ── Step 1: Get theme policy (from cache or AppView) ─────────────────────── 78 + let policy: CachedPolicy | null = cache?.getPolicy() ?? null; 79 + 80 + if (!policy) { 81 + let policyRes: Response; 82 + try { 83 + policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 84 + if (!policyRes.ok) { 85 + logger.warn("Theme policy fetch returned non-ok status — using fallback", { 86 + operation: "resolveTheme", 87 + status: policyRes.status, 88 + url: `${appviewUrl}/api/theme-policy`, 89 + }); 90 + return { ...FALLBACK_THEME, colorScheme }; 91 + } 92 + } catch (error) { 93 + if (isProgrammingError(error)) throw error; 94 + logger.error("Theme policy fetch failed — using fallback", { 90 95 operation: "resolveTheme", 91 - status: policyRes.status, 96 + error: error instanceof Error ? error.message : String(error), 97 + }); 98 + return { ...FALLBACK_THEME, colorScheme }; 99 + } 100 + 101 + try { 102 + // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw 103 + policy = (await policyRes.json()) as CachedPolicy; 104 + cache?.setPolicy(policy); 105 + } catch { 106 + logger.error("Theme policy response contained invalid JSON — using fallback", { 107 + operation: "resolveTheme", 92 108 url: `${appviewUrl}/api/theme-policy`, 93 109 }); 94 110 return { ...FALLBACK_THEME, colorScheme }; 95 111 } 96 - } catch (error) { 97 - if (isProgrammingError(error)) throw error; 98 - logger.error("Theme policy fetch failed — using fallback", { 99 - operation: "resolveTheme", 100 - error: error instanceof Error ? error.message : String(error), 101 - }); 102 - return { ...FALLBACK_THEME, colorScheme }; 103 112 } 104 113 105 - // ── Step 2: Parse policy JSON ────────────────────────────────────────────── 106 - let policy: ThemePolicyResponse; 107 - try { 108 - policy = (await policyRes.json()) as ThemePolicyResponse; 109 - } catch { 110 - // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw 111 - logger.error("Theme policy response contained invalid JSON — using fallback", { 112 - operation: "resolveTheme", 113 - url: `${appviewUrl}/api/theme-policy`, 114 - }); 115 - return { ...FALLBACK_THEME, colorScheme }; 116 - } 117 - 118 - // ── Step 3: Extract default theme URI and rkey ───────────────────────────── 114 + // ── Step 2: Extract default theme URI and rkey ───────────────────────────── 119 115 const defaultUri = 120 116 colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 121 117 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; ··· 133 129 // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 134 130 const expectedCid = matchingTheme?.cid ?? null; 135 131 136 - // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 132 + // ── Step 3: Get theme (from cache or AppView) ────────────────────────────── 133 + const cachedTheme: CachedTheme | null = cache?.getTheme(defaultUri, colorScheme) ?? null; 134 + 135 + if (cachedTheme !== null) { 136 + // CID check on the cached entry — guards against stale cache when the policy 137 + // refreshed (TTL expired) with a new CID while the theme entry is still live. 138 + if (expectedCid && cachedTheme.cid !== expectedCid) { 139 + logger.warn("Cached theme has stale CID — evicting and fetching fresh from AppView", { 140 + operation: "resolveTheme", 141 + expectedCid, 142 + cachedCid: cachedTheme.cid, 143 + themeUri: defaultUri, 144 + }); 145 + // Evict the stale entry so that if the fresh fetch also fails, 146 + // the next request doesn't re-enter this loop indefinitely. 147 + cache?.deleteTheme(defaultUri, colorScheme); 148 + // Fall through to fresh fetch below 149 + } else { 150 + // Cache hit — CID matches (or live ref with no expected CID) 151 + return { 152 + tokens: cachedTheme.tokens, 153 + cssOverrides: cachedTheme.cssOverrides ?? null, 154 + fontUrls: cachedTheme.fontUrls ?? null, 155 + colorScheme, 156 + }; 157 + } 158 + } 159 + 160 + // ── Step 4: Fetch theme from AppView ────────────────────────────────────── 137 161 let themeRes: Response; 138 162 try { 139 163 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); ··· 157 181 } 158 182 159 183 // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 160 - let theme: ThemeResponse; 184 + let theme: CachedTheme; 161 185 try { 162 - theme = (await themeRes.json()) as ThemeResponse; 186 + theme = (await themeRes.json()) as CachedTheme; 163 187 } catch { 164 188 logger.error("Theme response contained invalid JSON — using fallback", { 165 189 operation: "resolveTheme", ··· 180 204 return { ...FALLBACK_THEME, colorScheme }; 181 205 } 182 206 207 + // Populate cache only after successful validation 208 + cache?.setTheme(defaultUri, colorScheme, theme); 209 + 183 210 return { 184 - tokens: theme.tokens as Record<string, string>, 211 + tokens: theme.tokens, 185 212 cssOverrides: theme.cssOverrides ?? null, 186 213 fontUrls: theme.fontUrls ?? null, 187 214 colorScheme,
+39 -4
apps/web/src/middleware/__tests__/theme.test.ts
··· 64 64 expect(mockResolveTheme).toHaveBeenCalledWith( 65 65 expect.any(String), 66 66 "atbb-color-scheme=dark; session=abc", 67 - undefined 67 + undefined, 68 + expect.objectContaining({ ttlMs: expect.any(Number) }) 68 69 ); 69 70 }); 70 71 ··· 80 81 expect(mockResolveTheme).toHaveBeenCalledWith( 81 82 expect.any(String), 82 83 undefined, 83 - "dark" 84 + "dark", 85 + expect.objectContaining({ ttlMs: expect.any(Number) }) 84 86 ); 85 87 }); 86 88 ··· 94 96 expect(mockResolveTheme).toHaveBeenCalledWith( 95 97 "http://custom-appview.example.com", 96 98 undefined, 97 - undefined 99 + undefined, 100 + expect.objectContaining({ ttlMs: expect.any(Number) }) 98 101 ); 99 102 }); 100 103 ··· 108 111 expect(mockResolveTheme).toHaveBeenCalledWith( 109 112 "http://appview.test", 110 113 undefined, 111 - undefined 114 + undefined, 115 + expect.objectContaining({ ttlMs: expect.any(Number) }) 112 116 ); 113 117 }); 114 118 ··· 121 125 expect(res.status).toBe(200); 122 126 const body = await res.json() as { message: string }; 123 127 expect(body.message).toBe("handler ran"); 128 + }); 129 + 130 + it("passes a ThemeCache instance to resolveTheme (4th argument is not undefined)", async () => { 131 + const app = new Hono<WebAppEnv>() 132 + .use("*", createThemeMiddleware("http://appview.test", 60_000)) 133 + .get("/test", (c) => c.json({ ok: true })); 134 + 135 + await app.request("http://localhost/test"); 136 + 137 + // The 4th argument (cache) should be a non-null object — not undefined 138 + expect(mockResolveTheme).toHaveBeenCalledWith( 139 + expect.any(String), 140 + undefined, 141 + undefined, 142 + expect.objectContaining({ ttlMs: 60_000 }) 143 + ); 144 + }); 145 + 146 + it("uses default 5-minute TTL when cacheTtlMs is not provided", async () => { 147 + const app = new Hono<WebAppEnv>() 148 + .use("*", createThemeMiddleware("http://appview.test")) 149 + .get("/test", (c) => c.json({ ok: true })); 150 + 151 + await app.request("http://localhost/test"); 152 + 153 + expect(mockResolveTheme).toHaveBeenCalledWith( 154 + expect.any(String), 155 + undefined, 156 + undefined, 157 + expect.objectContaining({ ttlMs: 5 * 60 * 1000 }) 158 + ); 124 159 }); 125 160 126 161 it("catches unexpected throws from resolveTheme, logs the error, and sets FALLBACK_THEME", async () => {
+12 -2
apps/web/src/middleware/theme.ts
··· 1 1 import type { MiddlewareHandler } from "hono"; 2 2 import type { WebAppEnv } from "../lib/theme-resolution.js"; 3 3 import { resolveTheme, FALLBACK_THEME } from "../lib/theme-resolution.js"; 4 + import { ThemeCache } from "../lib/theme-cache.js"; 4 5 import { logger } from "../lib/logger.js"; 5 6 6 - export function createThemeMiddleware(appviewUrl: string): MiddlewareHandler<WebAppEnv> { 7 + const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes 8 + 9 + export function createThemeMiddleware( 10 + appviewUrl: string, 11 + cacheTtlMs: number = DEFAULT_CACHE_TTL_MS 12 + ): MiddlewareHandler<WebAppEnv> { 13 + // Intentionally outside the request handler: one shared instance per server 14 + // startup. Moving this inside the handler would create a new cache per 15 + // request, silently defeating the feature. 16 + const cache = new ThemeCache(cacheTtlMs); 7 17 return async (c, next) => { 8 18 const cookieHeader = c.req.header("Cookie"); 9 19 const colorSchemeHint = c.req.header("Sec-CH-Prefers-Color-Scheme"); 10 20 let theme; 11 21 try { 12 - theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint); 22 + theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint, cache); 13 23 } catch (error) { 14 24 logger.error("createThemeMiddleware: resolveTheme threw unexpectedly — using fallback", { 15 25 operation: "createThemeMiddleware",
+1 -1
apps/web/src/routes/index.ts
··· 15 15 const config = loadConfig(); 16 16 17 17 export const webRoutes = new Hono<WebAppEnv>() 18 - .use("*", createThemeMiddleware(config.appviewUrl)) 18 + .use("*", createThemeMiddleware(config.appviewUrl, config.themeCacheTtlMs)) 19 19 .route("/", createHomeRoutes(config.appviewUrl)) 20 20 .route("/", createBoardsRoutes(config.appviewUrl)) 21 21 .route("/", createTopicsRoutes(config.appviewUrl))
+1 -1
turbo.json
··· 19 19 }, 20 20 "test": { 21 21 "dependsOn": ["^build"], 22 - "env": ["DATABASE_URL", "LOG_LEVEL", "BACKFILL_RATE_LIMIT", "BACKFILL_CONCURRENCY", "BACKFILL_CURSOR_MAX_AGE_HOURS"] 22 + "env": ["DATABASE_URL", "LOG_LEVEL", "BACKFILL_RATE_LIMIT", "BACKFILL_CONCURRENCY", "BACKFILL_CURSOR_MAX_AGE_HOURS", "THEME_CACHE_TTL_MS"] 23 23 }, 24 24 "clean": { 25 25 "cache": false