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 ];
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, "&")
327 .replace(/</g, "<")
328 .replace(/>/g, ">")
329 .replace(/"/g, """);
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};