replies timeline only, appview-less bluesky client
1import { err, map, ok, type Result } from '$lib/result'; 2import { ComAtprotoIdentityResolveIdentity, ComAtprotoRepoGetRecord } from '@atcute/atproto'; 3import { Client as AtcuteClient, CredentialManager } from '@atcute/client'; 4import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; 5import type { ActorIdentifier, AtprotoDid, Nsid, RecordKey } from '@atcute/lexicons/syntax'; 6import type { 7 InferXRPCBodyOutput, 8 ObjectSchema, 9 RecordKeySchema, 10 RecordSchema, 11 XRPCQueryMetadata 12} from '@atcute/lexicons/validations'; 13import * as v from '@atcute/lexicons/validations'; 14import { LRUCache } from 'lru-cache'; 15 16export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', { 17 params: v.object({ 18 identifier: v.actorIdentifierString() 19 }), 20 output: { 21 type: 'lex', 22 schema: v.object({ 23 did: v.didString(), 24 handle: v.handleString(), 25 pds: v.genericUriString(), 26 signing_key: v.string() 27 }) 28 } 29}); 30export type MiniDoc = InferOutput<typeof MiniDocQuery.output.schema>; 31 32const cacheTtl = 1000 * 60 * 60 * 24; 33const handleCache = new LRUCache<Handle, AtprotoDid>({ 34 max: 1000, 35 ttl: cacheTtl 36}); 37const didDocCache = new LRUCache<ActorIdentifier, MiniDoc>({ 38 max: 1000, 39 ttl: cacheTtl 40}); 41const recordCache = new LRUCache< 42 string, 43 InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema> 44>({ 45 max: 5000, 46 ttl: cacheTtl 47}); 48 49export class AtpClient { 50 public atcute: AtcuteClient | null = null; 51 public didDoc: MiniDoc | null = null; 52 53 private slingshotUrl: URL = new URL('https://slingshot.microcosm.blue'); 54 private spacedustUrl: URL = new URL('https://spacedust.microcosm.blue'); 55 56 async login(handle: Handle, password: string): Promise<Result<null, string>> { 57 const didDoc = await this.resolveDidDoc(handle); 58 if (!didDoc.ok) return err(didDoc.error); 59 this.didDoc = didDoc.value; 60 61 try { 62 const handler = new CredentialManager({ service: didDoc.value.pds }); 63 const rpc = new AtcuteClient({ handler }); 64 await handler.login({ identifier: didDoc.value.did, password }); 65 66 this.atcute = rpc; 67 } catch (error) { 68 return err(`failed to login: ${error}`); 69 } 70 71 return ok(null); 72 } 73 74 async getRecord< 75 Collection extends Nsid, 76 TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, 77 TKey extends RecordKeySchema, 78 Schema extends RecordSchema<TObject, TKey>, 79 Output extends InferOutput<Schema> 80 >(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> { 81 const collection = schema.object.shape.$type.expected; 82 const cacheKey = `${repo}:${collection}:${rkey}`; 83 84 const cached = recordCache.get(cacheKey); 85 if (cached) return ok(cached.value as Output); 86 87 const result = await fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, { 88 repo, 89 collection, 90 rkey 91 }); 92 93 if (!result.ok) return result; 94 // console.info(`fetched record:`, result.value); 95 96 const parsed = safeParse(schema, result.value.value); 97 if (!parsed.ok) return err(parsed.message); 98 99 recordCache.set(cacheKey, result.value); 100 101 return ok(parsed.value as Output); 102 } 103 104 async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> { 105 const cached = handleCache.get(handle); 106 if (cached) return ok(cached); 107 108 const res = await fetchMicrocosm( 109 this.slingshotUrl, 110 ComAtprotoIdentityResolveIdentity.mainSchema, 111 { 112 handle: handle 113 } 114 ); 115 116 const mapped = map(res, (data) => data.did as AtprotoDid); 117 118 if (mapped.ok) { 119 handleCache.set(handle, mapped.value); 120 } 121 122 return mapped; 123 } 124 125 async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> { 126 const cached = didDocCache.get(handleOrDid); 127 if (cached) return ok(cached); 128 129 const result = await fetchMicrocosm(this.slingshotUrl, MiniDocQuery, { 130 identifier: handleOrDid 131 }); 132 133 if (result.ok) { 134 didDocCache.set(handleOrDid, result.value); 135 } 136 137 return result; 138 } 139} 140 141const fetchMicrocosm = async < 142 Schema extends XRPCQueryMetadata, 143 Output extends InferXRPCBodyOutput<Schema['output']> 144>( 145 api: URL, 146 schema: Schema, 147 params?: URLSearchParams | Record<string, string>, 148 init?: RequestInit 149): Promise<Result<Output, string>> => { 150 if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); 151 if (params && !(params instanceof URLSearchParams)) params = new URLSearchParams(params); 152 if (params?.size === 0) params = undefined; 153 try { 154 api.pathname = `/xrpc/${schema.nsid}`; 155 api.search = params ? `?${params}` : ''; 156 // console.info(`fetching:`, api.href); 157 const response = await fetch(api, init); 158 const body = await response.json(); 159 if (response.status === 400) return err(`${body.error}: ${body.message}`); 160 if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`); 161 const parsed = safeParse(schema.output.schema, body); 162 if (!parsed.ok) return err(parsed.message); 163 return ok(parsed.value as Output); 164 } catch (error) { 165 return err(`FetchError: ${error}`); 166 } 167};