@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/README.md +72 -13
- package/dist/client.d.ts +181 -0
- package/dist/client.js +180 -0
- package/dist/client.js.map +1 -0
- package/dist/credential-manager.d.ts +47 -38
- package/dist/credential-manager.js +50 -41
- package/dist/credential-manager.js.map +1 -1
- package/dist/fetch-handler.d.ts +2 -2
- package/dist/fetch-handler.js +1 -1
- package/dist/fetch-handler.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/lexicons.d.ts +82 -0
- package/dist/rpc.d.ts +44 -8
- package/dist/rpc.js +13 -1
- package/dist/rpc.js.map +1 -1
- package/lib/client.ts +406 -0
- package/lib/credential-manager.ts +119 -93
- package/lib/fetch-handler.ts +3 -3
- package/lib/index.ts +3 -2
- package/lib/lexicons.ts +81 -0
- package/lib/rpc.ts +44 -8
- package/package.json +2 -2
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
|