your personal website on atproto - mirror
blento.app
1import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons';
2import { user } from './auth.svelte';
3import type { AllowedCollection } from './settings';
4import {
5 CompositeDidDocumentResolver,
6 CompositeHandleResolver,
7 DohJsonHandleResolver,
8 PlcDidDocumentResolver,
9 WebDidDocumentResolver,
10 WellKnownHandleResolver
11} from '@atcute/identity-resolver';
12import { Client, simpleFetchHandler } from '@atcute/client';
13import { type AppBskyActorDefs } from '@atcute/bluesky';
14
15export type Collection = `${string}.${string}.${string}`;
16import * as TID from '@atcute/tid';
17
18/**
19 * Parses an AT Protocol URI into its components.
20 * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123")
21 * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri
22 */
23export function parseUri(uri: string) {
24 const parts = parseResourceUri(uri);
25 if (!parts.ok) return;
26 return parts.value;
27}
28
29/**
30 * Resolves a handle to a DID using DNS and HTTP methods.
31 * @param handle - The handle to resolve (e.g., "alice.bsky.social")
32 * @returns The DID associated with the handle
33 */
34export async function resolveHandle({ handle }: { handle: Handle }) {
35 const handleResolver = new CompositeHandleResolver({
36 methods: {
37 dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }),
38 http: new WellKnownHandleResolver()
39 }
40 });
41
42 const data = await handleResolver.resolve(handle);
43 return data;
44}
45
46const didResolver = new CompositeDidDocumentResolver({
47 methods: {
48 plc: new PlcDidDocumentResolver(),
49 web: new WebDidDocumentResolver()
50 }
51});
52
53/**
54 * Gets the PDS (Personal Data Server) URL for a given DID.
55 * @param did - The DID to look up
56 * @returns The PDS service endpoint URL
57 * @throws If no PDS is found in the DID document
58 */
59export async function getPDS(did: Did) {
60 const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>);
61 if (!doc.service) throw new Error('No PDS found');
62 for (const service of doc.service) {
63 if (service.id === '#atproto_pds') {
64 return service.serviceEndpoint.toString();
65 }
66 }
67}
68
69/**
70 * Fetches a detailed Bluesky profile for a user.
71 * @param data - Optional object with did and client
72 * @param data.did - The DID to fetch the profile for (defaults to current user)
73 * @param data.client - The client to use (defaults to public Bluesky API)
74 * @returns The profile data or undefined if not found
75 */
76export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
77 data ??= {};
78 data.did ??= user.did;
79
80 if (!data.did) throw new Error('Error getting detailed profile: no did');
81
82 data.client ??= new Client({
83 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
84 });
85
86 const response = await data.client.get('app.bsky.actor.getProfile', {
87 params: { actor: data.did }
88 });
89
90 if (!response.ok) return;
91
92 return response.data;
93}
94
95/**
96 * Creates an AT Protocol client for a user's PDS.
97 * @param did - The DID of the user
98 * @returns A client configured for the user's PDS
99 * @throws If the PDS cannot be found
100 */
101export async function getClient({ did }: { did: Did }) {
102 const pds = await getPDS(did);
103 if (!pds) throw new Error('PDS not found');
104
105 const client = new Client({
106 handler: simpleFetchHandler({ service: pds })
107 });
108
109 return client;
110}
111
112/**
113 * Lists records from a repository collection with pagination support.
114 * @param did - The DID of the repository (defaults to current user)
115 * @param collection - The collection to list records from
116 * @param cursor - Pagination cursor for continuing from a previous request
117 * @param limit - Maximum number of records to return (default 100, set to 0 for all records)
118 * @param client - The client to use (defaults to user's PDS client)
119 * @returns An array of records from the collection
120 */
121export async function listRecords({
122 did,
123 collection,
124 cursor,
125 limit = 100,
126 client
127}: {
128 did?: Did;
129 collection: `${string}.${string}.${string}`;
130 cursor?: string;
131 limit?: number;
132 client?: Client;
133}) {
134 did ??= user.did;
135 if (!collection) {
136 throw new Error('Missing parameters for listRecords');
137 }
138 if (!did) {
139 throw new Error('Missing did for getRecord');
140 }
141
142 client ??= await getClient({ did });
143
144 const allRecords = [];
145
146 let currentCursor = cursor;
147 do {
148 const response = await client.get('com.atproto.repo.listRecords', {
149 params: {
150 repo: did,
151 collection,
152 limit: !limit || limit > 100 ? 100 : limit,
153 cursor: currentCursor
154 }
155 });
156
157 if (!response.ok) {
158 return allRecords;
159 }
160
161 allRecords.push(...response.data.records);
162 currentCursor = response.data.cursor;
163 } while (currentCursor && (!limit || allRecords.length < limit));
164
165 return allRecords;
166}
167
168/**
169 * Fetches a single record from a repository.
170 * @param did - The DID of the repository (defaults to current user)
171 * @param collection - The collection the record belongs to
172 * @param rkey - The record key (defaults to "self")
173 * @param client - The client to use (defaults to user's PDS client)
174 * @returns The record data
175 */
176export async function getRecord({
177 did,
178 collection,
179 rkey = 'self',
180 client
181}: {
182 did?: Did;
183 collection: Collection;
184 rkey?: string;
185 client?: Client;
186}) {
187 did ??= user.did;
188
189 if (!collection) {
190 throw new Error('Missing parameters for getRecord');
191 }
192 if (!did) {
193 throw new Error('Missing did for getRecord');
194 }
195
196 client ??= await getClient({ did });
197
198 const record = await client.get('com.atproto.repo.getRecord', {
199 params: {
200 repo: did,
201 collection,
202 rkey
203 }
204 });
205
206 return JSON.parse(JSON.stringify(record.data));
207}
208
209/**
210 * Creates or updates a record in the current user's repository.
211 * Only accepts collections that are configured in permissions.
212 * @param collection - The collection to write to (must be in permissions.collections)
213 * @param rkey - The record key (defaults to "self")
214 * @param record - The record data to write
215 * @returns The response from the PDS
216 * @throws If the user is not logged in
217 */
218export async function putRecord({
219 collection,
220 rkey = 'self',
221 record
222}: {
223 collection: AllowedCollection;
224 rkey?: string;
225 record: Record<string, unknown>;
226}) {
227 if (!user.client || !user.did) throw new Error('No rpc or did');
228
229 const response = await user.client.post('com.atproto.repo.putRecord', {
230 input: {
231 collection,
232 repo: user.did,
233 rkey,
234 record: {
235 ...record
236 }
237 }
238 });
239
240 return response;
241}
242
243/**
244 * Deletes a record from the current user's repository.
245 * Only accepts collections that are configured in permissions.
246 * @param collection - The collection the record belongs to (must be in permissions.collections)
247 * @param rkey - The record key (defaults to "self")
248 * @returns True if the deletion was successful
249 * @throws If the user is not logged in
250 */
251export async function deleteRecord({
252 collection,
253 rkey = 'self'
254}: {
255 collection: AllowedCollection;
256 rkey: string;
257}) {
258 if (!user.client || !user.did) throw new Error('No profile or rpc or did');
259
260 const response = await user.client.post('com.atproto.repo.deleteRecord', {
261 input: {
262 collection,
263 repo: user.did,
264 rkey
265 }
266 });
267
268 return response.ok;
269}
270
271/**
272 * Uploads a blob to the current user's PDS.
273 * @param blob - The blob data to upload
274 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure
275 * @throws If the user is not logged in
276 */
277export async function uploadBlob({ blob }: { blob: Blob }) {
278 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in");
279
280 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', {
281 params: {
282 repo: user.did
283 },
284 input: blob
285 });
286
287 if (!blobResponse?.ok) return;
288
289 const blobInfo = blobResponse?.data.blob as {
290 $type: 'blob';
291 ref: {
292 $link: string;
293 };
294 mimeType: string;
295 size: number;
296 };
297
298 return blobInfo;
299}
300
301/**
302 * Gets metadata about a repository.
303 * @param client - The client to use
304 * @param did - The DID of the repository (defaults to current user)
305 * @returns Repository metadata or undefined on failure
306 */
307export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
308 did ??= user.did;
309 if (!did) {
310 throw new Error('Error describeRepo: No did');
311 }
312 client ??= await getClient({ did });
313
314 const repo = await client.get('com.atproto.repo.describeRepo', {
315 params: {
316 repo: did
317 }
318 });
319 if (!repo.ok) return;
320
321 return repo.data;
322}
323
324/**
325 * Constructs a URL to fetch a blob directly from a user's PDS.
326 * @param did - The DID of the user who owns the blob
327 * @param blob - The blob reference object
328 * @returns The URL to fetch the blob
329 */
330export async function getBlobURL({
331 did,
332 blob
333}: {
334 did: Did;
335 blob: {
336 $type: 'blob';
337 ref: {
338 $link: string;
339 };
340 };
341}) {
342 const pds = await getPDS(did);
343 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
344}
345
346/**
347 * Constructs a Bluesky CDN URL for an image blob.
348 * @param did - The DID of the user who owns the blob (defaults to current user)
349 * @param blob - The blob reference object
350 * @returns The CDN URL for the image in webp format
351 */
352export function getCDNImageBlobUrl({
353 did,
354 blob
355}: {
356 did?: string;
357 blob: {
358 $type: 'blob';
359 ref: {
360 $link: string;
361 };
362 };
363}) {
364 did ??= user.did;
365
366 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
367}
368
369/**
370 * Searches for actors with typeahead/autocomplete functionality.
371 * @param q - The search query
372 * @param limit - Maximum number of results (default 10)
373 * @param host - The API host to use (defaults to public Bluesky API)
374 * @returns An object containing matching actors and the original query
375 */
376export async function searchActorsTypeahead(
377 q: string,
378 limit: number = 10,
379 host?: string
380): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> {
381 host ??= 'https://public.api.bsky.app';
382
383 const client = new Client({
384 handler: simpleFetchHandler({ service: host })
385 });
386
387 const response = await client.get('app.bsky.actor.searchActorsTypeahead', {
388 params: {
389 q,
390 limit
391 }
392 });
393
394 if (!response.ok) return { actors: [], q };
395
396 return { actors: response.data.actors, q };
397}
398
399/**
400 * Return a TID based on current time
401 *
402 * @returns TID for current time
403 */
404export function createTID() {
405 return TID.now();
406}
407
408export async function getAuthorFeed(data?: {
409 did?: Did;
410 client?: Client;
411 filter?: string;
412 limit?: number;
413}) {
414 data ??= {};
415 data.did ??= user.did;
416
417 if (!data.did) throw new Error('Error getting detailed profile: no did');
418
419 data.client ??= new Client({
420 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
421 });
422
423 const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
424 params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
425 });
426
427 if (!response.ok) return;
428
429 return response.data;
430}