@atcute/client 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.ts ADDED
@@ -0,0 +1,461 @@
1
+ import type { Did } from '@atcute/lexicons';
2
+ import type { XRPCProcedures, XRPCQueries } from '@atcute/lexicons/ambient';
3
+ import type {
4
+ InferInput,
5
+ InferOutput,
6
+ ObjectSchema,
7
+ XRPCBlobBodyParam,
8
+ XRPCLexBodyParam,
9
+ XRPCProcedureMetadata,
10
+ XRPCQueryMetadata,
11
+ } from '@atcute/lexicons/validations';
12
+
13
+ import { buildFetchHandler, type FetchHandler, type FetchHandlerObject } from './fetch-handler.js';
14
+
15
+ // #region Type utilities
16
+ type RequiredKeysOf<TType extends object> = TType extends any
17
+ ? Exclude<
18
+ {
19
+ [Key in keyof TType]: TType extends Record<Key, TType[Key]> ? Key : never;
20
+ }[keyof TType],
21
+ undefined
22
+ >
23
+ : never;
24
+
25
+ type HasRequiredKeys<TType extends object> = RequiredKeysOf<TType> extends never ? false : true;
26
+
27
+ // #endregion
28
+
29
+ // #region Type definitions for response formats
30
+ type ResponseFormat = 'json' | 'blob' | 'bytes' | 'stream';
31
+
32
+ type FormattedResponse<TDef> = {
33
+ json: TDef extends XRPCQueryMetadata<any, infer Body extends XRPCLexBodyParam, any>
34
+ ? InferOutput<Body['schema']>
35
+ : TDef extends XRPCProcedureMetadata<any, any, infer Body extends XRPCLexBodyParam, any>
36
+ ? InferOutput<Body['schema']>
37
+ : unknown;
38
+ blob: Blob;
39
+ bytes: Uint8Array;
40
+ stream: ReadableStream<Uint8Array>;
41
+ };
42
+
43
+ // #endregion
44
+
45
+ // #region Type definitions for request options
46
+ type BaseRequestOptions = {
47
+ signal?: AbortSignal;
48
+ headers?: HeadersInit;
49
+ };
50
+
51
+ export type QueryRequestOptions<TDef> = BaseRequestOptions &
52
+ (TDef extends XRPCQueryMetadata<infer Params, infer Output, any>
53
+ ? (Params extends ObjectSchema
54
+ ? // query has parameters
55
+ { params: InferInput<Params> }
56
+ : // query has no parameters
57
+ { params?: Record<string, unknown> }) &
58
+ (Output extends XRPCLexBodyParam // query has JSON response, format is optionally specified
59
+ ? { as?: ResponseFormat | null }
60
+ : // query doesn't have JSON response, format needs to be specified
61
+ { as: ResponseFormat | null })
62
+ : {
63
+ as: ResponseFormat | null;
64
+ params?: Record<string, unknown>;
65
+ });
66
+
67
+ export type ProcedureRequestOptions<TDef> = BaseRequestOptions &
68
+ (TDef extends XRPCProcedureMetadata<infer Params, infer Input, infer Output, any>
69
+ ? (Params extends ObjectSchema
70
+ ? // procedure has parameters
71
+ { params: InferInput<Params> }
72
+ : // procedure has no parameters
73
+ { params?: Record<string, unknown> }) &
74
+ (Input extends XRPCLexBodyParam
75
+ ? // procedure requires JSON input
76
+ { input: InferInput<Input['schema']> }
77
+ : Input extends XRPCBlobBodyParam
78
+ ? // procedure requires blob
79
+ { input: Blob | ArrayBuffer | ArrayBufferView | ReadableStream }
80
+ : // procedure doesn't specify input
81
+ { input?: Record<string, unknown> | Blob | ArrayBuffer | ArrayBufferView | ReadableStream }) &
82
+ (Output extends XRPCLexBodyParam
83
+ ? // procedure has JSON response, format is optionally specified
84
+ { as?: ResponseFormat | null }
85
+ : // procedure doesn't have JSON response, format needs to be specified
86
+ { as: ResponseFormat | null })
87
+ : {
88
+ as: ResponseFormat | null;
89
+ input?: Record<string, unknown> | Blob | ArrayBuffer | ArrayBufferView | ReadableStream;
90
+ params?: Record<string, unknown>;
91
+ });
92
+
93
+ type InternalRequestOptions = BaseRequestOptions & {
94
+ as?: ResponseFormat | null;
95
+ params?: Record<string, unknown>;
96
+ input?: Record<string, unknown> | Blob | BufferSource | ReadableStream;
97
+ };
98
+
99
+ // #endregion
100
+
101
+ // #region Type definitions for client response
102
+ /** standard XRPC error payload structure */
103
+ export type XRPCErrorPayload = {
104
+ /** error name */
105
+ error: string;
106
+ /** error description */
107
+ message?: string;
108
+ };
109
+
110
+ type BaseClientResponse = {
111
+ /** response status */
112
+ status: number;
113
+ /** response headers */
114
+ headers: Headers;
115
+ };
116
+
117
+ /** represents a successful response returned by the client */
118
+ export type SuccessClientResponse<TDef, TInit> = BaseClientResponse & {
119
+ ok: true;
120
+ /** response data */
121
+ data: TInit extends { as: infer TFormat }
122
+ ? TFormat extends ResponseFormat
123
+ ? FormattedResponse<TDef>[TFormat]
124
+ : TFormat extends null
125
+ ? null
126
+ : never
127
+ : TDef extends XRPCQueryMetadata<any, infer Body extends XRPCLexBodyParam, any>
128
+ ? InferOutput<Body['schema']>
129
+ : TDef extends XRPCProcedureMetadata<any, any, infer Body extends XRPCLexBodyParam, any>
130
+ ? InferOutput<Body['schema']>
131
+ : never;
132
+ };
133
+
134
+ /** represents a failed response returned by the client */
135
+ export type FailedClientResponse = BaseClientResponse & {
136
+ ok: false;
137
+ /** response data */
138
+ data: XRPCErrorPayload;
139
+ };
140
+
141
+ /** represents a response returned by the client */
142
+ export type ClientResponse<TDef, TInit> = SuccessClientResponse<TDef, TInit> | FailedClientResponse;
143
+
144
+ // #endregion
145
+
146
+ // #region Client
147
+ /** options for configuring service proxying */
148
+ export type ServiceProxyOptions = {
149
+ /** DID identifier that the upstream service should look up */
150
+ did: Did;
151
+ /**
152
+ * the specific service ID within the resolved DID document's `service` array
153
+ * that the upstream service should forward requests to.
154
+ *
155
+ * must start with `#`
156
+ *
157
+ * common values include:
158
+ * - `#atproto_pds` (personal data server)
159
+ * - `#atproto_labeler` (labeler service)
160
+ * - `#bsky_chat` (Bluesky chat service)
161
+ */
162
+ serviceId: `#${string}`;
163
+ };
164
+
165
+ /** options for configuring the client */
166
+ export type ClientOptions = {
167
+ /** the underlying fetch handler it should make requests with */
168
+ handler: FetchHandler | FetchHandlerObject;
169
+ /** service proxy configuration */
170
+ proxy?: ServiceProxyOptions | null;
171
+ };
172
+
173
+ const JSON_CONTENT_TYPE_RE = /\bapplication\/json\b/;
174
+
175
+ /** XRPC API client */
176
+ export class Client<TQueries = XRPCQueries, TProcedures = XRPCProcedures> {
177
+ handler: FetchHandler;
178
+ proxy: ServiceProxyOptions | null;
179
+
180
+ constructor({ handler, proxy = null }: ClientOptions) {
181
+ this.handler = buildFetchHandler(handler);
182
+ this.proxy = proxy;
183
+ }
184
+
185
+ /**
186
+ * clones this XRPC client
187
+ * @param opts options to merge with
188
+ * @returns the cloned XRPC client
189
+ */
190
+ clone({ handler = this.handler, proxy = this.proxy }: Partial<ClientOptions> = {}): Client<
191
+ TQueries,
192
+ TProcedures
193
+ > {
194
+ return new Client({ handler, proxy });
195
+ }
196
+
197
+ /**
198
+ * performs an XRPC query request (HTTP GET)
199
+ * @param name NSID of the query
200
+ * @param options query options
201
+ */
202
+ get<TName extends keyof TQueries, TInit extends QueryRequestOptions<TQueries[TName]>>(
203
+ name: TName,
204
+ ...options: HasRequiredKeys<TInit> extends true ? [init: TInit] : [init?: TInit]
205
+ ): Promise<ClientResponse<TQueries[TName], TInit>>;
206
+
207
+ get(name: string, options: InternalRequestOptions = {}) {
208
+ return this.#perform('get', name, options);
209
+ }
210
+
211
+ /**
212
+ * performs an XRPC procedure request (HTTP POST)
213
+ * @param name NSID of the procedure
214
+ * @param options procedure options
215
+ */
216
+ post<TName extends keyof TProcedures, TInit extends ProcedureRequestOptions<TProcedures[TName]>>(
217
+ name: TName,
218
+ ...options: HasRequiredKeys<TInit> extends true ? [init: TInit] : [init?: TInit]
219
+ ): Promise<ClientResponse<TProcedures[TName], TInit>>;
220
+
221
+ post(name: string, options: InternalRequestOptions = {}) {
222
+ return this.#perform('post', name, options);
223
+ }
224
+
225
+ async #perform(
226
+ method: 'get' | 'post',
227
+ name: string,
228
+ { signal, as: format = 'json', headers, input, params }: InternalRequestOptions,
229
+ ) {
230
+ const isWebInput =
231
+ input &&
232
+ (input instanceof Blob ||
233
+ ArrayBuffer.isView(input) ||
234
+ input instanceof ArrayBuffer ||
235
+ input instanceof ReadableStream);
236
+
237
+ const url = `/xrpc/${name}` + _constructSearchParams(params);
238
+
239
+ const response = await this.handler(url, {
240
+ method,
241
+ signal,
242
+ body: input && !isWebInput ? JSON.stringify(input) : input,
243
+ headers: _mergeHeaders(headers, {
244
+ 'content-type': input && !isWebInput ? 'application/json' : null,
245
+ 'atproto-proxy': _constructProxyHeader(this.proxy),
246
+ }),
247
+ });
248
+
249
+ {
250
+ const status = response.status;
251
+ const headers = response.headers;
252
+
253
+ const type = headers.get('content-type');
254
+
255
+ if (status !== 200) {
256
+ let json: any;
257
+
258
+ if (type != null && JSON_CONTENT_TYPE_RE.test(type)) {
259
+ // it should be okay to swallow the parsing error here
260
+ try {
261
+ const parsed = await response.json();
262
+ if (isXRPCErrorPayload(parsed)) {
263
+ json = parsed;
264
+ }
265
+ } catch {}
266
+ } else {
267
+ await response.body?.cancel();
268
+ }
269
+
270
+ return {
271
+ ok: false,
272
+ status: status,
273
+ headers: headers,
274
+ data: json ?? {
275
+ error: `UnknownXRPCError`,
276
+ message: `Request failed with status code ${status}`,
277
+ },
278
+ };
279
+ }
280
+
281
+ {
282
+ let data: any;
283
+ switch (format) {
284
+ case 'json': {
285
+ if (type != null && JSON_CONTENT_TYPE_RE.test(type)) {
286
+ // we shouldn't be handling parsing errors
287
+ data = await response.json();
288
+ } else {
289
+ await response.body?.cancel();
290
+
291
+ throw new TypeError(`Invalid response content-type (got ${type})`);
292
+ }
293
+
294
+ break;
295
+ }
296
+
297
+ case null: {
298
+ data = null;
299
+
300
+ await response.body?.cancel();
301
+
302
+ if (type != null) {
303
+ throw new TypeError(`Invalid response content-type (got ${type})`);
304
+ }
305
+
306
+ break;
307
+ }
308
+
309
+ case 'blob': {
310
+ data = await response.blob();
311
+ break;
312
+ }
313
+ case 'bytes': {
314
+ data = new Uint8Array(await response.arrayBuffer());
315
+ break;
316
+ }
317
+ case 'stream': {
318
+ data = response.body!;
319
+ break;
320
+ }
321
+ }
322
+
323
+ return {
324
+ ok: true,
325
+ status: status,
326
+ headers: headers,
327
+ data: data,
328
+ };
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ // #endregion
335
+
336
+ // #region Utility functions
337
+ const _constructSearchParams = (params: Record<string, unknown> | undefined): string => {
338
+ let searchParams: URLSearchParams | undefined;
339
+
340
+ for (const key in params) {
341
+ const value = params[key];
342
+
343
+ if (value !== undefined) {
344
+ searchParams ??= new URLSearchParams();
345
+
346
+ if (Array.isArray(value)) {
347
+ for (let idx = 0, len = value.length; idx < len; idx++) {
348
+ const val = value[idx];
349
+ searchParams.append(key, '' + val);
350
+ }
351
+ } else {
352
+ searchParams.set(key, '' + value);
353
+ }
354
+ }
355
+ }
356
+
357
+ return searchParams ? `?` + searchParams.toString() : '';
358
+ };
359
+
360
+ const _constructProxyHeader = (proxy: ServiceProxyOptions | null | undefined): string | null => {
361
+ if (proxy != null) {
362
+ return `${proxy.did}${proxy.serviceId}`;
363
+ }
364
+
365
+ return null;
366
+ };
367
+
368
+ const _mergeHeaders = (
369
+ init: HeadersInit | undefined,
370
+ defaults: Record<string, string | null>,
371
+ ): HeadersInit | undefined => {
372
+ let headers: Headers | undefined;
373
+
374
+ for (const name in defaults) {
375
+ const value = defaults[name];
376
+
377
+ if (value !== null) {
378
+ headers ??= new Headers(init);
379
+
380
+ if (!headers.has(name)) {
381
+ headers.set(name, value);
382
+ }
383
+ }
384
+ }
385
+
386
+ return headers ?? init;
387
+ };
388
+
389
+ export const isXRPCErrorPayload = (input: any): input is XRPCErrorPayload => {
390
+ if (typeof input !== 'object' || input == null) {
391
+ return false;
392
+ }
393
+
394
+ const kindType = typeof input.error;
395
+ const messageType = typeof input.message;
396
+
397
+ return kindType === 'string' && (messageType === 'undefined' || messageType === 'string');
398
+ };
399
+ // #endregion
400
+
401
+ // #region Optimistic response helper
402
+ type SuccessData<R> = R extends { ok: true; data: infer D } ? D : never;
403
+
404
+ /**
405
+ * takes in the response returned by the client, and either returns the data if
406
+ * it is a successful response, or throws if it's a failed response.
407
+ * @param input either a ClientResponse, or a promise that resolves to a ClientResponse
408
+ * @returns the data from a successful response
409
+ *
410
+ * @example
411
+ * const data = await ok(client.get('com.atproto.server.describeServer'));
412
+ * // ^? ComAtprotoServerDescribeServer.Output
413
+ */
414
+ export const ok: {
415
+ <T extends Promise<ClientResponse<any, any>>>(promise: T): Promise<SuccessData<Awaited<T>>>;
416
+ <T extends ClientResponse<any, any>>(response: T): SuccessData<T>;
417
+ } = (input: Promise<ClientResponse<any, any>> | ClientResponse<any, any>): any => {
418
+ if (input instanceof Promise) {
419
+ return input.then(ok);
420
+ }
421
+
422
+ if (input.ok) {
423
+ return input.data;
424
+ }
425
+
426
+ throw new ClientResponseError(input);
427
+ };
428
+
429
+ /** options when constructing a ClientResponseError */
430
+ export type ClientResponseErrorOptions = {
431
+ status: number;
432
+ headers?: Headers;
433
+ data: XRPCErrorPayload;
434
+ };
435
+
436
+ /** represents an error response returned by the client */
437
+ export class ClientResponseError extends Error {
438
+ /** error name returned by service */
439
+ readonly error: string;
440
+ /** error message returned by service */
441
+ readonly description?: string;
442
+
443
+ /** response status */
444
+ readonly status: number;
445
+ /** response headers */
446
+ readonly headers: Headers;
447
+
448
+ constructor({ status, headers = new Headers(), data }: ClientResponseErrorOptions) {
449
+ super(`${data.error} > ${data.message ?? '(unspecified description)'}`);
450
+
451
+ this.name = 'ClientResponseError';
452
+
453
+ this.error = data.error;
454
+ this.description = data.message;
455
+
456
+ this.status = status;
457
+ this.headers = headers;
458
+ }
459
+ }
460
+
461
+ // #endregion