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
at root/atb-56-theme-caching-layer 216 lines 8.2 kB view raw
1import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2import { isProgrammingError } from "./errors.js"; 3import { logger } from "./logger.js"; 4import { ThemeCache, type CachedPolicy, type CachedTheme } from "./theme-cache.js"; 5 6export type ResolvedTheme = { 7 tokens: Record<string, string>; 8 cssOverrides: string | null; 9 fontUrls: string[] | null; 10 colorScheme: "light" | "dark"; 11}; 12 13/** Hono app environment type — used by middleware and all route factories. */ 14export type WebAppEnv = { 15 Variables: { theme: ResolvedTheme }; 16}; 17 18/** Hardcoded fallback used when theme policy is missing or resolution fails. */ 19export const FALLBACK_THEME: ResolvedTheme = Object.freeze({ 20 tokens: neobrutalLight as Record<string, string>, 21 cssOverrides: null, 22 fontUrls: Object.freeze([ 23 "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 24 ]), 25 colorScheme: "light", 26} as const) as ResolvedTheme; 27 28/** 29 * Detects the user's preferred color scheme. 30 * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". 31 */ 32export function detectColorScheme( 33 cookieHeader: string | undefined, 34 hint: string | undefined 35): "light" | "dark" { 36 const match = cookieHeader?.match(/(?:^|;\s*)atbb-color-scheme=(light|dark)/); 37 if (match) return match[1] as "light" | "dark"; 38 if (hint === "dark") return "dark"; 39 return "light"; 40} 41 42/** 43 * Extracts the rkey segment from an AT URI. 44 * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" 45 */ 46export function parseRkeyFromUri(atUri: string): string | null { 47 // Format: at://<did>/<collection>/<rkey> 48 // Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"] 49 const parts = atUri.split("/"); 50 if (parts.length < 5) return null; 51 return parts[4] ?? null; 52} 53 54 55/** 56 * Resolves which theme to render for a request using the waterfall: 57 * 1. User preference — not yet implemented (TODO: Theme Phase 4) 58 * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 59 * 3. Forum default — fetched from GET /api/theme-policy (cached in memory) 60 * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 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 * 66 * Never throws — always returns a usable theme. 67 */ 68export async function resolveTheme( 69 appviewUrl: string, 70 cookieHeader: string | undefined, 71 colorSchemeHint: string | undefined, 72 cache?: ThemeCache 73): Promise<ResolvedTheme> { 74 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 75 // TODO: user preference (Theme Phase 4) 76 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", { 95 operation: "resolveTheme", 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", 108 url: `${appviewUrl}/api/theme-policy`, 109 }); 110 return { ...FALLBACK_THEME, colorScheme }; 111 } 112 } 113 114 // ── Step 2: Extract default theme URI and rkey ───────────────────────────── 115 const defaultUri = 116 colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 117 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 118 119 const rkey = parseRkeyFromUri(defaultUri); 120 if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 121 122 const matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); 123 if (!matchingTheme) { 124 logger.warn("Theme URI not in availableThemes — skipping CID check", { 125 operation: "resolveTheme", 126 themeUri: defaultUri, 127 }); 128 } 129 // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 130 const expectedCid = matchingTheme?.cid ?? null; 131 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 ────────────────────────────────────── 161 let themeRes: Response; 162 try { 163 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 164 if (!themeRes.ok) { 165 logger.warn("Theme fetch returned non-ok status — using fallback", { 166 operation: "resolveTheme", 167 status: themeRes.status, 168 rkey, 169 themeUri: defaultUri, 170 }); 171 return { ...FALLBACK_THEME, colorScheme }; 172 } 173 } catch (error) { 174 if (isProgrammingError(error)) throw error; 175 logger.error("Theme fetch failed — using fallback", { 176 operation: "resolveTheme", 177 rkey, 178 error: error instanceof Error ? error.message : String(error), 179 }); 180 return { ...FALLBACK_THEME, colorScheme }; 181 } 182 183 // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 184 let theme: CachedTheme; 185 try { 186 theme = (await themeRes.json()) as CachedTheme; 187 } catch { 188 logger.error("Theme response contained invalid JSON — using fallback", { 189 operation: "resolveTheme", 190 rkey, 191 themeUri: defaultUri, 192 }); 193 return { ...FALLBACK_THEME, colorScheme }; 194 } 195 196 // ── Step 6: CID integrity check ──────────────────────────────────────────── 197 if (expectedCid && theme.cid !== expectedCid) { 198 logger.warn("Theme CID mismatch — using hardcoded fallback", { 199 operation: "resolveTheme", 200 expectedCid, 201 actualCid: theme.cid, 202 themeUri: defaultUri, 203 }); 204 return { ...FALLBACK_THEME, colorScheme }; 205 } 206 207 // Populate cache only after successful validation 208 cache?.setTheme(defaultUri, colorScheme, theme); 209 210 return { 211 tokens: theme.tokens, 212 cssOverrides: theme.cssOverrides ?? null, 213 fontUrls: theme.fontUrls ?? null, 214 colorScheme, 215 }; 216}