Highly ambitious ATProtocol AppView service and sdks
1// @slices/client - Core AT Protocol client for slices
2// This package provides the base functionality for generated slice clients
3
4import type { OAuthClient } from "@slices/oauth";
5
6// Minimal auth interface that only requires what we actually use
7// AuthProvider should be a user-scoped instance (e.g., OAuthClient created with userId)
8export interface AuthProvider {
9 ensureValidToken(): Promise<{ accessToken: string; tokenType?: string }>;
10 refreshAccessToken(): Promise<{ accessToken: string; tokenType?: string }>;
11}
12
13// Base interfaces
14export interface RecordResponse<T> {
15 uri: string;
16 cid: string;
17 did: string;
18 collection: string;
19 value: T;
20 indexedAt: string;
21}
22
23export interface GetRecordsResponse<T> {
24 records: RecordResponse<T>[];
25 cursor?: string;
26}
27
28export interface CountRecordsResponse {
29 count: number;
30}
31
32export interface GetRecordParams {
33 uri: string;
34}
35
36// Where condition types
37export interface WhereCondition {
38 eq?: string;
39 in?: string[];
40 contains?: string;
41}
42
43export type WhereClause<T extends string = string> = {
44 [K in T]?: WhereCondition;
45};
46
47export type IndexedRecordFields =
48 | "did"
49 | "collection"
50 | "uri"
51 | "cid"
52 | "indexedAt"
53 | "json";
54
55export interface SortField<TField extends string = string> {
56 field: TField;
57 direction: "asc" | "desc";
58}
59
60// Actor interfaces
61export interface Actor {
62 did: string;
63 handle?: string;
64 sliceUri: string;
65 indexedAt: string;
66}
67
68export interface ActorWhereConditions {
69 did?: WhereCondition;
70 handle?: WhereCondition;
71 indexed_at?: WhereCondition;
72}
73
74export interface GetActorsParams {
75 limit?: number;
76 cursor?: string;
77 where?: ActorWhereConditions;
78 orWhere?: ActorWhereConditions;
79}
80
81export interface GetActorsResponse {
82 actors: Actor[];
83 cursor?: string;
84}
85
86// Indexed record interface
87export interface IndexedRecord<T = Record<string, unknown>> {
88 uri: string;
89 cid: string;
90 did: string;
91 collection: string;
92 value: T;
93 indexedAt: string;
94}
95
96// Export these for use in generated clients
97export interface SliceLevelRecordsParams<TRecord = Record<string, unknown>> {
98 slice: string;
99 limit?: number;
100 cursor?: string;
101 where?: { [K in keyof TRecord | IndexedRecordFields]?: WhereCondition };
102 orWhere?: { [K in keyof TRecord | IndexedRecordFields]?: WhereCondition };
103 sortBy?: SortField[];
104}
105
106export interface SliceRecordsOutput<T = Record<string, unknown>> {
107 records: IndexedRecord<T>[];
108 cursor?: string;
109}
110
111// Blob upload interfaces
112export interface UploadBlobRequest {
113 data: ArrayBuffer | Uint8Array;
114 mimeType: string;
115}
116
117export interface BlobRef {
118 $type: string;
119 ref: { $link: string };
120 mimeType: string;
121 size: number;
122}
123
124export interface UploadBlobResponse {
125 blob: BlobRef;
126}
127
128// Base client class
129export class SlicesClient {
130 protected readonly baseUrl: string;
131 readonly sliceUri: string; // Make public so collection classes can access it
132 protected readonly authProvider?: OAuthClient | AuthProvider;
133
134 constructor(
135 baseUrl: string,
136 sliceUri: string,
137 authProvider?: OAuthClient | AuthProvider
138 ) {
139 this.baseUrl = baseUrl;
140 this.sliceUri = sliceUri;
141 this.authProvider = authProvider;
142 }
143
144 protected async ensureValidToken(): Promise<void> {
145 if (!this.authProvider) {
146 throw new Error("Auth provider not configured");
147 }
148 await this.authProvider.ensureValidToken();
149 }
150
151 async makeRequest<T = unknown>(
152 endpoint: string,
153 method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
154 params?: Record<string, unknown> | unknown
155 ): Promise<T> {
156 return await this.makeRequestWithRetry(endpoint, method, params, false);
157 }
158
159 private async makeRequestWithRetry<T = unknown>(
160 endpoint: string,
161 method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
162 params?: Record<string, unknown> | unknown,
163 isRetry = false
164 ): Promise<T> {
165 const httpMethod = method || "GET";
166 let url = `${this.baseUrl}/xrpc/${endpoint}`;
167
168 const requestInit: RequestInit = {
169 method: httpMethod,
170 headers: {},
171 };
172
173 // Add authorization header if auth provider is available
174 if (this.authProvider) {
175 try {
176 const tokens = await this.authProvider.ensureValidToken();
177 if (tokens.accessToken) {
178 (requestInit.headers as Record<string, string>)[
179 "Authorization"
180 ] = `${tokens.tokenType} ${tokens.accessToken}`;
181 }
182 } catch (_tokenError) {
183 // For write operations, OAuth tokens are required (excluding read endpoints that use POST)
184 const isReadEndpoint =
185 endpoint.includes(".getRecords") ||
186 endpoint.includes(".getSliceRecords") ||
187 endpoint.includes(".stats");
188 if (httpMethod !== "GET" && !isReadEndpoint) {
189 throw new Error(
190 `Authentication required: OAuth tokens are invalid or expired. Please log in again.`
191 );
192 }
193 // For read operations, continue without auth (allow read-only operations)
194 }
195 }
196
197 if (httpMethod === "GET" && params) {
198 const searchParams = new URLSearchParams();
199 Object.entries(params as Record<string, unknown>).forEach(([key, value]) => {
200 if (value !== undefined && value !== null) {
201 searchParams.append(key, String(value));
202 }
203 });
204 const queryString = searchParams.toString();
205 if (queryString) {
206 url += "?" + queryString;
207 }
208 } else if (httpMethod !== "GET" && params) {
209 // Regular API endpoints expect JSON
210 (requestInit.headers as Record<string, string>)["Content-Type"] =
211 "application/json";
212 requestInit.body = JSON.stringify(params);
213 }
214
215 const response = await fetch(url, requestInit);
216 if (!response.ok) {
217 // Handle 404 gracefully for GET requests
218 if (response.status === 404 && httpMethod === "GET") {
219 return null as T;
220 }
221
222 // Handle 401 Unauthorized - attempt token refresh and retry once
223 const isReadEndpoint =
224 endpoint.includes(".getRecords") ||
225 endpoint.includes(".getSliceRecords") ||
226 endpoint.includes(".stats");
227 if (
228 response.status === 401 &&
229 !isRetry &&
230 this.authProvider &&
231 httpMethod !== "GET" &&
232 !isReadEndpoint
233 ) {
234 try {
235 // Force token refresh by calling refreshAccessToken
236 await this.authProvider.refreshAccessToken();
237 // Retry the request once with refreshed tokens
238 return this.makeRequestWithRetry(endpoint, method, params, true);
239 } catch (_refreshError) {
240 throw new Error(
241 `Authentication required: OAuth tokens are invalid or expired. Please log in again.`
242 );
243 }
244 }
245
246 // Try to read the response body for detailed error information
247 let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
248
249 try {
250 const errorBody = await response.json();
251 // XRPC-style error format: { error: "ErrorName", message: "details" }
252 if (errorBody?.error && errorBody?.message) {
253 errorMessage = `${errorBody.error}: ${errorBody.message}`;
254 } else if (errorBody?.message) {
255 errorMessage = errorBody.message;
256 } else if (errorBody?.error) {
257 errorMessage = errorBody.error;
258 }
259 } catch {
260 // If we can't parse the response body, just use the status message
261 }
262
263 throw new Error(errorMessage);
264 }
265
266 return (await response.json()) as T;
267 }
268
269 // Core slice methods
270 async getSliceRecords<T = Record<string, unknown>>(
271 params: Omit<SliceLevelRecordsParams<T>, "slice">
272 ): Promise<SliceRecordsOutput<T>> {
273 // Combine where and orWhere into the expected backend format
274 const whereClause: Record<string, unknown> = params?.where ? { ...params.where } : {};
275 if (params?.orWhere) {
276 whereClause.$or = params.orWhere;
277 }
278 const requestParams = {
279 ...params,
280 where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
281 orWhere: undefined, // Remove orWhere as it's now in where.$or
282 slice: this.sliceUri,
283 };
284 return await this.makeRequest<SliceRecordsOutput<T>>(
285 "network.slices.slice.getSliceRecords",
286 "POST",
287 requestParams
288 );
289 }
290
291 async getActors(params?: GetActorsParams): Promise<GetActorsResponse> {
292 const whereClause: Record<string, unknown> = params?.where ? { ...params.where } : {};
293 if (params?.orWhere) {
294 whereClause.$or = params.orWhere;
295 }
296 const requestParams = {
297 ...params,
298 where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
299 orWhere: undefined,
300 slice: this.sliceUri,
301 };
302 return await this.makeRequest<GetActorsResponse>(
303 "network.slices.slice.getActors",
304 "POST",
305 requestParams
306 );
307 }
308
309 async syncUserCollections<T = unknown>(params?: {
310 timeoutSeconds?: number;
311 }): Promise<T> {
312 const requestParams = { slice: this.sliceUri, ...params };
313 return await this.makeRequest<T>(
314 "network.slices.slice.syncUserCollections",
315 "POST",
316 requestParams
317 );
318 }
319
320 async uploadBlob(request: UploadBlobRequest): Promise<UploadBlobResponse> {
321 return await this.uploadBlobWithRetry(request, false);
322 }
323
324 private async uploadBlobWithRetry(
325 request: UploadBlobRequest,
326 isRetry = false
327 ): Promise<UploadBlobResponse> {
328 // Special handling for blob upload with binary data
329 const httpMethod = "POST";
330 const url = `${this.baseUrl}/xrpc/com.atproto.repo.uploadBlob`;
331
332 if (!this.authProvider) {
333 throw new Error("OAuth client not configured");
334 }
335
336 const tokens = await this.authProvider.ensureValidToken();
337
338 const requestInit: RequestInit = {
339 method: httpMethod,
340 headers: {
341 "Content-Type": request.mimeType,
342 Authorization: `${tokens.tokenType} ${tokens.accessToken}`,
343 },
344 body: request.data,
345 };
346
347 const response = await fetch(url, requestInit);
348 if (!response.ok) {
349 // Handle 401 Unauthorized - attempt token refresh and retry once
350 if (response.status === 401 && !isRetry && this.authProvider) {
351 try {
352 // Force token refresh by calling refreshAccessToken
353 await this.authProvider.refreshAccessToken();
354 // Retry the request once with refreshed tokens
355 return this.uploadBlobWithRetry(request, true);
356 } catch (_refreshError) {
357 throw new Error(
358 `Authentication required: OAuth tokens are invalid or expired. Please log in again.`
359 );
360 }
361 }
362
363 // Try to read the response body for detailed error information
364 let errorMessage = `Blob upload failed: ${response.status} ${response.statusText}`;
365 try {
366 const errorBody = await response.json();
367 // XRPC-style error format: { error: "ErrorName", message: "details" }
368 if (errorBody?.error && errorBody?.message) {
369 errorMessage = `${errorBody.error}: ${errorBody.message}`;
370 } else if (errorBody?.message) {
371 errorMessage = errorBody.message;
372 } else if (errorBody?.error) {
373 errorMessage = errorBody.error;
374 }
375 } catch {
376 // If we can't parse the response body, just use the status message
377 }
378
379 throw new Error(errorMessage);
380 }
381
382 return await response.json();
383 }
384
385 // Generic collection operations
386 async getRecords<T>(
387 collection: string,
388 params?: {
389 limit?: number;
390 cursor?: string;
391 where?: Record<string, WhereCondition>;
392 orWhere?: Record<string, WhereCondition>;
393 sortBy?: SortField[];
394 }
395 ): Promise<GetRecordsResponse<T>> {
396 // Combine where and orWhere into the expected backend format
397 const whereClause: Record<string, unknown> = params?.where ? { ...params.where } : {};
398 if (params?.orWhere) {
399 whereClause.$or = params.orWhere;
400 }
401 const requestParams = {
402 ...params,
403 where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
404 orWhere: undefined, // Remove orWhere as it's now in where.$or
405 slice: this.sliceUri,
406 };
407 const result = await this.makeRequest<SliceRecordsOutput>(
408 `${collection}.getRecords`,
409 "POST",
410 requestParams
411 );
412 return {
413 records: result.records.map((record) => ({
414 uri: record.uri,
415 cid: record.cid,
416 did: record.did,
417 collection: record.collection,
418 value: record.value as T,
419 indexedAt: record.indexedAt,
420 })),
421 cursor: result.cursor,
422 };
423 }
424
425 async getRecord<T>(
426 collection: string,
427 params: GetRecordParams
428 ): Promise<RecordResponse<T>> {
429 const requestParams = { ...params, slice: this.sliceUri };
430 return await this.makeRequest<RecordResponse<T>>(
431 `${collection}.getRecord`,
432 "GET",
433 requestParams
434 );
435 }
436
437 async createRecord<T>(
438 collection: string,
439 record: T,
440 useSelfRkey?: boolean
441 ): Promise<{ uri: string; cid: string }> {
442 const recordValue = { $type: collection, ...record };
443 const payload = {
444 slice: this.sliceUri,
445 ...(useSelfRkey ? { rkey: "self" } : {}),
446 record: recordValue,
447 };
448 return await this.makeRequest<{ uri: string; cid: string }>(
449 `${collection}.createRecord`,
450 "POST",
451 payload
452 );
453 }
454
455 async updateRecord<T>(
456 collection: string,
457 rkey: string,
458 record: T
459 ): Promise<{ uri: string; cid: string }> {
460 const recordValue = { $type: collection, ...record };
461 const payload = {
462 slice: this.sliceUri,
463 rkey,
464 record: recordValue,
465 };
466 return await this.makeRequest<{ uri: string; cid: string }>(
467 `${collection}.updateRecord`,
468 "POST",
469 payload
470 );
471 }
472
473 async deleteRecord(collection: string, rkey: string): Promise<void> {
474 return await this.makeRequest<void>(`${collection}.deleteRecord`, "POST", {
475 rkey,
476 });
477 }
478
479 async countRecords(
480 collection: string,
481 params?: {
482 where?: Record<string, WhereCondition>;
483 orWhere?: Record<string, WhereCondition>;
484 }
485 ): Promise<CountRecordsResponse> {
486 // Combine where and orWhere into the expected backend format
487 const whereClause: Record<string, unknown> = params?.where ? { ...params.where } : {};
488 if (params?.orWhere) {
489 whereClause.$or = params.orWhere;
490 }
491 const requestParams = {
492 ...params,
493 where: Object.keys(whereClause).length > 0 ? whereClause : undefined,
494 orWhere: undefined, // Remove orWhere as it's now in where.$or
495 slice: this.sliceUri,
496 };
497 return await this.makeRequest<CountRecordsResponse>(
498 `${collection}.countRecords`,
499 "POST",
500 requestParams
501 );
502 }
503}
504
505// Utility function to convert BlobRef to CDN URL using record context
506export function recordBlobToCdnUrl<T>(
507 record: RecordResponse<T>,
508 blobRef: BlobRef,
509 preset:
510 | "avatar"
511 | "banner"
512 | "feed_thumbnail"
513 | "feed_fullsize" = "feed_fullsize",
514 cdnBaseUrl = "https://cdn.bsky.app/img"
515): string {
516 const sizePreset = preset || "feed_fullsize";
517 const cid = blobRef.ref.$link;
518 return `${cdnBaseUrl}/${sizePreset}/plain/${record.did}/${cid}@jpeg`;
519}
520
521// Collection operations interface for generated clients
522export interface CollectionOperations<T, TSortField extends string = string> {
523 getRecords(params?: {
524 limit?: number;
525 cursor?: string;
526 where?: { [K in TSortField | IndexedRecordFields]?: WhereCondition };
527 orWhere?: { [K in TSortField | IndexedRecordFields]?: WhereCondition };
528 sortBy?: SortField<TSortField>[];
529 }): Promise<GetRecordsResponse<T>>;
530 getRecord(params: GetRecordParams): Promise<RecordResponse<T>>;
531 createRecord(
532 record: T,
533 useSelfRkey?: boolean
534 ): Promise<{ uri: string; cid: string }>;
535 updateRecord(rkey: string, record: T): Promise<{ uri: string; cid: string }>;
536 deleteRecord(rkey: string): Promise<void>;
537 countRecords(params?: {
538 where?: { [K in TSortField | IndexedRecordFields]?: WhereCondition };
539 orWhere?: { [K in TSortField | IndexedRecordFields]?: WhereCondition };
540 }): Promise<CountRecordsResponse>;
541}