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