atmosphere explorer pds.ls
tool typescript atproto
at main 687 lines 21 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, mr: 4 }); 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 - 2); 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", whiteSpace: "nowrap" } }, 191 ...guides, 192 ...line.segments.map((seg) => 193 h( 194 "div", 195 { style: { color: seg.color, ...(seg.mr ? { marginRight: seg.mr } : {}) } }, 196 seg.text, 197 ), 198 ), 199 ); 200} 201 202function OgImage({ record }) { 203 const lines = []; 204 for (const k of Object.keys(record)) { 205 if (lines.length >= MAX_LINES) break; 206 flattenJson(record[k], 0, lines, k, false, MAX_STRING_WIDTH); 207 } 208 if (lines.length >= MAX_LINES) { 209 lines.push({ depth: 0, segments: [{ text: "…", color: C.null }] }); 210 } 211 212 const availableHeight = 630 - 100; // height minus vertical padding 213 const fontSize = Math.min(32, Math.max(18, Math.floor(availableHeight / (lines.length * 1.5)))); 214 const guideMargin = Math.round(fontSize * 1.2) - 1; 215 216 // Re-truncate string values if the larger font size means fewer chars fit. 217 // Available width: 1200 canvas - 100 padding - 80 logo area - 200 for key/depth overhead; 218 // Roboto Mono char ≈ 0.6× fontSize. 219 const maxStrWidth = Math.floor((1200 - 100 - 80 - 200) / (fontSize * 0.6)); 220 if (maxStrWidth < MAX_STRING_WIDTH) { 221 for (const line of lines) { 222 for (const seg of line.segments) { 223 if (seg.color === C.string) { 224 seg.text = truncateToWidth(seg.text, maxStrWidth - 2); 225 } 226 } 227 } 228 } 229 230 return h( 231 "div", 232 { 233 style: { 234 display: "flex", 235 flexDirection: "column", 236 justifyContent: "center", 237 position: "relative", 238 width: "100%", 239 height: "100%", 240 background: "#1f1f1f", 241 padding: "50px 50px", 242 fontFamily: "Roboto Mono, Noto Sans JP, Noto Sans SC, Noto Sans KR, Noto Emoji", 243 fontSize, 244 lineHeight: 1.5, 245 color: "#e2e8f0", 246 }, 247 }, 248 h( 249 "div", 250 { 251 style: { 252 position: "absolute", 253 bottom: 32, 254 right: 32, 255 }, 256 }, 257 h( 258 "svg", 259 { viewBox: "0 0 24 24", width: 48, height: 48 }, 260 h("path", { fill: "#76c4e5", d: LOGO_PATH }), 261 ), 262 ), 263 h( 264 "div", 265 { style: { display: "flex", flexDirection: "column", paddingRight: 80 } }, 266 ...lines.map((line) => renderLine(line, guideMargin)), 267 ), 268 ); 269} 270 271async function handleOgImage(searchParams) { 272 const did = searchParams.get("did"); 273 const collection = searchParams.get("collection"); 274 const rkey = searchParams.get("rkey"); 275 276 if (!did || !collection || !rkey) { 277 return new Response("Missing params", { status: 400 }); 278 } 279 280 const doc = await resolveDidDoc(did).catch(() => null); 281 const pdsUrl = doc ? pdsFromDoc(doc) : null; 282 if (!pdsUrl) { 283 return new Response("Could not resolve PDS", { status: 404 }); 284 } 285 286 const data = await fetchRecord(pdsUrl, did, collection, rkey).catch(() => null); 287 if (!data?.value) { 288 return new Response("Record not found", { status: 404 }); 289 } 290 291 const fonts = await getFonts(); 292 293 return new ImageResponse(OgImage({ record: data.value }), { 294 width: 1200, 295 height: 630, 296 module: wasmModule, 297 fonts, 298 format: "png", 299 }); 300} 301 302// ---- existing worker logic ---- 303 304const BOT_UAS = [ 305 "Discordbot", 306 "Twitterbot", 307 "facebookexternalhit", 308 "LinkedInBot", 309 "Slackbot-LinkExpanding", 310 "TelegramBot", 311 "WhatsApp", 312 "Iframely", 313 "Embedly", 314 "redditbot", 315 "Cardyb", 316]; 317 318function isBot(ua) { 319 return BOT_UAS.some((b) => ua.includes(b)); 320} 321 322function esc(s) { 323 return s 324 .replace(/&/g, "&amp;") 325 .replace(/</g, "&lt;") 326 .replace(/>/g, "&gt;") 327 .replace(/"/g, "&quot;"); 328} 329 330async function resolveDidDoc(did) { 331 let docUrl; 332 if (did.startsWith("did:plc:")) { 333 docUrl = `https://plc.directory/${did}`; 334 } else if (did.startsWith("did:web:")) { 335 const host = did.slice("did:web:".length); 336 docUrl = `https://${host}/.well-known/did.json`; 337 } else { 338 return null; 339 } 340 341 const res = await fetch(docUrl, { signal: AbortSignal.timeout(3000) }); 342 if (!res.ok) return null; 343 return res.json(); 344} 345 346function pdsFromDoc(doc) { 347 return doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 348} 349 350function handleFromDoc(doc) { 351 const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://")); 352 return aka ? aka.slice("at://".length) : null; 353} 354 355const STATIC_ROUTES = { 356 "/": { title: "PDSls", description: "Browse the public data on atproto" }, 357 "/jetstream": { 358 title: "Jetstream", 359 description: "A simplified event stream with support for collection and DID filtering.", 360 }, 361 "/firehose": { title: "Firehose", description: "The raw event stream from a relay or PDS." }, 362 "/spacedust": { 363 title: "Spacedust", 364 description: "A stream of links showing interactions across the network.", 365 }, 366 "/labels": { title: "Labels", description: "Query labels applied to accounts and records." }, 367 "/car": { 368 title: "Archive tools", 369 description: "Tools for working with CAR (Content Addressable aRchive) files.", 370 }, 371 "/car/explore": { 372 title: "Explore archive", 373 description: "Upload a CAR file to explore its contents.", 374 }, 375 "/car/unpack": { 376 title: "Unpack archive", 377 description: "Upload a CAR file to extract all records into a ZIP archive.", 378 }, 379 "/settings": { title: "Settings", description: "Browse the public data on atproto" }, 380}; 381 382async function resolveOgData(pathname) { 383 if (pathname in STATIC_ROUTES) return STATIC_ROUTES[pathname]; 384 385 let title = "PDSls"; 386 let description = "Browse the public data on atproto"; 387 388 const segments = pathname.slice(1).split("/").filter(Boolean); 389 const isAtUrl = segments[0] === "at:"; 390 391 if (isAtUrl) { 392 // at://did[/collection[/rkey]] 393 const [, did, collection, rkey] = segments; 394 395 if (!did) { 396 // bare /at: — use defaults 397 } else if (!collection) { 398 const doc = await resolveDidDoc(did).catch(() => null); 399 const handle = doc ? handleFromDoc(doc) : null; 400 const pdsUrl = doc ? pdsFromDoc(doc) : null; 401 const pdsHost = pdsUrl ? pdsUrl.replace("https://", "").replace("http://", "") : null; 402 403 title = handle ? `${handle} (${did})` : did; 404 description = pdsHost ? `Hosted on ${pdsHost}` : `Repository for ${did}`; 405 } else if (!rkey) { 406 const doc = await resolveDidDoc(did).catch(() => null); 407 const handle = doc ? handleFromDoc(doc) : null; 408 title = `at://${handle ?? did}/${collection}`; 409 description = `List of ${collection} records from ${handle ?? did}`; 410 } else { 411 const doc = await resolveDidDoc(did).catch(() => null); 412 const handle = doc ? handleFromDoc(doc) : null; 413 description = ""; 414 title = `at://${handle ?? did}/${collection}/${rkey}`; 415 return { title, description, generateImage: true, did, collection, rkey }; 416 } 417 } else { 418 // /pds 419 const [pds] = segments; 420 if (pds) { 421 title = pds; 422 description = `Browse the repositories at ${pds}`; 423 } 424 } 425 426 return { title, description }; 427} 428 429class OgTagRewriter { 430 constructor(ogData, url) { 431 this.ogData = ogData; 432 this.url = url; 433 } 434 435 element(element) { 436 const property = element.getAttribute("property"); 437 const name = element.getAttribute("name"); 438 439 if ( 440 property === "og:title" || 441 property === "og:description" || 442 property === "og:url" || 443 property === "og:type" || 444 property === "og:site_name" || 445 property === "og:image" || 446 property === "description" || 447 name === "description" || 448 name === "twitter:card" || 449 name === "twitter:title" || 450 name === "twitter:description" || 451 name === "twitter:image" 452 ) { 453 element.remove(); 454 } 455 } 456} 457 458class HeadEndRewriter { 459 constructor(ogData, imageUrl) { 460 this.ogData = ogData; 461 this.imageUrl = imageUrl; 462 } 463 464 element(element) { 465 const t = esc(this.ogData.title); 466 const d = esc(this.ogData.description); 467 const i = this.imageUrl ? esc(this.imageUrl) : null; 468 469 const imageTags = 470 i ? 471 `\n <meta property="og:image" content="${i}" /> 472 <meta name="twitter:card" content="summary_large_image" /> 473 <meta name="twitter:image" content="${i}" />` 474 : `\n <meta name="twitter:card" content="summary" />`; 475 476 element.append( 477 `<meta property="og:title" content="${t}" /> 478 <meta property="og:type" content="website" /> 479 <meta property="og:description" content="${d}" /> 480 <meta property="og:site_name" content="PDSls" /> 481 <meta name="description" content="${d}" /> 482 <meta name="twitter:title" content="${t}" /> 483 <meta name="twitter:description" content="${d}" />${imageTags}`, 484 { html: true }, 485 ); 486 } 487} 488 489const MAX_FAVICON_SIZE = 100 * 1024; // 100KB 490 491async function corsProxy(url, fetchOpts = {}) { 492 const res = await fetch(url, { 493 signal: AbortSignal.timeout(5000), 494 ...fetchOpts, 495 }); 496 497 return new Response(res.body, { 498 status: res.status, 499 headers: { 500 "Content-Type": res.headers.get("content-type") ?? "application/json", 501 "Access-Control-Allow-Origin": "*", 502 }, 503 }); 504} 505 506function handleResolveDidWeb(searchParams) { 507 const host = searchParams.get("host"); 508 if (!host) return new Response("Missing host param", { status: 400 }); 509 return corsProxy(`https://${host}/.well-known/did.json`, { 510 redirect: "manual", 511 headers: { accept: "application/did+ld+json,application/json" }, 512 }); 513} 514 515function handleResolveHandleDns(searchParams) { 516 const handle = searchParams.get("handle"); 517 if (!handle) return new Response("Missing handle param", { status: 400 }); 518 const url = new URL("https://dns.google/resolve"); 519 url.searchParams.set("name", `_atproto.${handle}`); 520 url.searchParams.set("type", "TXT"); 521 return corsProxy(url, { headers: { accept: "application/dns-json" } }); 522} 523 524function handleResolveHandleHttp(searchParams) { 525 const handle = searchParams.get("handle"); 526 if (!handle) return new Response("Missing handle param", { status: 400 }); 527 return corsProxy(`https://${handle}/.well-known/atproto-did`, { redirect: "manual" }); 528} 529 530async function handleFavicon(searchParams) { 531 const domain = searchParams.get("domain"); 532 if (!domain) { 533 return new Response("Missing domain param", { status: 400 }); 534 } 535 536 let faviconUrl = null; 537 try { 538 const pageRes = await fetch(`https://${domain}/`, { 539 signal: AbortSignal.timeout(5000), 540 headers: { "User-Agent": "PDSls-Favicon/1.0" }, 541 redirect: "follow", 542 }); 543 544 if (pageRes.ok && (pageRes.headers.get("content-type") ?? "").includes("text/html")) { 545 let bestHref = null; 546 let bestPriority = -1; 547 let bestSize = 0; 548 549 const rewriter = new HTMLRewriter().on("link", { 550 element(el) { 551 const rel = (el.getAttribute("rel") ?? "").toLowerCase(); 552 if (!rel.includes("icon")) return; 553 const href = el.getAttribute("href"); 554 if (!href) return; 555 556 // Prefer icon with sizes > icon > apple-touch-icon > shortcut icon 557 let priority = 0; 558 if (rel === "icon" && el.getAttribute("sizes")) priority = 3; 559 else if (rel === "icon") priority = 2; 560 else if (rel === "apple-touch-icon") priority = 1; 561 562 const sizesAttr = el.getAttribute("sizes") ?? ""; 563 const size = Math.max(...sizesAttr.split(/\s+/).map((s) => parseInt(s) || 0), 0); 564 565 if ( 566 priority > bestPriority || 567 (priority === bestPriority && size > bestSize && size <= 64) 568 ) { 569 bestPriority = priority; 570 bestSize = size; 571 bestHref = href; 572 } 573 }, 574 }); 575 576 const transformed = rewriter.transform(pageRes); 577 await transformed.text(); 578 579 if (bestHref) { 580 try { 581 faviconUrl = new URL(bestHref, `https://${domain}/`).href; 582 } catch { 583 faviconUrl = null; 584 } 585 } 586 } 587 } catch {} 588 589 const fallbackUrl = `https://${domain}/favicon.ico`; 590 const urls = faviconUrl ? [faviconUrl, fallbackUrl] : [fallbackUrl]; 591 592 for (const url of urls) { 593 try { 594 const iconRes = await fetch(url, { 595 signal: AbortSignal.timeout(5000), 596 redirect: "follow", 597 }); 598 599 if (!iconRes.ok) continue; 600 601 const contentType = iconRes.headers.get("content-type") ?? ""; 602 if (contentType.includes("text/html") || contentType.includes("text/plain")) continue; 603 604 const contentLength = parseInt(iconRes.headers.get("content-length") ?? "0", 10); 605 if (contentLength > MAX_FAVICON_SIZE) { 606 return new Response("Favicon too large", { status: 413 }); 607 } 608 609 const body = await iconRes.arrayBuffer(); 610 if (body.byteLength > MAX_FAVICON_SIZE) { 611 return new Response("Favicon too large", { status: 413 }); 612 } 613 614 return new Response(body, { 615 headers: { 616 "Content-Type": contentType || "image/x-icon", 617 "Cache-Control": "public, max-age=86400", 618 "Access-Control-Allow-Origin": "*", 619 }, 620 }); 621 } catch { 622 continue; 623 } 624 } 625 626 return new Response("Favicon not found", { status: 404 }); 627} 628 629export default { 630 async fetch(request, env) { 631 const url = new URL(request.url); 632 633 if (url.pathname === "/og-image") { 634 return handleOgImage(url.searchParams).catch( 635 (err) => new Response(`Failed to generate image: ${err?.message ?? err}`, { status: 500 }), 636 ); 637 } 638 639 if (url.pathname === "/favicon") { 640 return handleFavicon(url.searchParams).catch( 641 (err) => new Response(`Failed to fetch favicon: ${err?.message ?? err}`, { status: 500 }), 642 ); 643 } 644 645 const proxyRoutes = { 646 "/resolve-did-web": handleResolveDidWeb, 647 "/resolve-handle-dns": handleResolveHandleDns, 648 "/resolve-handle-http": handleResolveHandleHttp, 649 }; 650 651 if (url.pathname in proxyRoutes) { 652 return proxyRoutes[url.pathname](url.searchParams).catch( 653 (err) => new Response(`Proxy error: ${err?.message ?? err}`, { status: 500 }), 654 ); 655 } 656 657 const ua = request.headers.get("user-agent") ?? ""; 658 659 if (!isBot(ua)) { 660 return env.ASSETS.fetch(request); 661 } 662 663 let ogData; 664 try { 665 ogData = await resolveOgData(url.pathname); 666 } catch { 667 return env.ASSETS.fetch(request); 668 } 669 670 const imageUrl = 671 ogData.generateImage ? 672 `${url.origin}/og-image?` + 673 new URLSearchParams({ did: ogData.did, collection: ogData.collection, rkey: ogData.rkey }) 674 : null; 675 676 const response = await env.ASSETS.fetch(request); 677 const contentType = response.headers.get("content-type") ?? ""; 678 if (!contentType.includes("text/html")) { 679 return response; 680 } 681 682 return new HTMLRewriter() 683 .on("meta", new OgTagRewriter(ogData, request.url)) 684 .on("head", new HeadEndRewriter(ogData, imageUrl)) 685 .transform(response); 686 }, 687};