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};