forked from
pds.ls/pdsls
atmosphere explorer
1import { Client, simpleFetchHandler } from "@atcute/client";
2import { DidDocument, getPdsEndpoint } from "@atcute/identity";
3import { lexiconDoc } from "@atcute/lexicon-doc";
4import { RecordValidator } from "@atcute/lexicon-doc/validations";
5import { FailedLexiconResolutionError, ResolvedSchema } from "@atcute/lexicon-resolver";
6import { ActorIdentifier, is, Nsid } from "@atcute/lexicons";
7import { AtprotoDid, Did, isNsid } from "@atcute/lexicons/syntax";
8import { verifyRecord } from "@atcute/repo";
9import { Title } from "@solidjs/meta";
10import { A, useLocation, useNavigate, useParams } from "@solidjs/router";
11import { createResource, createSignal, ErrorBoundary, For, Show, Suspense } from "solid-js";
12import { agent } from "../auth/state";
13import { Backlinks } from "../components/backlinks.jsx";
14import { Button } from "../components/button.jsx";
15import { RecordEditor, setPlaceholder } from "../components/create";
16import {
17 CopyMenu,
18 DropdownMenu,
19 MenuProvider,
20 MenuSeparator,
21 NavMenu,
22} from "../components/dropdown.jsx";
23import { Favicon } from "../components/favicon.jsx";
24import { JSONValue } from "../components/json.jsx";
25import { LexiconSchemaView } from "../components/lexicon-schema.jsx";
26import { Modal } from "../components/modal.jsx";
27import { pds } from "../components/navbar.jsx";
28import { addNotification, removeNotification } from "../components/notification.jsx";
29import { PermissionButton } from "../components/permission-button.jsx";
30import {
31 didDocumentResolver,
32 resolveLexiconAuthority,
33 resolveLexiconSchema,
34 resolvePDS,
35} from "../utils/api.js";
36import { clearCollectionCache } from "../utils/route-cache.js";
37import { AtUri, uriTemplates } from "../utils/templates.js";
38import { lexicons } from "../utils/types/lexicons.js";
39
40const toAuthority = (hostname: string) => hostname.split(".").reverse().join(".");
41
42const faviconWrapper = (children: any) => (
43 <div class="flex size-4 items-center justify-center">{children}</div>
44);
45
46const bskyAltClients = [
47 {
48 label: "Blacksky",
49 hostname: "blacksky.app",
50 transform: (url: string) => url.replace("https://bsky.app", "https://blacksky.community"),
51 },
52 {
53 label: "Witchsky",
54 hostname: "witchsky.app",
55 transform: (url: string) => url.replace("https://bsky.app", "https://witchsky.app"),
56 },
57 {
58 label: "Anartia",
59 hostname: "kelinci.net",
60 icon: "https://kelinci.net/rabbit.svg",
61 transform: (url: string) =>
62 url
63 .replace("https://bsky.app/profile", "https://anartia.kelinci.net")
64 .replace("/post/", "/")
65 .replace("/feed/", "/feeds/"),
66 },
67];
68
69const authorityCache = new Map<string, Promise<AtprotoDid>>();
70const documentCache = new Map<string, Promise<DidDocument>>();
71const schemaCache = new Map<string, Promise<unknown>>();
72
73const getAuthoritySegment = (nsid: string): string => {
74 const segments = nsid.split(".");
75 return segments.slice(0, -1).join(".");
76};
77
78const resolveSchema = async (authority: AtprotoDid, nsid: Nsid): Promise<unknown> => {
79 const cacheKey = `${authority}:${nsid}`;
80
81 let cachedSchema = schemaCache.get(cacheKey);
82 if (cachedSchema) {
83 return cachedSchema;
84 }
85
86 const schemaPromise = (async () => {
87 let didDocPromise = documentCache.get(authority);
88 if (!didDocPromise) {
89 didDocPromise = didDocumentResolver().resolve(authority);
90 documentCache.set(authority, didDocPromise);
91 }
92
93 const didDocument = await didDocPromise;
94 const pdsEndpoint = getPdsEndpoint(didDocument);
95
96 if (!pdsEndpoint) {
97 throw new FailedLexiconResolutionError(nsid, {
98 cause: new TypeError(`no pds service in did document; did=${authority}`),
99 });
100 }
101
102 const rpc = new Client({ handler: simpleFetchHandler({ service: pdsEndpoint }) });
103 const response = await rpc.get("com.atproto.repo.getRecord", {
104 params: {
105 repo: authority,
106 collection: "com.atproto.lexicon.schema",
107 rkey: nsid,
108 },
109 });
110
111 if (!response.ok) {
112 throw new Error(`got http ${response.status}`);
113 }
114
115 return response.data.value;
116 })();
117
118 schemaCache.set(cacheKey, schemaPromise);
119
120 try {
121 return await schemaPromise;
122 } catch (err) {
123 schemaCache.delete(cacheKey);
124 throw err;
125 }
126};
127
128const extractRefs = (obj: any): Nsid[] => {
129 const refs: Set<string> = new Set();
130
131 const traverse = (value: any) => {
132 if (!value || typeof value !== "object") return;
133
134 if (value.type === "ref" && value.ref) {
135 const ref = value.ref;
136 if (!ref.startsWith("#")) {
137 const nsid = ref.split("#")[0];
138 if (isNsid(nsid)) refs.add(nsid);
139 }
140 }
141
142 if (value.type === "union" && Array.isArray(value.refs)) {
143 for (const ref of value.refs) {
144 if (!ref.startsWith("#")) {
145 const nsid = ref.split("#")[0];
146 if (isNsid(nsid)) refs.add(nsid);
147 }
148 }
149 }
150
151 if (Array.isArray(value)) value.forEach(traverse);
152 else Object.values(value).forEach(traverse);
153 };
154
155 traverse(obj);
156 return Array.from(refs) as Nsid[];
157};
158
159const resolveAllLexicons = async (
160 nsid: Nsid,
161 depth: number = 0,
162 resolved: Map<string, any> = new Map(),
163 failed: Set<string> = new Set(),
164 inFlight: Map<string, Promise<void>> = new Map(),
165): Promise<{ resolved: Map<string, any>; failed: Set<string> }> => {
166 if (depth >= 10) {
167 console.warn(`Maximum recursion depth reached for ${nsid}`);
168 return { resolved, failed };
169 }
170
171 if (resolved.has(nsid) || failed.has(nsid)) return { resolved, failed };
172
173 if (inFlight.has(nsid)) {
174 await inFlight.get(nsid);
175 return { resolved, failed };
176 }
177
178 const fetchPromise = (async () => {
179 let authority: AtprotoDid | undefined;
180 const authoritySegment = getAuthoritySegment(nsid);
181 try {
182 let authorityPromise = authorityCache.get(authoritySegment);
183 if (!authorityPromise) {
184 authorityPromise = resolveLexiconAuthority(nsid);
185 authorityCache.set(authoritySegment, authorityPromise);
186 }
187
188 authority = await authorityPromise;
189 const schema = await resolveSchema(authority, nsid);
190
191 resolved.set(nsid, schema);
192
193 const refs = extractRefs(schema);
194
195 if (refs.length > 0) {
196 await Promise.all(
197 refs.map((ref) => resolveAllLexicons(ref, depth + 1, resolved, failed, inFlight)),
198 );
199 }
200 } catch (err) {
201 console.error(`Failed to resolve lexicon ${nsid}:`, err);
202 failed.add(nsid);
203 authorityCache.delete(authoritySegment);
204 if (authority) {
205 documentCache.delete(authority);
206 }
207 } finally {
208 inFlight.delete(nsid);
209 }
210 })();
211
212 inFlight.set(nsid, fetchPromise);
213 await fetchPromise;
214
215 return { resolved, failed };
216};
217
218export const RecordView = () => {
219 const location = useLocation();
220 const navigate = useNavigate();
221 const params = useParams();
222 const [openDelete, setOpenDelete] = createSignal(false);
223 const [showAlternates, setShowAlternates] = createSignal(false);
224 const [verifyError, setVerifyError] = createSignal("");
225 const [validationError, setValidationError] = createSignal("");
226 const [externalLink, setExternalLink] = createSignal<
227 { label: string; link: string; icon?: string } | undefined
228 >();
229 const [lexiconAuthority, setLexiconAuthority] = createSignal<AtprotoDid>();
230 const [validRecord, setValidRecord] = createSignal<boolean | undefined>(undefined);
231 const [validSchema, setValidSchema] = createSignal<boolean | undefined>(undefined);
232 const [schema, setSchema] = createSignal<ResolvedSchema>();
233 const [lexiconNotFound, setLexiconNotFound] = createSignal<boolean>();
234 const [remoteValidation, setRemoteValidation] = createSignal<boolean>();
235 const did = params.repo;
236 let rpc: Client;
237
238 const fetchRecord = async () => {
239 setValidRecord(undefined);
240 setValidSchema(undefined);
241 const pds = await resolvePDS(did!);
242 rpc = new Client({ handler: simpleFetchHandler({ service: pds }) });
243 const res = await rpc.get("com.atproto.repo.getRecord", {
244 params: {
245 repo: did as ActorIdentifier,
246 collection: params.collection as `${string}.${string}.${string}`,
247 rkey: params.rkey!,
248 },
249 });
250 if (!res.ok) {
251 setValidRecord(false);
252 setVerifyError(res.data.error);
253 throw new Error(res.data.error);
254 }
255 setPlaceholder(res.data.value);
256 setExternalLink(checkUri(res.data.uri, res.data.value));
257 resolveLexicon(params.collection as Nsid);
258 verifyRecordIntegrity();
259 validateLocalSchema(res.data.value);
260
261 return res.data;
262 };
263
264 const [record, { refetch }] = createResource(fetchRecord);
265
266 const validateLocalSchema = async (record: Record<string, unknown>) => {
267 try {
268 if (params.collection === "com.atproto.lexicon.schema") {
269 setLexiconNotFound(false);
270 lexiconDoc.parse(record, { mode: "passthrough" });
271 setValidSchema(true);
272 } else if (params.collection && params.collection in lexicons) {
273 if (is(lexicons[params.collection], record)) setValidSchema(true);
274 else setValidSchema(false);
275 }
276 } catch (err: any) {
277 console.error("Schema validation error:", err);
278 setValidSchema(false);
279 setValidationError(err.message || String(err));
280 }
281 };
282
283 const validateRemoteSchema = async (record: Record<string, unknown>) => {
284 try {
285 setRemoteValidation(true);
286 const { resolved, failed } = await resolveAllLexicons(params.collection as Nsid);
287
288 if (failed.size > 0) {
289 console.error(`Failed to resolve ${failed.size} documents:`, Array.from(failed));
290 setValidSchema(false);
291 setValidationError(`Unable to resolve lexicon documents: ${Array.from(failed).join(", ")}`);
292 return;
293 }
294
295 const lexiconDocs = Object.fromEntries(resolved);
296
297 const validator = new RecordValidator(lexiconDocs, params.collection as Nsid);
298 validator.parse({
299 key: params.rkey ?? null,
300 object: record,
301 });
302
303 setValidSchema(true);
304 } catch (err: any) {
305 console.error("Schema validation error:", err);
306 setValidSchema(false);
307 setValidationError(err.message || String(err));
308 }
309 setRemoteValidation(false);
310 };
311
312 const verifyRecordIntegrity = async () => {
313 try {
314 const { ok, data } = await rpc.get("com.atproto.sync.getRecord", {
315 params: {
316 did: did as Did,
317 collection: params.collection as Nsid,
318 rkey: params.rkey!,
319 },
320 as: "bytes",
321 });
322 if (!ok) throw data.error;
323
324 await verifyRecord({
325 did: did as AtprotoDid,
326 collection: params.collection!,
327 rkey: params.rkey!,
328 carBytes: data as Uint8Array<ArrayBufferLike>,
329 });
330
331 setValidRecord(true);
332 } catch (err: any) {
333 console.error("Record verification error:", err);
334 setVerifyError(err.message);
335 setValidRecord(false);
336 }
337 };
338
339 const resolveLexicon = async (nsid: Nsid) => {
340 try {
341 const authority = await resolveLexiconAuthority(nsid);
342 setLexiconAuthority(authority);
343 if (params.collection !== "com.atproto.lexicon.schema") {
344 const schema = await resolveLexiconSchema(authority, nsid);
345 setSchema(schema);
346 setLexiconNotFound(false);
347 }
348 } catch {
349 setLexiconNotFound(true);
350 }
351 };
352
353 const deleteRecord = async () => {
354 rpc = new Client({ handler: agent()! });
355 await rpc.post("com.atproto.repo.deleteRecord", {
356 input: {
357 repo: params.repo as ActorIdentifier,
358 collection: params.collection as `${string}.${string}.${string}`,
359 rkey: params.rkey!,
360 },
361 });
362 const id = addNotification({
363 message: "Record deleted",
364 type: "success",
365 });
366 setTimeout(() => removeNotification(id), 3000);
367 clearCollectionCache(`${params.pds}/${params.repo}/${params.collection}`);
368 navigate(`/at://${params.repo}/${params.collection}`);
369 };
370
371 const checkUri = (uri: string, record: any) => {
372 const uriParts = uri.split("/"); // expected: ["at:", "", "repo", "collection", "rkey"]
373 if (uriParts.length != 5) return undefined;
374 if (uriParts[0] !== "at:" || uriParts[1] !== "") return undefined;
375 const parsedUri: AtUri = { repo: uriParts[2], collection: uriParts[3], rkey: uriParts[4] };
376 const template = uriTemplates[parsedUri.collection];
377 if (!template) return undefined;
378 return template(parsedUri, record);
379 };
380
381 const RecordTab = (props: {
382 tab: "record" | "backlinks" | "info" | "schema";
383 label: string;
384 error?: boolean;
385 }) => {
386 const isActive = () => {
387 if (!location.hash && props.tab === "record") return true;
388 if (location.hash === `#${props.tab}`) return true;
389 if (props.tab === "schema" && location.hash.startsWith("#schema:")) return true;
390 return false;
391 };
392
393 return (
394 <div class="flex items-center gap-0.5">
395 <A
396 classList={{
397 "border-b-2 font-medium transition-colors": true,
398 "border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-300/80":
399 !isActive(),
400 }}
401 href={`/at://${did}/${params.collection}/${params.rkey}#${props.tab}`}
402 >
403 {props.label}
404 </A>
405 <Show when={props.error && (validRecord() === false || validSchema() === false)}>
406 <span class="iconify lucide--x text-red-500 dark:text-red-400"></span>
407 </Show>
408 </div>
409 );
410 };
411
412 return (
413 <>
414 <Title>
415 {params.collection}/{params.rkey} - PDSls
416 </Title>
417 <ErrorBoundary
418 fallback={(err) => (
419 <div class="flex w-full flex-col items-center gap-1 px-2 py-4">
420 <span class="font-semibold text-red-500 dark:text-red-400">Error loading record</span>
421 <div class="max-w-full text-sm wrap-break-word text-neutral-600 dark:text-neutral-400">
422 {err.message}
423 </div>
424 </div>
425 )}
426 >
427 <Show when={record()} keyed>
428 <div class="flex w-full flex-col items-center">
429 <div class="mb-3 flex w-full justify-between px-2 text-sm sm:text-base">
430 <div class="flex items-center gap-3 sm:gap-4">
431 <RecordTab tab="record" label="Record" />
432 <RecordTab tab="schema" label="Schema" />
433 <RecordTab tab="backlinks" label="Backlinks" />
434 <RecordTab tab="info" label="Info" error />
435 </div>
436 <div class="flex sm:gap-0.5">
437 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}>
438 <RecordEditor
439 create={false}
440 record={record()?.value}
441 refetch={refetch}
442 scope="update"
443 />
444 <PermissionButton
445 scope="delete"
446 tooltip="Delete"
447 onClick={() => setOpenDelete(true)}
448 >
449 <span class="iconify lucide--trash-2"></span>
450 </PermissionButton>
451 <Modal
452 open={openDelete()}
453 onClose={() => setOpenDelete(false)}
454 contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700"
455 >
456 <h2 class="mb-2 font-semibold">Delete this record?</h2>
457 <div class="flex justify-end gap-2">
458 <Button onClick={() => setOpenDelete(false)}>Cancel</Button>
459 <Button
460 onClick={deleteRecord}
461 classList={{
462 "bg-red-500! border-none! text-white! hover:bg-red-400! active:bg-red-400!": true,
463 }}
464 >
465 Delete
466 </Button>
467 </div>
468 </Modal>
469 </Show>
470 <Show when={externalLink()}>
471 {(link) => {
472 const bskyAlts = () =>
473 link().link.startsWith("https://bsky.app") ?
474 bskyAltClients.map((alt) => ({ ...alt, link: alt.transform(link().link) }))
475 : [];
476 return (
477 <div
478 class="relative"
479 onMouseEnter={() => setShowAlternates(true)}
480 onMouseLeave={() => setShowAlternates(false)}
481 >
482 <a
483 href={link().link}
484 target="_blank"
485 title={`Open on ${link().label}`}
486 class="flex p-1.5"
487 classList={{
488 "rounded-sm hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600":
489 !bskyAlts().length,
490 "bg-neutral-50 rounded-t dark:bg-dark-200 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600":
491 showAlternates() && bskyAlts().length > 0,
492 }}
493 >
494 <Favicon
495 authority={toAuthority(new URL(link().link).hostname)}
496 wrapper={faviconWrapper}
497 />
498 </a>
499 <Show when={showAlternates() && bskyAlts().length > 0}>
500 <div class="dark:bg-dark-200 absolute top-full left-0 z-10 flex flex-col overflow-hidden rounded-b bg-neutral-50 shadow-xs">
501 <For each={bskyAlts()}>
502 {(alt) => (
503 <a
504 href={alt.link}
505 target="_blank"
506 title={`Open on ${alt.label}`}
507 class="flex p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"
508 >
509 {alt.icon ?
510 <img src={alt.icon} class="size-4" />
511 : <Favicon
512 authority={toAuthority(alt.hostname)}
513 wrapper={faviconWrapper}
514 />
515 }
516 </a>
517 )}
518 </For>
519 </div>
520 </Show>
521 </div>
522 );
523 }}
524 </Show>
525 <MenuProvider>
526 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">
527 <CopyMenu
528 content={JSON.stringify(record()?.value, null, 2)}
529 label="Copy record"
530 icon="lucide--copy"
531 />
532 <CopyMenu
533 content={`at://${params.repo}/${params.collection}/${params.rkey}`}
534 label="Copy AT URI"
535 icon="lucide--copy"
536 />
537 <Show when={record()?.cid}>
538 {(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />}
539 </Show>
540 <MenuSeparator />
541 <NavMenu
542 href={`https://${pds()}/xrpc/com.atproto.repo.getRecord?repo=${params.repo}&collection=${params.collection}&rkey=${params.rkey}`}
543 icon="lucide--external-link"
544 label="Record on PDS"
545 newTab
546 />
547 </DropdownMenu>
548 </MenuProvider>
549 </div>
550 </div>
551 <Show when={!location.hash || location.hash === "#record"}>
552 <div class="w-full max-w-screen min-w-full px-2 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-max sm:text-sm md:max-w-3xl">
553 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} />
554 </div>
555 </Show>
556 <Show when={location.hash === "#schema" || location.hash.startsWith("#schema:")}>
557 <Show when={lexiconNotFound() === true}>
558 <span class="w-full px-2 text-sm">Lexicon schema could not be resolved.</span>
559 </Show>
560 <Show when={lexiconNotFound() === undefined}>
561 <span class="w-full px-2 text-sm">Resolving lexicon schema...</span>
562 </Show>
563 <Show when={schema() || params.collection === "com.atproto.lexicon.schema"}>
564 <ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
565 <LexiconSchemaView
566 schema={schema()?.rawSchema ?? (record()?.value as any)}
567 authority={lexiconAuthority()}
568 />
569 </ErrorBoundary>
570 </Show>
571 </Show>
572 <Show when={location.hash === "#backlinks"}>
573 <ErrorBoundary
574 fallback={(err) => <div class="wrap-break-word">Error: {err.message}</div>}
575 >
576 <Suspense
577 fallback={
578 <div class="iconify lucide--loader-circle animate-spin self-center text-xl" />
579 }
580 >
581 <div class="w-full px-2">
582 <Backlinks target={`at://${did}/${params.collection}/${params.rkey}`} />
583 </div>
584 </Suspense>
585 </ErrorBoundary>
586 </Show>
587 <Show when={location.hash === "#info"}>
588 <div class="flex w-full flex-col gap-3 px-2">
589 <div>
590 <p class="font-semibold">AT URI</p>
591 <div class="truncate text-xs text-neutral-700 dark:text-neutral-300">
592 {record()?.uri}
593 </div>
594 </div>
595 <Show when={record()?.cid}>
596 <div>
597 <p class="font-semibold">CID</p>
598 <div
599 class="truncate text-left text-xs text-neutral-700 dark:text-neutral-300"
600 dir="rtl"
601 >
602 {record()?.cid}
603 </div>
604 </div>
605 </Show>
606 <div>
607 <div class="flex items-center gap-1">
608 <p class="font-semibold">Record verification</p>
609 <span
610 classList={{
611 "iconify lucide--check text-green-500 dark:text-green-400":
612 validRecord() === true,
613 "iconify lucide--x text-red-500 dark:text-red-400": validRecord() === false,
614 "iconify lucide--loader-circle animate-spin": validRecord() === undefined,
615 }}
616 ></span>
617 </div>
618 <Show when={validRecord() === false}>
619 <div class="text-xs wrap-break-word">{verifyError()}</div>
620 </Show>
621 </div>
622 <div>
623 <div class="flex items-center gap-1">
624 <p class="font-semibold">Schema validation</p>
625 <span
626 classList={{
627 "iconify lucide--check text-green-500 dark:text-green-400":
628 validSchema() === true,
629 "iconify lucide--x text-red-500 dark:text-red-400": validSchema() === false,
630 "iconify lucide--loader-circle animate-spin":
631 validSchema() === undefined && remoteValidation(),
632 }}
633 ></span>
634 </div>
635 <Show when={validSchema() === false}>
636 <div class="text-xs wrap-break-word">{validationError()}</div>
637 </Show>
638 <Show
639 when={
640 !remoteValidation() &&
641 validSchema() === undefined &&
642 params.collection &&
643 !(params.collection in lexicons)
644 }
645 >
646 <Button onClick={() => validateRemoteSchema(record()!.value)}>
647 Validate via resolution
648 </Button>
649 </Show>
650 </div>
651 </div>
652 </Show>
653 </div>
654 </Show>
655 </ErrorBoundary>
656 </>
657 );
658};