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