Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 236 lines 7.8 kB view raw
1import { LRUCache } from "lru-cache"; 2import { fetchDocument, extractPreview, fetchLexicon, getLexiconType, getLexiconPreviewDef } from "./lib/graphql"; 3import { generateOgImage, generateLexiconOgImage } from "./lib/og-image"; 4 5const PORT = process.env.PORT || 3000; 6const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; 7const CDN_URL = process.env.CDN_URL || "https://tools.slices.network"; 8 9// Cache for generated images (max 500, 1 hour TTL) 10const imageCache = new LRUCache<string, Uint8Array>({ 11 max: 500, 12 ttl: 1000 * 60 * 60, 13}); 14 15// Cache for HTML (5 min TTL) 16const htmlCache = new LRUCache<string, string>({ 17 max: 10, 18 ttl: 1000 * 60 * 5, 19}); 20 21async function fetchHtml(filename: string): Promise<string> { 22 const cacheKey = filename; 23 const cached = htmlCache.get(cacheKey); 24 if (cached) return cached; 25 26 const url = `${CDN_URL}/${filename}`; 27 const res = await fetch(url); 28 if (!res.ok) { 29 throw new Error(`Failed to fetch ${url}: ${res.status}`); 30 } 31 32 const html = await res.text(); 33 htmlCache.set(cacheKey, html); 34 return html; 35} 36 37function escapeHtml(str: string): string { 38 return str 39 .replace(/&/g, "&amp;") 40 .replace(/</g, "&lt;") 41 .replace(/>/g, "&gt;") 42 .replace(/"/g, "&quot;"); 43} 44 45const server = Bun.serve({ 46 port: PORT, 47 async fetch(req) { 48 const url = new URL(req.url); 49 const path = url.pathname; 50 51 // Health check 52 if (path === "/health") { 53 return new Response("ok"); 54 } 55 56 // OG Image endpoint: /og/docs/:handle/:slug.png 57 const ogMatch = path.match(/^\/og\/docs\/([^/]+)\/([^/]+)\.png$/); 58 if (ogMatch) { 59 const [, handle, slug] = ogMatch; 60 61 try { 62 const doc = await fetchDocument(handle, slug); 63 if (!doc) { 64 return new Response("Document not found", { status: 404 }); 65 } 66 67 // Cache by cid - auto-invalidates when doc changes 68 const cacheKey = doc.cid; 69 const cached = imageCache.get(cacheKey); 70 if (cached) { 71 return new Response(cached, { 72 headers: { "Content-Type": "image/png" }, 73 }); 74 } 75 76 const imageResponse = await generateOgImage({ 77 title: doc.title, 78 preview: extractPreview(doc.blocks), 79 author: doc.actorHandle, 80 avatarUrl: doc.appBskyActorProfileByDid?.avatar?.url, 81 }); 82 83 const buffer = new Uint8Array(await imageResponse.arrayBuffer()); 84 imageCache.set(cacheKey, buffer); 85 86 return new Response(buffer, { 87 headers: { "Content-Type": "image/png" }, 88 }); 89 } catch (err) { 90 console.error("OG image error:", err); 91 return new Response("Error generating image", { status: 500 }); 92 } 93 } 94 95 // Docs endpoint: /docs/:handle/:slug (with OG tags) 96 const docsMatch = path.match(/^\/docs\/([^/]+)\/([^/]+)$/); 97 if (docsMatch) { 98 const [, handle, slug] = docsMatch; 99 100 try { 101 const docsHtml = await fetchHtml("docs"); 102 const doc = await fetchDocument(handle, slug); 103 104 if (!doc) { 105 return new Response(docsHtml, { 106 headers: { "Content-Type": "text/html" }, 107 }); 108 } 109 110 const preview = extractPreview(doc.blocks); 111 const ogImageUrl = `${BASE_URL}/og/docs/${encodeURIComponent(handle)}/${encodeURIComponent(slug)}.png`; 112 113 const ogTags = ` 114 <meta property="og:title" content="${escapeHtml(doc.title)}" /> 115 <meta property="og:description" content="${escapeHtml(preview)}" /> 116 <meta property="og:image" content="${ogImageUrl}" /> 117 <meta property="og:image:width" content="1200" /> 118 <meta property="og:image:height" content="630" /> 119 <meta property="og:type" content="article" /> 120 <meta name="twitter:card" content="summary_large_image" />`; 121 122 const html = docsHtml.replace("<head>", `<head>${ogTags}`); 123 return new Response(html, { 124 headers: { "Content-Type": "text/html" }, 125 }); 126 } catch (err) { 127 console.error("OG tag injection error:", err); 128 return new Response("Error loading page", { status: 500 }); 129 } 130 } 131 132 // Fallback for /docs/* (callback, settings, etc) - serve HTML without OG injection 133 if (path.startsWith("/docs/")) { 134 try { 135 const docsHtml = await fetchHtml("docs"); 136 return new Response(docsHtml, { 137 headers: { "Content-Type": "text/html" }, 138 }); 139 } catch (err) { 140 console.error("Docs fallback error:", err); 141 return new Response("Error loading page", { status: 500 }); 142 } 143 } 144 145 // OG Image endpoint for lexicon-explorer: /og/lexicon-explorer/:nsid.png 146 const lexiconOgMatch = path.match(/^\/og\/lexicon-explorer\/(.+)\.png$/); 147 if (lexiconOgMatch) { 148 const nsid = decodeURIComponent(lexiconOgMatch[1]); 149 150 try { 151 const lexicon = await fetchLexicon(nsid); 152 if (!lexicon) { 153 return new Response("Lexicon not found", { status: 404 }); 154 } 155 156 // Cache by nsid 157 const cacheKey = `lexicon:${nsid}`; 158 const cached = imageCache.get(cacheKey); 159 if (cached) { 160 return new Response(cached, { 161 headers: { "Content-Type": "image/png" }, 162 }); 163 } 164 165 const previewDef = getLexiconPreviewDef(lexicon); 166 167 const imageResponse = await generateLexiconOgImage({ 168 nsid: lexicon.id, 169 type: getLexiconType(lexicon), 170 author: lexicon.actorHandle || "unknown", 171 jsonDef: previewDef, 172 }); 173 174 const buffer = new Uint8Array(await imageResponse.arrayBuffer()); 175 imageCache.set(cacheKey, buffer); 176 177 return new Response(buffer, { 178 headers: { "Content-Type": "image/png" }, 179 }); 180 } catch (err) { 181 console.error("Lexicon OG image error:", err); 182 return new Response("Error generating image", { status: 500 }); 183 } 184 } 185 186 // Lexicon explorer endpoint: /lexicon-explorer?nsid=xxx (with OG tags) 187 if (path === "/lexicon-explorer") { 188 const nsid = url.searchParams.get("nsid"); 189 190 try { 191 const explorerHtml = await fetchHtml("lexicon-explorer"); 192 193 if (!nsid) { 194 // No nsid param, serve without OG injection 195 return new Response(explorerHtml, { 196 headers: { "Content-Type": "text/html" }, 197 }); 198 } 199 200 const lexicon = await fetchLexicon(nsid); 201 if (!lexicon) { 202 // Lexicon not found, serve without OG injection 203 return new Response(explorerHtml, { 204 headers: { "Content-Type": "text/html" }, 205 }); 206 } 207 208 const type = getLexiconType(lexicon); 209 const description = lexicon.description || `${type} lexicon by @${lexicon.actorHandle}`; 210 const ogImageUrl = `${BASE_URL}/og/lexicon-explorer/${encodeURIComponent(nsid)}.png`; 211 212 const ogTags = ` 213 <meta property="og:title" content="${escapeHtml(nsid)}" /> 214 <meta property="og:description" content="${escapeHtml(description)}" /> 215 <meta property="og:image" content="${ogImageUrl}" /> 216 <meta property="og:image:width" content="1200" /> 217 <meta property="og:image:height" content="630" /> 218 <meta property="og:type" content="website" /> 219 <meta name="twitter:card" content="summary_large_image" />`; 220 221 const html = explorerHtml.replace("<head>", `<head>${ogTags}`); 222 return new Response(html, { 223 headers: { "Content-Type": "text/html" }, 224 }); 225 } catch (err) { 226 console.error("Lexicon explorer error:", err); 227 return new Response("Error loading page", { status: 500 }); 228 } 229 } 230 231 return new Response("Not found", { status: 404 }); 232 }, 233}); 234 235console.log(`OG server running on port ${server.port}`); 236console.log(`Fetching HTML from: ${CDN_URL}`);