An in-browser wisp.place site explorer
1/**
2 * ATProto client utilities for fetching records and blobs
3 *
4 * This module provides browser-compatible utilities for ATProto operations.
5 */
6
7import { createLogger } from './logger';
8import type {
9 PlaceWispFsRecord,
10 PlaceWispSubfsRecord,
11 WispDirectory,
12} from '../types/lexicon';
13
14const logger = createLogger({ prefix: 'atproto' });
15
16/**
17 * Extract domain from a did:web DID
18 * @param did - The did:web DID (e.g., "did:web:example.com")
19 * @returns The domain (e.g., "example.com")
20 */
21function extractDomainFromDidWeb(did: string): string {
22 if (!did.startsWith('did:web:')) {
23 throw new Error(`Not a did:web: ${did}`);
24 }
25 const domain = did.slice('did:web:'.length);
26 // Convert escaped colons back to dots (e.g., "did:web:example%3Acom" -> "example.com")
27 return domain.replace(/%3A/gi, ':').replace(/%2F/gi, '/');
28}
29
30/**
31 * Resolve a handle to a DID using the Bluesky Identity API
32 */
33export async function resolveHandleToDid(handle: string): Promise<string> {
34 // Remove @ prefix if present
35 const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
36
37 logger.debug(`Resolving handle '${cleanHandle}' to DID`);
38
39 // Use Bluesky API for handle resolution
40 const bskyApiUrl = 'https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle';
41 const url = `${bskyApiUrl}?handle=${encodeURIComponent(cleanHandle)}`;
42
43 const response = await fetch(url);
44
45 if (!response.ok) {
46 // Try direct DID resolution for DIDs passed as input
47 if (cleanHandle.startsWith('did:')) {
48 // For did:web, verify by fetching from the domain's .well-known/did.json
49 if (cleanHandle.startsWith('did:web:')) {
50 logger.debug(`Input appears to be a did:web, verifying via .well-known/did.json`);
51 const domain = extractDomainFromDidWeb(cleanHandle);
52 const webUrl = `https://${domain}/.well-known/did.json`;
53 const webResponse = await fetch(webUrl);
54 if (!webResponse.ok) {
55 throw new Error(`Failed to verify did:web '${cleanHandle}': ${webResponse.statusText}`);
56 }
57 const didDocument = await webResponse.json() as { id?: string };
58 if (!didDocument?.id || didDocument.id !== cleanHandle) {
59 throw new Error(`Invalid DID document for '${cleanHandle}'`);
60 }
61 logger.debug(`Verified did:web: ${cleanHandle}`);
62 return cleanHandle;
63 }
64
65 // For other DIDs, try PLC directory
66 logger.debug(`Input appears to be a DID, using PLC directory`);
67 const plcUrl = import.meta.env.VITE_PLC_DIRECTORY || 'https://plc.directory';
68 const plcResponse = await fetch(`${plcUrl}/${cleanHandle}`);
69 if (!plcResponse.ok) {
70 throw new Error(`Failed to resolve DID '${cleanHandle}': ${plcResponse.statusText}`);
71 }
72 const didDocument = await plcResponse.json() as { id?: string };
73 if (!didDocument?.id || !didDocument.id.startsWith('did:')) {
74 throw new Error(`Invalid DID document for '${cleanHandle}'`);
75 }
76 logger.debug(`Resolved '${cleanHandle}' to DID: ${didDocument.id}`);
77 return didDocument.id;
78 }
79
80 throw new Error(`Failed to resolve handle '${cleanHandle}': ${response.statusText}`);
81 }
82
83 const data = await response.json() as { did?: string };
84
85 if (!data || !data.did || !data.did.startsWith('did:')) {
86 throw new Error(`Invalid response for handle '${cleanHandle}'`);
87 }
88
89 logger.debug(`Resolved '${cleanHandle}' to DID: ${data.did}`);
90 return data.did;
91}
92
93/**
94 * Extract PDS endpoint from a DID document
95 */
96export async function getPdsEndpoint(did: string): Promise<string> {
97 logger.debug(`Getting PDS endpoint for DID: ${did}`);
98
99 // Handle did:web by fetching from the domain's .well-known/did.json
100 if (did.startsWith('did:web:')) {
101 const domain = extractDomainFromDidWeb(did);
102 const webUrl = `https://${domain}/.well-known/did.json`;
103
104 logger.debug(`Fetching did:web document from: ${webUrl}`);
105
106 try {
107 const response = await fetch(webUrl);
108
109 if (!response.ok) {
110 throw new Error(`Failed to fetch did:web document from '${webUrl}': ${response.statusText}`);
111 }
112
113 const didDocument = (await response.json()) as {
114 service?: Array<{
115 id?: string;
116 type?: string;
117 serviceEndpoint?: string;
118 }>;
119 };
120 const services = didDocument?.service;
121
122 if (!Array.isArray(services)) {
123 throw new Error(`No services found in did:web document for '${did}'`);
124 }
125
126 for (const service of services) {
127 if (
128 service.id === '#atproto_pds' ||
129 service.type === 'AtprotoPersonalDataServer'
130 ) {
131 if (!service.serviceEndpoint) {
132 throw new Error(`PDS service found but no endpoint for DID '${did}'`);
133 }
134 logger.debug(`Found PDS endpoint for did:web: ${service.serviceEndpoint}`);
135 return service.serviceEndpoint;
136 }
137 }
138
139 throw new Error(`Could not find PDS endpoint in did:web document for '${did}'`);
140 } catch (error) {
141 logger.error(`Failed to get PDS endpoint for did:web '${did}'`, { error });
142 throw new Error(`Failed to get PDS endpoint for did:web '${did}': ${error}`);
143 }
144 }
145
146 // Try PLC directory for did:plc and other DIDs
147 const plcUrl = import.meta.env.VITE_PLC_DIRECTORY || 'https://plc.directory';
148 const url = `${plcUrl}/${did}`;
149
150 logger.debug(`Fetching DID document from PLC directory: ${url}`);
151
152 try {
153 const response = await fetch(url);
154
155 if (!response.ok) {
156 throw new Error(`Failed to fetch DID document: ${response.statusText}`);
157 }
158
159 const didDocument = (await response.json()) as {
160 service?: Array<{
161 id?: string;
162 type?: string;
163 serviceEndpoint?: string;
164 }>;
165 };
166 const services = didDocument?.service;
167
168 if (!Array.isArray(services)) {
169 throw new Error(`No services found in DID document for '${did}'`);
170 }
171
172 for (const service of services) {
173 if (
174 service.id === '#atproto_pds' ||
175 service.type === 'AtprotoPersonalDataServer'
176 ) {
177 if (!service.serviceEndpoint) {
178 throw new Error(`PDS service found but no endpoint for DID '${did}'`);
179 }
180 logger.debug(`Found PDS endpoint: ${service.serviceEndpoint}`);
181 return service.serviceEndpoint;
182 }
183 }
184
185 throw new Error(`Could not find PDS endpoint in DID document for '${did}'`);
186 } catch (error) {
187 logger.error(`Failed to get PDS endpoint for DID '${did}'`, { error });
188 throw new Error(`Failed to get PDS endpoint for DID '${did}': ${error}`);
189 }
190}
191
192/**
193 * Fetch a blob from PDS using XRPC
194 */
195export async function fetchBlob(
196 pdsUrl: string,
197 did: string,
198 cid: string
199): Promise<Uint8Array> {
200 const url = new URL(`${pdsUrl}/xrpc/com.atproto.sync.getBlob`);
201 url.searchParams.set('did', did);
202 url.searchParams.set('cid', cid);
203
204 logger.debug(`Fetching blob ${cid} from ${pdsUrl}`);
205
206 const response = await fetch(url.toString());
207
208 if (!response.ok) {
209 throw new Error(`Failed to fetch blob ${cid}: ${response.statusText}`);
210 }
211
212 const buffer = await response.arrayBuffer();
213 logger.debug(`Successfully fetched blob ${cid} (${buffer.byteLength} bytes)`);
214 return new Uint8Array(buffer);
215}
216
217/**
218 * Fetch place.wisp.fs records from PDS
219 */
220export async function fetchWispFsRecords(
221 pdsUrl: string,
222 did: string
223): Promise<Array<{ rkey: string; value: unknown }>> {
224 const records: Array<{ rkey: string; value: unknown }> = [];
225 let cursor: string | undefined = undefined;
226
227 logger.debug(`Fetching place.wisp.fs records for ${did}`);
228
229 do {
230 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`);
231 url.searchParams.set('repo', did);
232 url.searchParams.set('collection', 'place.wisp.fs');
233 url.searchParams.set('limit', '100');
234 if (cursor) {
235 url.searchParams.set('cursor', cursor);
236 }
237
238 const response = await fetch(url.toString());
239
240 if (!response.ok) {
241 throw new Error(
242 `Failed to list place.wisp.fs records: ${response.statusText}`
243 );
244 }
245
246 const data = (await response.json()) as {
247 records?: Array<{ uri: string; value: unknown }>;
248 cursor?: string;
249 };
250
251 if (!data.records) {
252 break;
253 }
254
255 for (const record of data.records) {
256 const rkey = record.uri.split('/').pop() || '';
257 records.push({ rkey, value: record.value });
258 }
259
260 cursor = data.cursor;
261 } while (cursor);
262
263 logger.debug(`Fetched ${records.length} place.wisp.fs records`);
264 return records;
265}
266
267/**
268 * Fetch place.wisp.subfs records from PDS
269 */
270export async function fetchWispSubfsRecords(
271 pdsUrl: string,
272 did: string
273): Promise<Array<{ rkey: string; value: unknown }>> {
274 const records: Array<{ rkey: string; value: unknown }> = [];
275 let cursor: string | undefined = undefined;
276
277 logger.debug(`Fetching place.wisp.subfs records for ${did}`);
278
279 do {
280 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`);
281 url.searchParams.set('repo', did);
282 url.searchParams.set('collection', 'place.wisp.subfs');
283 url.searchParams.set('limit', '100');
284 if (cursor) {
285 url.searchParams.set('cursor', cursor);
286 }
287
288 const response = await fetch(url.toString());
289
290 if (!response.ok) {
291 throw new Error(
292 `Failed to list place.wisp.subfs records: ${response.statusText}`
293 );
294 }
295
296 const data = (await response.json()) as {
297 records?: Array<{ uri: string; value: unknown }>;
298 cursor?: string;
299 };
300
301 if (!data.records) {
302 break;
303 }
304
305 for (const record of data.records) {
306 const rkey = record.uri.split('/').pop() || '';
307 records.push({ rkey, value: record.value });
308 }
309
310 cursor = data.cursor;
311 } while (cursor);
312
313 logger.debug(`Fetched ${records.length} place.wisp.subfs records`);
314 return records;
315}
316
317/**
318 * Site information from a wisp.fs record
319 */
320export interface WispSiteInfo {
321 rkey: string;
322 site: string;
323 fileCount?: number;
324 createdAt?: string;
325}
326
327/**
328 * Fetch all wisp.fs site records (returns metadata, not full content)
329 */
330export async function fetchWispSites(
331 pdsUrl: string,
332 did: string
333): Promise<WispSiteInfo[]> {
334 const fsRecords = await fetchWispFsRecords(pdsUrl, did);
335
336 if (fsRecords.length === 0) {
337 logger.warn('No wisp.fs records found');
338 return [];
339 }
340
341 const sites: WispSiteInfo[] = [];
342
343 for (const { rkey, value } of fsRecords) {
344 const parsed = value as PlaceWispFsRecord;
345 sites.push({
346 rkey,
347 site: parsed.site || rkey,
348 fileCount: parsed.fileCount,
349 createdAt: parsed.createdAt,
350 });
351 }
352
353 logger.debug(`Found ${sites.length} wisp site(s)`);
354 return sites;
355}
356
357/**
358 * Fetch manifest for a specific site by rkey
359 */
360export async function fetchWispSiteManifest(
361 pdsUrl: string,
362 did: string,
363 siteRkey: string
364): Promise<WispDirectory | null> {
365 // Fetch the specific wisp.fs record
366 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
367 url.searchParams.set('repo', did);
368 url.searchParams.set('collection', 'place.wisp.fs');
369 url.searchParams.set('rkey', siteRkey);
370
371 logger.debug(`Fetching wisp.fs record for site: ${siteRkey}`);
372
373 const response = await fetch(url.toString());
374
375 if (!response.ok) {
376 throw new Error(`Failed to fetch site '${siteRkey}': ${response.statusText}`);
377 }
378
379 const data = await response.json();
380 const parsed = data.value as PlaceWispFsRecord;
381
382 if (!parsed.root) {
383 logger.warn(`Site '${siteRkey}' has no root directory`);
384 return null;
385 }
386
387 // Import conversion function
388 const { convertDirectoryNewToOld, mergeDirectories } = await import('../types/lexicon');
389
390 // Convert new format to old format if needed
391 const rootAsAny = parsed.root as any;
392 const isNewFormat = rootAsAny && 'type' in rootAsAny && 'entries' in rootAsAny;
393 const rootDir = isNewFormat ? convertDirectoryNewToOld(rootAsAny) : (parsed.root as WispDirectory);
394
395 // Fetch related subfs records (for large sites)
396 const subfsRecords = await fetchWispSubfsRecords(pdsUrl, did);
397
398 // Build directories array (all should be old format)
399 const directories: WispDirectory[] = [rootDir];
400
401 for (const { value } of subfsRecords) {
402 const subfs = value as PlaceWispSubfsRecord;
403
404 // Check if directory is new format (has 'type' and 'entries')
405 const dir = subfs.directory as any;
406 const isNewFormat = dir && 'type' in dir && 'entries' in dir;
407
408 const subfsDir = isNewFormat
409 ? convertDirectoryNewToOld(dir)
410 : subfs.directory as WispDirectory;
411
412 directories.push(subfsDir);
413 }
414
415 // Merge directories
416 const merged = mergeDirectories(...directories);
417
418 logger.debug(`Fetched manifest for site '${siteRkey}' with ${directories.length} directory records`);
419 return merged;
420}
421
422/**
423 * Fetch and merge all wisp.fs and wisp.subfs records into a single directory
424 * @deprecated Use fetchWispSites and fetchWispSiteManifest for multi-site support
425 */
426export async function fetchWispManifest(
427 pdsUrl: string,
428 did: string
429): Promise<WispDirectory | null> {
430 const sites = await fetchWispSites(pdsUrl, did);
431
432 if (sites.length === 0) {
433 logger.warn('No wisp.fs records found');
434 return null;
435 }
436
437 // Use the first site if no specific site requested
438 const firstSite = sites[0];
439 logger.info(`Fetching manifest for site '${firstSite.site}' (first of ${sites.length} sites)`);
440
441 return fetchWispSiteManifest(pdsUrl, did, firstSite.rkey);
442}