An in-browser wisp.place site explorer
at main 442 lines 14 kB view raw
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}