atmosphere explorer
pds.ls
tool
typescript
atproto
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, "&")
325 .replace(/</g, "<")
326 .replace(/>/g, ">")
327 .replace(/"/g, """);
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};