atmosphere explorer
0
fork

Configure Feed

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

at 0f4e0d17bdadbbfff6eb0f5117bb8d695a3843ee 523 lines 16 kB view raw
1import { ImageResponse } from "@takumi-rs/image-response/wasm"; 2import wasmModule from "./takumi_wasm_bg.wasm"; 3 4// Minimal createElement helper — avoids pulling in React 5function h(type, props, ...children) { 6 const flat = children.flat(Infinity).filter((c) => c != null && c !== false); 7 return { 8 type, 9 props: { 10 ...props, 11 children: 12 flat.length === 0 ? undefined 13 : flat.length === 1 ? flat[0] 14 : flat, 15 }, 16 }; 17} 18 19let fontData = null; 20async function getFonts() { 21 if (!fontData) { 22 const urls = [ 23 [ 24 "Roboto Mono", 25 "https://fonts.bunny.net/roboto-mono/files/roboto-mono-latin-400-normal.woff2", 26 ], 27 [ 28 "Noto Sans JP", 29 "https://fonts.bunny.net/noto-sans-jp/files/noto-sans-jp-japanese-400-normal.woff2", 30 ], 31 [ 32 "Noto Sans SC", 33 "https://fonts.bunny.net/noto-sans-sc/files/noto-sans-sc-chinese-simplified-400-normal.woff2", 34 ], 35 [ 36 "Noto Sans KR", 37 "https://fonts.bunny.net/noto-sans-kr/files/noto-sans-kr-korean-400-normal.woff2", 38 ], 39 ["Noto Emoji", "https://fonts.bunny.net/noto-emoji/files/noto-emoji-emoji-400-normal.woff2"], 40 ]; 41 const results = await Promise.all( 42 urls.map(([name, url]) => 43 fetch(url) 44 .then((r) => (r.ok ? r.arrayBuffer() : null)) 45 .then((data) => (data ? { data, name, weight: 400, style: "normal" } : null)) 46 .catch(() => null), 47 ), 48 ); 49 fontData = results.filter(Boolean); 50 } 51 return fontData; 52} 53 54async function fetchRecord(pdsUrl, repo, collection, rkey) { 55 const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; 56 const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); 57 if (!res.ok) return null; 58 return res.json(); 59} 60 61const LOGO_PATH = 62 "M14 1a3 3 0 0 1 2.348 4.868l2 3.203Q18.665 9 19 9a3 3 0 1 1-2.347 1.132l-2-3.203a3 3 0 0 1-1.304 0l-2.001 3.203c.408.513.652 1.162.652 1.868s-.244 1.356-.653 1.868l2.002 3.203Q13.664 17 14 17a3 3 0 1 1-2.347 1.132L9.65 14.929a3 3 0 0 1-1.302 0l-2.002 3.203a3 3 0 1 1-1.696-1.06l2.002-3.204A3 3 0 0 1 9.65 9.07l2.002-3.202A3 3 0 0 1 14 1"; 63 64// Colors matching json.tsx dark mode 65const C = { 66 key: "#818cf8", // indigo-400 67 index: "#a78bfa", // violet-400 68 string: "#f1f5f9", // slate-100 69 quote: "#a3a3a3", // neutral-400 70 number: "#f1f5f9", // slate-100 71 boolean: "#fbbf24", // amber-400 72 null: "#737373", // neutral-500 73 guide: "#737373", // neutral-500 74 colon: "#a3a3a3", // neutral-400 75}; 76 77const MAX_STRING_WIDTH = 80; 78 79function truncateToWidth(str, maxWidth) { 80 let w = 0; 81 let i = 0; 82 const chars = [...str]; 83 for (; i < chars.length; i++) { 84 const cp = chars[i].codePointAt(0); 85 const cw = 86 ( 87 (cp >= 0x1100 && cp <= 0x115f) || 88 (cp >= 0x2e80 && cp <= 0x9fff) || 89 (cp >= 0xac00 && cp <= 0xd7af) || 90 (cp >= 0xf900 && cp <= 0xfaff) || 91 (cp >= 0xfe10 && cp <= 0xfe6f) || 92 (cp >= 0xff01 && cp <= 0xff60) || 93 (cp >= 0xffe0 && cp <= 0xffe6) || 94 (cp >= 0x20000 && cp <= 0x2fa1f) 95 ) ? 96 2 97 : 1; 98 if (w + cw > maxWidth) break; 99 w += cw; 100 } 101 return i < chars.length ? chars.slice(0, i).join("") + "…" : str; 102} 103const MAX_LINES = 20; 104 105// Flatten JSON into an array of { depth, segments } lines 106// Each segment is { text, color } 107function flattenJson(value, depth, lines, key, isIndex, maxStrWidth) { 108 if (lines.length >= MAX_LINES) return; 109 110 const keySegs = []; 111 if (key !== undefined) { 112 keySegs.push({ text: String(key), color: isIndex ? C.index : C.key }); 113 keySegs.push({ text: ": ", color: C.colon }); 114 } 115 116 if (value === null) { 117 lines.push({ depth, segments: [...keySegs, { text: "null", color: C.null }] }); 118 } else if (typeof value === "boolean") { 119 lines.push({ depth, segments: [...keySegs, { text: String(value), color: C.boolean }] }); 120 } else if (typeof value === "number") { 121 lines.push({ depth, segments: [...keySegs, { text: String(value), color: C.number }] }); 122 } else if (typeof value === "string") { 123 const display = value.replace(/\n/g, " "); 124 const truncated = truncateToWidth(display, maxStrWidth); 125 lines.push({ 126 depth, 127 segments: [ 128 ...keySegs, 129 { text: '"', color: C.quote }, 130 { text: truncated, color: C.string }, 131 { text: '"', color: C.quote }, 132 ], 133 }); 134 } else if (Array.isArray(value)) { 135 if (value.length === 0) { 136 lines.push({ depth, segments: [...keySegs, { text: "[ ]", color: C.null }] }); 137 } else { 138 if (key !== undefined) { 139 lines.push({ 140 depth, 141 segments: [ 142 { text: String(key), color: isIndex ? C.index : C.key }, 143 { text: ":", color: C.colon }, 144 ], 145 }); 146 } 147 for (let i = 0; i < value.length; i++) { 148 if (lines.length >= MAX_LINES) break; 149 flattenJson(value[i], depth + 1, lines, `#${i}`, true, maxStrWidth); 150 } 151 } 152 } else { 153 const keys = Object.keys(value); 154 if (keys.length === 0) { 155 lines.push({ depth, segments: [...keySegs, { text: "{ }", color: C.null }] }); 156 } else { 157 if (key !== undefined) { 158 lines.push({ 159 depth, 160 segments: [ 161 { text: String(key), color: isIndex ? C.index : C.key }, 162 { text: ":", color: C.colon }, 163 ], 164 }); 165 } 166 for (const k of keys) { 167 if (lines.length >= MAX_LINES) break; 168 flattenJson(value[k], depth + 1, lines, k, false, maxStrWidth); 169 } 170 } 171 } 172} 173 174function renderLine(line, guideMargin) { 175 const guides = []; 176 for (let i = 0; i < line.depth; i++) { 177 guides.push( 178 h("div", { 179 style: { 180 width: 1, 181 backgroundColor: C.guide, 182 marginRight: guideMargin, 183 flexShrink: 0, 184 }, 185 }), 186 ); 187 } 188 return h( 189 "div", 190 { style: { display: "flex", overflow: "hidden" } }, 191 ...guides, 192 ...line.segments.map((seg) => h("div", { style: { color: seg.color } }, seg.text)), 193 ); 194} 195 196function OgImage({ record }) { 197 const lines = []; 198 for (const k of Object.keys(record)) { 199 if (lines.length >= MAX_LINES) break; 200 flattenJson(record[k], 0, lines, k, false, MAX_STRING_WIDTH); 201 } 202 if (lines.length >= MAX_LINES) { 203 lines.push({ depth: 0, segments: [{ text: "…", color: C.null }] }); 204 } 205 206 const availableHeight = 630 - 100; // height minus vertical padding 207 const fontSize = Math.min(32, Math.max(18, Math.floor(availableHeight / (lines.length * 1.5)))); 208 const guideMargin = Math.round((fontSize * 19) / 18); 209 210 // Re-truncate string values if the larger font size means fewer chars fit. 211 // Available width: 1200 canvas - 100 padding - 80 logo area - 200 for key/depth overhead; 212 // Roboto Mono char ≈ 0.6× fontSize. 213 const maxStrWidth = Math.floor((1200 - 100 - 80 - 200) / (fontSize * 0.6)); 214 if (maxStrWidth < MAX_STRING_WIDTH) { 215 for (const line of lines) { 216 for (const seg of line.segments) { 217 if (seg.color === C.string) { 218 seg.text = truncateToWidth(seg.text, maxStrWidth); 219 } 220 } 221 } 222 } 223 224 return h( 225 "div", 226 { 227 style: { 228 display: "flex", 229 flexDirection: "column", 230 justifyContent: "center", 231 position: "relative", 232 width: "100%", 233 height: "100%", 234 background: "#1f1f1f", 235 padding: "50px 50px", 236 fontFamily: "Roboto Mono, Noto Sans JP, Noto Sans SC, Noto Sans KR, Noto Emoji", 237 fontSize, 238 lineHeight: 1.5, 239 color: "#e2e8f0", 240 }, 241 }, 242 h( 243 "div", 244 { 245 style: { 246 position: "absolute", 247 bottom: 24, 248 right: 24, 249 }, 250 }, 251 h( 252 "svg", 253 { viewBox: "0 0 24 24", width: 48, height: 48 }, 254 h("path", { fill: "#76c4e5", d: LOGO_PATH }), 255 ), 256 ), 257 h( 258 "div", 259 { style: { display: "flex", flexDirection: "column", paddingRight: 80 } }, 260 ...lines.map((line) => renderLine(line, guideMargin)), 261 ), 262 ); 263} 264 265async function handleOgImage(searchParams) { 266 const did = searchParams.get("did"); 267 const collection = searchParams.get("collection"); 268 const rkey = searchParams.get("rkey"); 269 270 if (!did || !collection || !rkey) { 271 return new Response("Missing params", { status: 400 }); 272 } 273 274 const doc = await resolveDidDoc(did).catch(() => null); 275 const pdsUrl = doc ? pdsFromDoc(doc) : null; 276 if (!pdsUrl) { 277 return new Response("Could not resolve PDS", { status: 404 }); 278 } 279 280 const data = await fetchRecord(pdsUrl, did, collection, rkey).catch(() => null); 281 if (!data?.value) { 282 return new Response("Record not found", { status: 404 }); 283 } 284 285 const fonts = await getFonts(); 286 287 return new ImageResponse(OgImage({ record: data.value }), { 288 width: 1200, 289 height: 630, 290 module: wasmModule, 291 fonts, 292 format: "png", 293 }); 294} 295 296// ---- existing worker logic ---- 297 298const BOT_UAS = [ 299 "Discordbot", 300 "Twitterbot", 301 "facebookexternalhit", 302 "LinkedInBot", 303 "Slackbot-LinkExpanding", 304 "TelegramBot", 305 "WhatsApp", 306 "Iframely", 307 "Embedly", 308 "redditbot", 309 "Cardyb", 310]; 311 312function isBot(ua) { 313 return BOT_UAS.some((b) => ua.includes(b)); 314} 315 316function esc(s) { 317 return s 318 .replace(/&/g, "&amp;") 319 .replace(/</g, "&lt;") 320 .replace(/>/g, "&gt;") 321 .replace(/"/g, "&quot;"); 322} 323 324async function resolveDidDoc(did) { 325 let docUrl; 326 if (did.startsWith("did:plc:")) { 327 docUrl = `https://plc.directory/${did}`; 328 } else if (did.startsWith("did:web:")) { 329 const host = did.slice("did:web:".length); 330 docUrl = `https://${host}/.well-known/did.json`; 331 } else { 332 return null; 333 } 334 335 const res = await fetch(docUrl, { signal: AbortSignal.timeout(3000) }); 336 if (!res.ok) return null; 337 return res.json(); 338} 339 340function pdsFromDoc(doc) { 341 return doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 342} 343 344function handleFromDoc(doc) { 345 const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://")); 346 return aka ? aka.slice("at://".length) : null; 347} 348 349const STATIC_ROUTES = { 350 "/": { title: "PDSls", description: "Browse the public data on atproto" }, 351 "/jetstream": { 352 title: "Jetstream", 353 description: "A simplified event stream with support for collection and DID filtering.", 354 }, 355 "/firehose": { title: "Firehose", description: "The raw event stream from a relay or PDS." }, 356 "/spacedust": { 357 title: "Spacedust", 358 description: "A stream of links showing interactions across the network.", 359 }, 360 "/labels": { title: "Labels", description: "Query labels applied to accounts and records." }, 361 "/car": { 362 title: "Archive tools", 363 description: "Tools for working with CAR (Content Addressable aRchive) files.", 364 }, 365 "/car/explore": { 366 title: "Explore archive", 367 description: "Upload a CAR file to explore its contents.", 368 }, 369 "/car/unpack": { 370 title: "Unpack archive", 371 description: "Upload a CAR file to extract all records into a ZIP archive.", 372 }, 373 "/settings": { title: "Settings", description: "Browse the public data on atproto" }, 374}; 375 376async function resolveOgData(pathname) { 377 if (pathname in STATIC_ROUTES) return STATIC_ROUTES[pathname]; 378 379 let title = "PDSls"; 380 let description = "Browse the public data on atproto"; 381 382 const segments = pathname.slice(1).split("/").filter(Boolean); 383 const isAtUrl = segments[0] === "at:"; 384 385 if (isAtUrl) { 386 // at://did[/collection[/rkey]] 387 const [, did, collection, rkey] = segments; 388 389 if (!did) { 390 // bare /at: — use defaults 391 } else if (!collection) { 392 const doc = await resolveDidDoc(did).catch(() => null); 393 const handle = doc ? handleFromDoc(doc) : null; 394 const pdsUrl = doc ? pdsFromDoc(doc) : null; 395 const pdsHost = pdsUrl ? pdsUrl.replace("https://", "").replace("http://", "") : null; 396 397 title = handle ? `${handle} (${did})` : did; 398 description = pdsHost ? `Hosted on ${pdsHost}` : `Repository for ${did}`; 399 } else if (!rkey) { 400 const doc = await resolveDidDoc(did).catch(() => null); 401 const handle = doc ? handleFromDoc(doc) : null; 402 title = `at://${handle ?? did}/${collection}`; 403 description = `List of ${collection} records from ${handle ?? did}`; 404 } else { 405 const doc = await resolveDidDoc(did).catch(() => null); 406 const handle = doc ? handleFromDoc(doc) : null; 407 description = ""; 408 title = `at://${handle ?? did}/${collection}/${rkey}`; 409 return { title, description, generateImage: true, did, collection, rkey }; 410 } 411 } else { 412 // /pds 413 const [pds] = segments; 414 if (pds) { 415 title = pds; 416 description = `Browse the repositories at ${pds}`; 417 } 418 } 419 420 return { title, description }; 421} 422 423class OgTagRewriter { 424 constructor(ogData, url) { 425 this.ogData = ogData; 426 this.url = url; 427 } 428 429 element(element) { 430 const property = element.getAttribute("property"); 431 const name = element.getAttribute("name"); 432 433 if ( 434 property === "og:title" || 435 property === "og:description" || 436 property === "og:url" || 437 property === "og:type" || 438 property === "og:site_name" || 439 property === "og:image" || 440 property === "description" || 441 name === "description" || 442 name === "twitter:card" || 443 name === "twitter:title" || 444 name === "twitter:description" || 445 name === "twitter:image" 446 ) { 447 element.remove(); 448 } 449 } 450} 451 452class HeadEndRewriter { 453 constructor(ogData, imageUrl) { 454 this.ogData = ogData; 455 this.imageUrl = imageUrl; 456 } 457 458 element(element) { 459 const t = esc(this.ogData.title); 460 const d = esc(this.ogData.description); 461 const i = this.imageUrl ? esc(this.imageUrl) : null; 462 463 const imageTags = 464 i ? 465 `\n <meta property="og:image" content="${i}" /> 466 <meta name="twitter:card" content="summary_large_image" /> 467 <meta name="twitter:image" content="${i}" />` 468 : `\n <meta name="twitter:card" content="summary" />`; 469 470 element.append( 471 `<meta property="og:title" content="${t}" /> 472 <meta property="og:type" content="website" /> 473 <meta property="og:description" content="${d}" /> 474 <meta property="og:site_name" content="PDSls" /> 475 <meta name="description" content="${d}" /> 476 <meta name="twitter:title" content="${t}" /> 477 <meta name="twitter:description" content="${d}" />${imageTags}`, 478 { html: true }, 479 ); 480 } 481} 482 483export default { 484 async fetch(request, env) { 485 const url = new URL(request.url); 486 487 if (url.pathname === "/og-image") { 488 return handleOgImage(url.searchParams).catch( 489 (err) => new Response(`Failed to generate image: ${err?.message ?? err}`, { status: 500 }), 490 ); 491 } 492 493 const ua = request.headers.get("user-agent") ?? ""; 494 495 if (!isBot(ua)) { 496 return env.ASSETS.fetch(request); 497 } 498 499 let ogData; 500 try { 501 ogData = await resolveOgData(url.pathname); 502 } catch { 503 return env.ASSETS.fetch(request); 504 } 505 506 const imageUrl = 507 ogData.generateImage ? 508 `${url.origin}/og-image?` + 509 new URLSearchParams({ did: ogData.did, collection: ogData.collection, rkey: ogData.rkey }) 510 : null; 511 512 const response = await env.ASSETS.fetch(request); 513 const contentType = response.headers.get("content-type") ?? ""; 514 if (!contentType.includes("text/html")) { 515 return response; 516 } 517 518 return new HTMLRewriter() 519 .on("meta", new OgTagRewriter(ogData, request.url)) 520 .on("head", new HeadEndRewriter(ogData, imageUrl)) 521 .transform(response); 522 }, 523};