Highly ambitious ATProtocol AppView service and sdks
at main 541 lines 16 kB view raw
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}