atmosphere explorer pds.ls
tool typescript atproto
427
fork

Configure Feed

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

at main 681 lines 20 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 ]; 40 const results = await Promise.all( 41 urls.map(([name, url]) => 42 fetch(url) 43 .then((r) => (r.ok ? r.arrayBuffer() : null)) 44 .then((data) => (data ? { data, name, weight: 400, style: "normal" } : null)) 45 .catch(() => null), 46 ), 47 ); 48 fontData = results.filter(Boolean); 49 } 50 return fontData; 51} 52 53async function fetchRecord(pdsUrl, repo, collection, rkey) { 54 const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; 55 const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); 56 if (!res.ok) return null; 57 return res.json(); 58} 59 60const LOGO_PATH = 61 "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"; 62 63// Colors matching json.tsx dark mode 64const C = { 65 key: "#818cf8", // indigo-400 66 index: "#a78bfa", // violet-400 67 string: "#f1f5f9", // slate-100 68 quote: "#a3a3a3", // neutral-400 69 number: "#f1f5f9", // slate-100 70 boolean: "#fbbf24", // amber-400 71 null: "#737373", // neutral-500 72 guide: "#737373", // neutral-500 73 colon: "#a3a3a3", // neutral-400 74}; 75 76const MAX_STRING_WIDTH = 80; 77 78function truncateToWidth(str, maxWidth) { 79 let w = 0; 80 let i = 0; 81 const chars = [...str]; 82 for (; i < chars.length; i++) { 83 const cp = chars[i].codePointAt(0); 84 const cw = 85 ( 86 (cp >= 0x1100 && cp <= 0x115f) || 87 (cp >= 0x2e80 && cp <= 0x9fff) || 88 (cp >= 0xac00 && cp <= 0xd7af) || 89 (cp >= 0xf900 && cp <= 0xfaff) || 90 (cp >= 0xfe10 && cp <= 0xfe6f) || 91 (cp >= 0xff01 && cp <= 0xff60) || 92 (cp >= 0xffe0 && cp <= 0xffe6) || 93 (cp >= 0x20000 && cp <= 0x2fa1f) 94 ) ? 95 2 96 : 1; 97 if (w + cw > maxWidth) break; 98 w += cw; 99 } 100 return i < chars.length ? chars.slice(0, i).join("") + "…" : str; 101} 102const MAX_LINES = 20; 103 104// Flatten JSON into an array of { depth, segments } lines 105// Each segment is { text, color } 106function flattenJson(value, depth, lines, key, isIndex, maxStrWidth) { 107 if (lines.length >= MAX_LINES) return; 108 109 const keySegs = []; 110 if (key !== undefined) { 111 keySegs.push({ text: String(key), color: isIndex ? C.index : C.key }); 112 keySegs.push({ text: ":", color: C.colon, mr: 4 }); 113 } 114 115 if (value === null) { 116 lines.push({ depth, segments: [...keySegs, { text: "null", color: C.null }] }); 117 } else if (typeof value === "boolean") { 118 lines.push({ depth, segments: [...keySegs, { text: String(value), color: C.boolean }] }); 119 } else if (typeof value === "number") { 120 lines.push({ depth, segments: [...keySegs, { text: String(value), color: C.number }] }); 121 } else if (typeof value === "string") { 122 const display = value.replace(/\n/g, " "); 123 const truncated = truncateToWidth(display, maxStrWidth - 2); 124 lines.push({ 125 depth, 126 segments: [ 127 ...keySegs, 128 { text: '"', color: C.quote }, 129 { text: truncated, color: C.string }, 130 { text: '"', color: C.quote }, 131 ], 132 }); 133 } else if (Array.isArray(value)) { 134 if (value.length === 0) { 135 lines.push({ depth, segments: [...keySegs, { text: "[ ]", color: C.null }] }); 136 } else { 137 if (key !== undefined) { 138 lines.push({ 139 depth, 140 segments: [ 141 { text: String(key), color: isIndex ? C.index : C.key }, 142 { text: ":", color: C.colon }, 143 ], 144 }); 145 } 146 for (let i = 0; i < value.length; i++) { 147 if (lines.length >= MAX_LINES) break; 148 flattenJson(value[i], depth + 1, lines, `#${i}`, true, maxStrWidth); 149 } 150 } 151 } else { 152 const keys = Object.keys(value); 153 if (keys.length === 0) { 154 lines.push({ depth, segments: [...keySegs, { text: "{ }", color: C.null }] }); 155 } else { 156 if (key !== undefined) { 157 lines.push({ 158 depth, 159 segments: [ 160 { text: String(key), color: isIndex ? C.index : C.key }, 161 { text: ":", color: C.colon }, 162 ], 163 }); 164 } 165 for (const k of keys) { 166 if (lines.length >= MAX_LINES) break; 167 flattenJson(value[k], depth + 1, lines, k, false, maxStrWidth); 168 } 169 } 170 } 171} 172 173function renderLine(line, guideMargin) { 174 const guides = []; 175 for (let i = 0; i < line.depth; i++) { 176 guides.push( 177 h("div", { 178 style: { 179 display: "flex", 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", whiteSpace: "nowrap" } }, 191 ...guides, 192 ...line.segments.map((seg) => 193 h( 194 "div", 195 { 196 style: { display: "flex", color: seg.color, ...(seg.mr ? { marginRight: seg.mr } : {}) }, 197 }, 198 seg.text, 199 ), 200 ), 201 ); 202} 203 204function OgImage({ record }) { 205 const lines = []; 206 for (const k of Object.keys(record)) { 207 if (lines.length >= MAX_LINES) break; 208 flattenJson(record[k], 0, lines, k, false, MAX_STRING_WIDTH); 209 } 210 if (lines.length >= MAX_LINES) { 211 lines.push({ depth: 0, segments: [{ text: "…", color: C.null }] }); 212 } 213 214 const availableHeight = 630 - 100; // height minus vertical padding 215 const fontSize = Math.min(32, Math.max(18, Math.floor(availableHeight / (lines.length * 1.5)))); 216 const guideMargin = Math.round(fontSize * 1.2) - 1; 217 218 // Re-truncate string values if the larger font size means fewer chars fit. 219 // Available width: 1200 canvas - 100 padding - 80 logo area - 200 for key/depth overhead; 220 // Roboto Mono char ≈ 0.6× fontSize. 221 const maxStrWidth = Math.floor((1200 - 100 - 80 - 200) / (fontSize * 0.6)); 222 if (maxStrWidth < MAX_STRING_WIDTH) { 223 for (const line of lines) { 224 for (const seg of line.segments) { 225 if (seg.color === C.string) { 226 seg.text = truncateToWidth(seg.text, maxStrWidth - 2); 227 } 228 } 229 } 230 } 231 232 return h( 233 "div", 234 { 235 style: { 236 display: "flex", 237 flexDirection: "column", 238 justifyContent: "center", 239 position: "relative", 240 width: "100%", 241 height: "100%", 242 background: "#1f1f1f", 243 padding: "50px 50px", 244 fontFamily: "Roboto Mono, Noto Sans JP, Noto Sans SC, Noto Sans KR", 245 fontSize, 246 lineHeight: 1.5, 247 color: "#e2e8f0", 248 }, 249 }, 250 h( 251 "div", 252 { 253 style: { 254 position: "absolute", 255 bottom: 32, 256 right: 32, 257 }, 258 }, 259 h( 260 "svg", 261 { viewBox: "0 0 24 24", width: 48, height: 48 }, 262 h("path", { fill: "#76c4e5", d: LOGO_PATH }), 263 ), 264 ), 265 h( 266 "div", 267 { style: { display: "flex", flexDirection: "column", paddingRight: 80 } }, 268 ...lines.map((line) => renderLine(line, guideMargin)), 269 ), 270 ); 271} 272 273async function handleOgImage(searchParams) { 274 const did = searchParams.get("did"); 275 const collection = searchParams.get("collection"); 276 const rkey = searchParams.get("rkey"); 277 278 if (!did || !collection || !rkey) { 279 return new Response("Missing params", { status: 400 }); 280 } 281 282 const doc = await resolveDidDoc(did).catch(() => null); 283 const pdsUrl = doc ? pdsFromDoc(doc) : null; 284 if (!pdsUrl) { 285 return new Response("Could not resolve PDS", { status: 404 }); 286 } 287 288 const data = await fetchRecord(pdsUrl, did, collection, rkey).catch(() => null); 289 if (!data?.value) { 290 return new Response("Record not found", { status: 404 }); 291 } 292 293 const fonts = await getFonts(); 294 295 return new ImageResponse(OgImage({ record: data.value }), { 296 width: 1200, 297 height: 630, 298 module: wasmModule, 299 fonts, 300 format: "png", 301 }); 302} 303 304// ---- existing worker logic ---- 305 306const BOT_UAS = [ 307 "Discordbot", 308 "Twitterbot", 309 "facebookexternalhit", 310 "LinkedInBot", 311 "Slackbot-LinkExpanding", 312 "TelegramBot", 313 "WhatsApp", 314 "Iframely", 315 "Embedly", 316 "redditbot", 317 "Cardyb", 318]; 319 320function isBot(ua) { 321 return BOT_UAS.some((b) => ua.includes(b)); 322} 323 324function esc(s) { 325 return s 326 .replace(/&/g, "&amp;") 327 .replace(/</g, "&lt;") 328 .replace(/>/g, "&gt;") 329 .replace(/"/g, "&quot;"); 330} 331 332async function resolveDidDoc(did) { 333 let docUrl; 334 if (did.startsWith("did:plc:")) { 335 docUrl = `https://plc.directory/${did}`; 336 } else if (did.startsWith("did:web:")) { 337 const host = did.slice("did:web:".length); 338 docUrl = `https://${host}/.well-known/did.json`; 339 } else { 340 return null; 341 } 342 343 const res = await fetch(docUrl, { signal: AbortSignal.timeout(3000) }); 344 if (!res.ok) return null; 345 return res.json(); 346} 347 348function pdsFromDoc(doc) { 349 return doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 350} 351 352function handleFromDoc(doc) { 353 const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://")); 354 return aka ? aka.slice("at://".length) : null; 355} 356 357const STATIC_ROUTES = { 358 "/": { title: "PDSls", description: "Browse the public data on atproto" }, 359 "/jetstream": { 360 title: "Jetstream", 361 description: "A simplified event stream with support for collection and DID filtering.", 362 }, 363 "/firehose": { title: "Firehose", description: "The raw event stream from a relay or PDS." }, 364 "/spacedust": { 365 title: "Spacedust", 366 description: "A stream of links showing interactions across the network.", 367 }, 368 "/labels": { title: "Labels", description: "Query labels applied to accounts and records." }, 369 "/car": { 370 title: "CAR explorer", 371 description: "Upload an archive to explore or export its contents.", 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 483const MAX_FAVICON_SIZE = 100 * 1024; // 100KB 484 485async function corsProxy(url, fetchOpts = {}) { 486 const res = await fetch(url, { 487 signal: AbortSignal.timeout(5000), 488 ...fetchOpts, 489 }); 490 491 return new Response(res.body, { 492 status: res.status, 493 headers: { 494 "Content-Type": res.headers.get("content-type") ?? "application/json", 495 "Access-Control-Allow-Origin": "*", 496 }, 497 }); 498} 499 500function handleResolveDidWeb(searchParams) { 501 const host = searchParams.get("host"); 502 if (!host) return new Response("Missing host param", { status: 400 }); 503 return corsProxy(`https://${host}/.well-known/did.json`, { 504 redirect: "manual", 505 headers: { accept: "application/did+ld+json,application/json" }, 506 }); 507} 508 509function handleResolveHandleDns(searchParams) { 510 const handle = searchParams.get("handle"); 511 if (!handle) return new Response("Missing handle param", { status: 400 }); 512 const url = new URL("https://dns.google/resolve"); 513 url.searchParams.set("name", `_atproto.${handle}`); 514 url.searchParams.set("type", "TXT"); 515 return corsProxy(url, { headers: { accept: "application/dns-json" } }); 516} 517 518function handleResolveHandleHttp(searchParams) { 519 const handle = searchParams.get("handle"); 520 if (!handle) return new Response("Missing handle param", { status: 400 }); 521 return corsProxy(`https://${handle}/.well-known/atproto-did`, { redirect: "manual" }); 522} 523 524async function handleFavicon(searchParams) { 525 const domain = searchParams.get("domain"); 526 if (!domain) { 527 return new Response("Missing domain param", { status: 400 }); 528 } 529 530 let faviconUrl = null; 531 try { 532 const pageRes = await fetch(`https://${domain}/`, { 533 signal: AbortSignal.timeout(5000), 534 headers: { "User-Agent": "PDSls-Favicon/1.0" }, 535 redirect: "follow", 536 }); 537 538 if (pageRes.ok && (pageRes.headers.get("content-type") ?? "").includes("text/html")) { 539 let bestHref = null; 540 let bestPriority = -1; 541 let bestSize = 0; 542 543 const rewriter = new HTMLRewriter().on("link", { 544 element(el) { 545 const rel = (el.getAttribute("rel") ?? "").toLowerCase(); 546 if (!rel.includes("icon")) return; 547 const href = el.getAttribute("href"); 548 if (!href) return; 549 550 // Prefer icon with sizes > icon > apple-touch-icon > shortcut icon 551 let priority = 0; 552 if (rel === "icon" && el.getAttribute("sizes")) priority = 3; 553 else if (rel === "icon") priority = 2; 554 else if (rel === "apple-touch-icon") priority = 1; 555 556 const sizesAttr = el.getAttribute("sizes") ?? ""; 557 const size = Math.max(...sizesAttr.split(/\s+/).map((s) => parseInt(s) || 0), 0); 558 559 if ( 560 priority > bestPriority || 561 (priority === bestPriority && size > bestSize && size <= 64) 562 ) { 563 bestPriority = priority; 564 bestSize = size; 565 bestHref = href; 566 } 567 }, 568 }); 569 570 const transformed = rewriter.transform(pageRes); 571 await transformed.text(); 572 573 if (bestHref) { 574 try { 575 faviconUrl = new URL(bestHref, `https://${domain}/`).href; 576 } catch { 577 faviconUrl = null; 578 } 579 } 580 } 581 } catch {} 582 583 const fallbackUrl = `https://${domain}/favicon.ico`; 584 const urls = faviconUrl ? [faviconUrl, fallbackUrl] : [fallbackUrl]; 585 586 for (const url of urls) { 587 try { 588 const iconRes = await fetch(url, { 589 signal: AbortSignal.timeout(5000), 590 redirect: "follow", 591 }); 592 593 if (!iconRes.ok) continue; 594 595 const contentType = iconRes.headers.get("content-type") ?? ""; 596 if (contentType.includes("text/html") || contentType.includes("text/plain")) continue; 597 598 const contentLength = parseInt(iconRes.headers.get("content-length") ?? "0", 10); 599 if (contentLength > MAX_FAVICON_SIZE) { 600 return new Response("Favicon too large", { status: 413 }); 601 } 602 603 const body = await iconRes.arrayBuffer(); 604 if (body.byteLength > MAX_FAVICON_SIZE) { 605 return new Response("Favicon too large", { status: 413 }); 606 } 607 608 return new Response(body, { 609 headers: { 610 "Content-Type": contentType || "image/x-icon", 611 "Cache-Control": "public, max-age=86400", 612 "Access-Control-Allow-Origin": "*", 613 }, 614 }); 615 } catch { 616 continue; 617 } 618 } 619 620 return new Response("Favicon not found", { status: 404 }); 621} 622 623export default { 624 async fetch(request, env) { 625 const url = new URL(request.url); 626 627 if (url.pathname === "/og-image") { 628 return handleOgImage(url.searchParams).catch( 629 (err) => new Response(`Failed to generate image: ${err?.message ?? err}`, { status: 500 }), 630 ); 631 } 632 633 if (url.pathname === "/favicon") { 634 return handleFavicon(url.searchParams).catch( 635 (err) => new Response(`Failed to fetch favicon: ${err?.message ?? err}`, { status: 500 }), 636 ); 637 } 638 639 const proxyRoutes = { 640 "/resolve-did-web": handleResolveDidWeb, 641 "/resolve-handle-dns": handleResolveHandleDns, 642 "/resolve-handle-http": handleResolveHandleHttp, 643 }; 644 645 if (url.pathname in proxyRoutes) { 646 return proxyRoutes[url.pathname](url.searchParams).catch( 647 (err) => new Response(`Proxy error: ${err?.message ?? err}`, { status: 500 }), 648 ); 649 } 650 651 const ua = request.headers.get("user-agent") ?? ""; 652 653 if (!isBot(ua)) { 654 return env.ASSETS.fetch(request); 655 } 656 657 let ogData; 658 try { 659 ogData = await resolveOgData(url.pathname); 660 } catch { 661 return env.ASSETS.fetch(request); 662 } 663 664 const imageUrl = 665 ogData.generateImage ? 666 `${url.origin}/og-image?` + 667 new URLSearchParams({ did: ogData.did, collection: ogData.collection, rkey: ogData.rkey }) 668 : null; 669 670 const response = await env.ASSETS.fetch(request); 671 const contentType = response.headers.get("content-type") ?? ""; 672 if (!contentType.includes("text/html")) { 673 return response; 674 } 675 676 return new HTMLRewriter() 677 .on("meta", new OgTagRewriter(ogData, request.url)) 678 .on("head", new HeadEndRewriter(ogData, imageUrl)) 679 .transform(response); 680 }, 681};