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 159 lines 5.0 kB view raw
1import { Hono } from "hono"; 2import type { AppContext } from "../lib/app-context.js"; 3import { themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; 4import { eq, inArray, and } from "drizzle-orm"; 5import { serializeBigInt, serializeDate } from "./helpers.js"; 6import { handleRouteError } from "../lib/route-errors.js"; 7import { parseAtUri } from "../lib/at-uri.js"; 8 9type ThemeRow = typeof themes.$inferSelect; 10 11function serializeThemeSummary(theme: ThemeRow) { 12 return { 13 id: serializeBigInt(theme.id), 14 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 15 name: theme.name, 16 colorScheme: theme.colorScheme, 17 indexedAt: serializeDate(theme.indexedAt), 18 }; 19} 20 21function serializeThemeFull(theme: ThemeRow) { 22 return { 23 id: serializeBigInt(theme.id), 24 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 25 cid: theme.cid, 26 name: theme.name, 27 colorScheme: theme.colorScheme, 28 tokens: theme.tokens, 29 cssOverrides: theme.cssOverrides ?? null, 30 fontUrls: (theme.fontUrls as string[] | null) ?? null, 31 createdAt: serializeDate(theme.createdAt), 32 indexedAt: serializeDate(theme.indexedAt), 33 }; 34} 35 36export function createThemesRoutes(ctx: AppContext) { 37 return new Hono() 38 .get("/", async (c) => { 39 try { 40 // Step 1: Get available theme URIs from this forum's policy 41 const availableRows = await ctx.db 42 .select({ themeUri: themePolicyAvailableThemes.themeUri }) 43 .from(themePolicyAvailableThemes) 44 .innerJoin( 45 themePolicies, 46 eq(themePolicies.id, themePolicyAvailableThemes.policyId) 47 ) 48 .where(eq(themePolicies.did, ctx.config.forumDid)); 49 50 if (availableRows.length === 0) { 51 c.header("Cache-Control", "public, max-age=300"); 52 return c.json({ themes: [] }); 53 } 54 55 // Step 2: Parse rkeys from AT-URIs 56 const rkeys = availableRows 57 .map((r) => parseAtUri(r.themeUri)?.rkey) 58 .filter((rkey): rkey is string => !!rkey); 59 60 if (rkeys.length === 0) { 61 c.header("Cache-Control", "public, max-age=300"); 62 return c.json({ themes: [] }); 63 } 64 65 // Step 3: Fetch matching themes 66 const themeList = await ctx.db 67 .select() 68 .from(themes) 69 .where( 70 and( 71 eq(themes.did, ctx.config.forumDid), 72 inArray(themes.rkey, rkeys) 73 ) 74 ) 75 .limit(100); 76 77 c.header("Cache-Control", "public, max-age=300"); 78 return c.json({ themes: themeList.map(serializeThemeSummary) }); 79 } catch (error) { 80 return handleRouteError(c, error, "Failed to retrieve themes", { 81 operation: "GET /api/themes", 82 logger: ctx.logger, 83 }); 84 } 85 }) 86 .get("/:rkey", async (c) => { 87 const rkey = c.req.param("rkey").trim(); 88 if (!rkey) { 89 return c.json({ error: "Invalid theme rkey" }, 400); 90 } 91 92 try { 93 const [theme] = await ctx.db 94 .select() 95 .from(themes) 96 .where( 97 and( 98 eq(themes.did, ctx.config.forumDid), 99 eq(themes.rkey, rkey) 100 ) 101 ) 102 .limit(1); 103 104 if (!theme) { 105 return c.json({ error: "Theme not found" }, 404); 106 } 107 108 c.header("Cache-Control", "public, max-age=300"); 109 c.header("ETag", `"${theme.cid}"`); 110 return c.json(serializeThemeFull(theme)); 111 } catch (error) { 112 return handleRouteError(c, error, "Failed to retrieve theme", { 113 operation: "GET /api/themes/:rkey", 114 logger: ctx.logger, 115 themeRkey: rkey, 116 }); 117 } 118 }); 119} 120 121export function createThemePolicyRoutes(ctx: AppContext) { 122 return new Hono().get("/", async (c) => { 123 try { 124 const [policy] = await ctx.db 125 .select() 126 .from(themePolicies) 127 .where(eq(themePolicies.did, ctx.config.forumDid)) 128 .limit(1); 129 130 if (!policy) { 131 return c.json({ error: "Theme policy not found" }, 404); 132 } 133 134 const available = await ctx.db 135 .select({ 136 themeUri: themePolicyAvailableThemes.themeUri, 137 themeCid: themePolicyAvailableThemes.themeCid, 138 }) 139 .from(themePolicyAvailableThemes) 140 .where(eq(themePolicyAvailableThemes.policyId, policy.id)); 141 142 c.header("Cache-Control", "public, max-age=300"); 143 return c.json({ 144 defaultLightThemeUri: policy.defaultLightThemeUri, 145 defaultDarkThemeUri: policy.defaultDarkThemeUri, 146 allowUserChoice: policy.allowUserChoice, 147 availableThemes: available.map((t) => ({ 148 uri: t.themeUri, 149 cid: t.themeCid, 150 })), 151 }); 152 } catch (error) { 153 return handleRouteError(c, error, "Failed to retrieve theme policy", { 154 operation: "GET /api/theme-policy", 155 logger: ctx.logger, 156 }); 157 } 158 }); 159}