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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+521 -53
+40
apps/appview/src/routes/__tests__/themes.test.ts
··· 140 140 const res = await app.request("/themes"); 141 141 expect(res.status).toBe(503); 142 142 }); 143 + 144 + it("sets Cache-Control: public, max-age=300 on successful response", async () => { 145 + const res = await app.request("/themes"); 146 + expect(res.status).toBe(200); 147 + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); 148 + }); 143 149 }); 144 150 145 151 // ── GET /api/themes/:rkey ──────────────────────────────── ··· 219 225 const res = await app.request("/themes/any-rkey"); 220 226 expect(res.status).toBe(503); 221 227 }); 228 + 229 + it("sets Cache-Control: public, max-age=300 and ETag from CID on successful response", async () => { 230 + await ctx.db.insert(themes).values({ 231 + did: ctx.config.forumDid, 232 + rkey: "3lblcachetest", 233 + cid: "bafycachetest123", 234 + name: "Cache Test Theme", 235 + colorScheme: "light", 236 + tokens: { "color-bg": "#fff" }, 237 + createdAt: new Date(), 238 + indexedAt: new Date(), 239 + }); 240 + 241 + const res = await app.request("/themes/3lblcachetest"); 242 + expect(res.status).toBe(200); 243 + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); 244 + expect(res.headers.get("ETag")).toBe('"bafycachetest123"'); 245 + }); 222 246 }); 223 247 224 248 // ── GET /api/theme-policy ──────────────────────────────── ··· 289 313 290 314 const res = await app.request("/theme-policy"); 291 315 expect(res.status).toBe(503); 316 + }); 317 + 318 + it("sets Cache-Control: public, max-age=300 on successful response", async () => { 319 + await ctx.db.insert(themePolicies).values({ 320 + did: ctx.config.forumDid, 321 + rkey: "self", 322 + cid: "bafypolicycache", 323 + defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbllight`, 324 + defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbldark`, 325 + allowUserChoice: true, 326 + indexedAt: new Date(), 327 + }); 328 + 329 + const res = await app.request("/theme-policy"); 330 + expect(res.status).toBe(200); 331 + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); 292 332 }); 293 333 });
+4
apps/appview/src/routes/themes.ts
··· 37 37 return new Hono() 38 38 .get("/", async (c) => { 39 39 try { 40 + c.header("Cache-Control", "public, max-age=300"); 40 41 // Step 1: Get available theme URIs from this forum's policy 41 42 const availableRows = await ctx.db 42 43 .select({ themeUri: themePolicyAvailableThemes.themeUri }) ··· 102 103 return c.json({ error: "Theme not found" }, 404); 103 104 } 104 105 106 + c.header("Cache-Control", "public, max-age=300"); 107 + c.header("ETag", `"${theme.cid}"`); 105 108 return c.json(serializeThemeFull(theme)); 106 109 } catch (error) { 107 110 return handleRouteError(c, error, "Failed to retrieve theme", { ··· 134 137 .from(themePolicyAvailableThemes) 135 138 .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 136 139 140 + c.header("Cache-Control", "public, max-age=300"); 137 141 return c.json({ 138 142 defaultLightThemeUri: policy.defaultLightThemeUri, 139 143 defaultDarkThemeUri: policy.defaultDarkThemeUri,
+138
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("evicts stale entry from the map on expired access", () => { 128 + const uri = "at://did/col/theme"; 129 + cache.setTheme(uri, "light", MOCK_THEME); 130 + vi.advanceTimersByTime(TTL_MS + 1); 131 + // Access after expiry 132 + expect(cache.getTheme(uri, "light")).toBeNull(); 133 + // After eviction, setting a new entry works correctly 134 + const newTheme = { ...MOCK_THEME, cid: "bafynew" }; 135 + cache.setTheme(uri, "light", newTheme); 136 + expect(cache.getTheme(uri, "light")).toEqual(newTheme); 137 + }); 138 + });
+152
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 + // second call — only theme fetch needed (policy is cached) 407 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 408 + 409 + await resolveTheme(APPVIEW, undefined, undefined, cache); 410 + await resolveTheme(APPVIEW, undefined, undefined, cache); 411 + 412 + // 3 fetches: 1 policy + 2 theme (theme cache not populated for second call 413 + // because first call already cached it — actually theme should also be cached) 414 + // Both policy AND theme should be cached after first call 415 + expect(mockFetch).toHaveBeenCalledTimes(2); // policy + theme (1 each) 416 + }); 417 + 418 + it("theme cache hit skips theme fetch on second call", async () => { 419 + const cache = new ThemeCache(TTL_MS); 420 + mockFetch 421 + .mockResolvedValueOnce(policyResponse()) 422 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 423 + 424 + await resolveTheme(APPVIEW, undefined, undefined, cache); 425 + // Second call: policy is cached, theme is cached — zero fetches 426 + mockFetch.mockClear(); 427 + await resolveTheme(APPVIEW, undefined, undefined, cache); 428 + 429 + expect(mockFetch).not.toHaveBeenCalled(); 430 + }); 431 + 432 + it("cache returns correct tokens on second call without fetch", async () => { 433 + const cache = new ThemeCache(TTL_MS); 434 + mockFetch 435 + .mockResolvedValueOnce(policyResponse()) 436 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 437 + 438 + const first = await resolveTheme(APPVIEW, undefined, undefined, cache); 439 + const second = await resolveTheme(APPVIEW, undefined, undefined, cache); 440 + 441 + expect(second.tokens["color-bg"]).toBe("#fff"); 442 + expect(second.tokens).toEqual(first.tokens); 443 + }); 444 + 445 + it("light and dark are cached independently — color scheme determines which is served", async () => { 446 + const cache = new ThemeCache(TTL_MS); 447 + mockFetch 448 + .mockResolvedValueOnce(policyResponse()) 449 + .mockResolvedValueOnce(themeResponse("light", "bafylight")) 450 + // Dark request: policy is cached, but dark theme is not yet 451 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 452 + 453 + const light = await resolveTheme(APPVIEW, undefined, undefined, cache); 454 + const dark = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined, cache); 455 + 456 + expect(light.colorScheme).toBe("light"); 457 + expect(light.tokens["color-bg"]).toBe("#fff"); 458 + expect(dark.colorScheme).toBe("dark"); 459 + expect(dark.tokens["color-bg"]).toBe("#111"); 460 + // policy (1) + light theme (1) + dark theme (1) = 3 fetches 461 + expect(mockFetch).toHaveBeenCalledTimes(3); 462 + }); 463 + 464 + it("stale cache CID triggers fresh fetch and logs warning", async () => { 465 + const cache = new ThemeCache(TTL_MS); 466 + // First request: policy has cid=bafylight, theme fetched with cid=bafylight 467 + mockFetch 468 + .mockResolvedValueOnce(policyResponse()) 469 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 470 + await resolveTheme(APPVIEW, undefined, undefined, cache); 471 + 472 + // Simulate policy TTL expiry + policy updated with new CID 473 + // We manually update the cache's policy to have a new CID 474 + cache.setPolicy({ 475 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 476 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 477 + allowUserChoice: true, 478 + availableThemes: [ 479 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafynew" }, // CID changed 480 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 481 + ], 482 + }); 483 + 484 + // Next request: policy is re-cached with new CID; theme cache has old CID 485 + // → stale cache detected → fresh fetch triggered 486 + mockFetch.mockResolvedValueOnce(themeResponse("light", "bafynew")); 487 + 488 + const mockLogger = vi.mocked(logger); 489 + const result = await resolveTheme(APPVIEW, undefined, undefined, cache); 490 + 491 + expect(mockLogger.warn).toHaveBeenCalledWith( 492 + expect.stringContaining("Cached theme has stale CID"), 493 + expect.objectContaining({ expectedCid: "bafynew", cachedCid: "bafylight" }) 494 + ); 495 + expect(result.tokens["color-bg"]).toBe("#fff"); // fresh theme served 496 + expect(mockFetch).toHaveBeenCalledTimes(3); // initial policy+theme + 1 fresh theme 497 + }); 498 + });
+3
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: parseInt(process.env.THEME_CACHE_TTL_MS ?? "300000", 10), 14 17 }; 15 18 }
+63
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<{ uri: string; cid?: string }>; 6 + } 7 + 8 + export interface CachedTheme { 9 + cid: string; 10 + tokens: Record<string, unknown>; 11 + cssOverrides: string | null; 12 + fontUrls: string[] | null; 13 + } 14 + 15 + interface CacheEntry<T> { 16 + data: T; 17 + expiresAt: number; 18 + } 19 + 20 + /** 21 + * In-memory TTL cache for resolved theme data on the web server. 22 + * 23 + * Themes change rarely. A single instance is created per server startup 24 + * (inside createThemeMiddleware) and shared across all requests. 25 + * 26 + * Policy: single entry, keyed to the forum. 27 + * Themes: map keyed by `${at-uri}:${colorScheme}` — colorScheme in the key 28 + * ensures light and dark cached entries are never confused, even if the same 29 + * AT-URI is used for both defaults. 30 + */ 31 + export class ThemeCache { 32 + private policy: CacheEntry<CachedPolicy> | null = null; 33 + private themes = new Map<string, CacheEntry<CachedTheme>>(); 34 + 35 + constructor(readonly ttlMs: number) {} 36 + 37 + getPolicy(): CachedPolicy | null { 38 + if (!this.policy || Date.now() > this.policy.expiresAt) { 39 + this.policy = null; 40 + return null; 41 + } 42 + return this.policy.data; 43 + } 44 + 45 + setPolicy(data: CachedPolicy): void { 46 + this.policy = { data, expiresAt: Date.now() + this.ttlMs }; 47 + } 48 + 49 + getTheme(uri: string, colorScheme: "light" | "dark"): CachedTheme | null { 50 + const key = `${uri}:${colorScheme}`; 51 + const entry = this.themes.get(key); 52 + if (!entry || Date.now() > entry.expiresAt) { 53 + this.themes.delete(key); 54 + return null; 55 + } 56 + return entry.data; 57 + } 58 + 59 + setTheme(uri: string, colorScheme: "light" | "dark", data: CachedTheme): void { 60 + const key = `${uri}:${colorScheme}`; 61 + this.themes.set(key, { data, expiresAt: Date.now() + this.ttlMs }); 62 + } 63 + }
+72 -46
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 - } 54 + // Re-export for consumers that only need the type shapes 55 + export type { CachedPolicy, CachedTheme, ThemeCache }; 66 56 67 57 /** 68 58 * Resolves which theme to render for a request using the waterfall: 69 59 * 1. User preference — not yet implemented (TODO: Theme Phase 4) 70 60 * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 71 - * 3. Forum default — fetched from GET /api/theme-policy 61 + * 3. Forum default — fetched from GET /api/theme-policy (cached in memory) 72 62 * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 63 + * 64 + * Pass a ThemeCache instance to enable in-memory TTL caching of policy and 65 + * theme data. The cache is checked before each network request and populated 66 + * after a successful fetch + CID validation. 73 67 * 74 68 * Never throws — always returns a usable theme. 75 69 */ 76 70 export async function resolveTheme( 77 71 appviewUrl: string, 78 72 cookieHeader: string | undefined, 79 - colorSchemeHint: string | undefined 73 + colorSchemeHint: string | undefined, 74 + cache?: ThemeCache 80 75 ): Promise<ResolvedTheme> { 81 76 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 82 77 // TODO: user preference (Theme Phase 4) 83 78 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", { 79 + // ── Step 1: Get theme policy (from cache or AppView) ─────────────────────── 80 + let policy: CachedPolicy | null = cache?.getPolicy() ?? null; 81 + 82 + if (!policy) { 83 + let policyRes: Response; 84 + try { 85 + policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 86 + if (!policyRes.ok) { 87 + logger.warn("Theme policy fetch returned non-ok status — using fallback", { 88 + operation: "resolveTheme", 89 + status: policyRes.status, 90 + url: `${appviewUrl}/api/theme-policy`, 91 + }); 92 + return { ...FALLBACK_THEME, colorScheme }; 93 + } 94 + } catch (error) { 95 + if (isProgrammingError(error)) throw error; 96 + logger.error("Theme policy fetch failed — using fallback", { 90 97 operation: "resolveTheme", 91 - status: policyRes.status, 92 - url: `${appviewUrl}/api/theme-policy`, 98 + error: error instanceof Error ? error.message : String(error), 93 99 }); 94 100 return { ...FALLBACK_THEME, colorScheme }; 95 101 } 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 - } 104 102 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 }; 103 + try { 104 + // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw 105 + policy = (await policyRes.json()) as CachedPolicy; 106 + cache?.setPolicy(policy); 107 + } catch { 108 + logger.error("Theme policy response contained invalid JSON — using fallback", { 109 + operation: "resolveTheme", 110 + url: `${appviewUrl}/api/theme-policy`, 111 + }); 112 + return { ...FALLBACK_THEME, colorScheme }; 113 + } 116 114 } 117 115 118 - // ── Step 3: Extract default theme URI and rkey ───────────────────────────── 116 + // ── Step 2: Extract default theme URI and rkey ───────────────────────────── 119 117 const defaultUri = 120 118 colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 121 119 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; ··· 133 131 // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 134 132 const expectedCid = matchingTheme?.cid ?? null; 135 133 136 - // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 134 + // ── Step 3: Get theme (from cache or AppView) ────────────────────────────── 135 + const cachedTheme: CachedTheme | null = cache?.getTheme(defaultUri, colorScheme) ?? null; 136 + 137 + if (cachedTheme !== null) { 138 + // CID check on the cached entry — guards against stale cache when the policy 139 + // refreshed (TTL expired) with a new CID while the theme entry is still live. 140 + if (expectedCid && cachedTheme.cid !== expectedCid) { 141 + logger.warn("Cached theme has stale CID — fetching fresh from AppView", { 142 + operation: "resolveTheme", 143 + expectedCid, 144 + cachedCid: cachedTheme.cid, 145 + themeUri: defaultUri, 146 + }); 147 + // Fall through to fresh fetch below 148 + } else { 149 + // Cache hit — CID matches (or live ref with no expected CID) 150 + return { 151 + tokens: cachedTheme.tokens as Record<string, string>, 152 + cssOverrides: cachedTheme.cssOverrides ?? null, 153 + fontUrls: cachedTheme.fontUrls ?? null, 154 + colorScheme, 155 + }; 156 + } 157 + } 158 + 159 + // ── Step 4: Fetch theme from AppView ────────────────────────────────────── 137 160 let themeRes: Response; 138 161 try { 139 162 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); ··· 157 180 } 158 181 159 182 // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 160 - let theme: ThemeResponse; 183 + let theme: CachedTheme; 161 184 try { 162 - theme = (await themeRes.json()) as ThemeResponse; 185 + theme = (await themeRes.json()) as CachedTheme; 163 186 } catch { 164 187 logger.error("Theme response contained invalid JSON — using fallback", { 165 188 operation: "resolveTheme", ··· 179 202 }); 180 203 return { ...FALLBACK_THEME, colorScheme }; 181 204 } 205 + 206 + // Populate cache only after successful validation 207 + cache?.setTheme(defaultUri, colorScheme, theme); 182 208 183 209 return { 184 210 tokens: theme.tokens as Record<string, string>,
+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 () => {
+9 -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 + const cache = new ThemeCache(cacheTtlMs); 7 14 return async (c, next) => { 8 15 const cookieHeader = c.req.header("Cookie"); 9 16 const colorSchemeHint = c.req.header("Sec-CH-Prefers-Color-Scheme"); 10 17 let theme; 11 18 try { 12 - theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint); 19 + theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint, cache); 13 20 } catch (error) { 14 21 logger.error("createThemeMiddleware: resolveTheme threw unexpectedly — using fallback", { 15 22 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))