@atproto/xrpc 0.5.0 → 0.6.0-rc.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/src/util.ts CHANGED
@@ -6,12 +6,28 @@ import {
6
6
  } from '@atproto/lexicon'
7
7
  import {
8
8
  CallOptions,
9
- Headers,
9
+ errorResponseBody,
10
+ ErrorResponseBody,
11
+ Gettable,
10
12
  QueryParams,
11
13
  ResponseType,
12
14
  XRPCError,
13
15
  } from './types'
14
16
 
17
+ const ReadableStream =
18
+ globalThis.ReadableStream ||
19
+ (class {
20
+ constructor() {
21
+ // This anonymous class will never pass any "instanceof" check and cannot
22
+ // be instantiated.
23
+ throw new Error('ReadableStream is not supported in this environment')
24
+ }
25
+ } as typeof globalThis.ReadableStream)
26
+
27
+ export function isErrorResponseBody(v: unknown): v is ErrorResponseBody {
28
+ return errorResponseBody.safeParse(v).success
29
+ }
30
+
15
31
  export function getMethodSchemaHTTPMethod(
16
32
  schema: LexXrpcProcedure | LexXrpcQuery,
17
33
  ) {
@@ -27,33 +43,43 @@ export function constructMethodCallUri(
27
43
  serviceUri: URL,
28
44
  params?: QueryParams,
29
45
  ): string {
30
- const uri = new URL(serviceUri)
31
- uri.pathname = `/xrpc/${nsid}`
32
-
33
- // given parameters
34
- if (params) {
35
- for (const [key, value] of Object.entries(params)) {
36
- const paramSchema = schema.parameters?.properties?.[key]
37
- if (!paramSchema) {
38
- throw new Error(`Invalid query parameter: ${key}`)
39
- }
40
- if (value !== undefined) {
41
- if (paramSchema.type === 'array') {
42
- const vals: typeof value[] = []
43
- vals.concat(value).forEach((val) => {
44
- uri.searchParams.append(
45
- key,
46
- encodeQueryParam(paramSchema.items.type, val),
47
- )
48
- })
49
- } else {
50
- uri.searchParams.set(key, encodeQueryParam(paramSchema.type, value))
46
+ const uri = new URL(constructMethodCallUrl(nsid, schema, params), serviceUri)
47
+ return uri.toString()
48
+ }
49
+
50
+ export function constructMethodCallUrl(
51
+ nsid: string,
52
+ schema: LexXrpcProcedure | LexXrpcQuery,
53
+ params?: QueryParams,
54
+ ): string {
55
+ const pathname = `/xrpc/${encodeURIComponent(nsid)}`
56
+ if (!params) return pathname
57
+
58
+ const searchParams: [string, string][] = []
59
+
60
+ for (const [key, value] of Object.entries(params)) {
61
+ const paramSchema = schema.parameters?.properties?.[key]
62
+ if (!paramSchema) {
63
+ throw new Error(`Invalid query parameter: ${key}`)
64
+ }
65
+ if (value !== undefined) {
66
+ if (paramSchema.type === 'array') {
67
+ const values = Array.isArray(value) ? value : [value]
68
+ for (const val of values) {
69
+ searchParams.push([
70
+ key,
71
+ encodeQueryParam(paramSchema.items.type, val),
72
+ ])
51
73
  }
74
+ } else {
75
+ searchParams.push([key, encodeQueryParam(paramSchema.type, value)])
52
76
  }
53
77
  }
54
78
  }
55
79
 
56
- return uri.toString()
80
+ if (!searchParams.length) return pathname
81
+
82
+ return `${pathname}?${new URLSearchParams(searchParams).toString()}`
57
83
  }
58
84
 
59
85
  export function encodeQueryParam(
@@ -85,100 +111,343 @@ export function encodeQueryParam(
85
111
  throw new Error(`Unsupported query param type: ${type}`)
86
112
  }
87
113
 
88
- export function normalizeHeaders(headers: Headers): Headers {
89
- const normalized: Headers = {}
90
- for (const [header, value] of Object.entries(headers)) {
91
- normalized[header.toLowerCase()] = value
92
- }
93
-
94
- return normalized
95
- }
96
-
97
114
  export function constructMethodCallHeaders(
98
115
  schema: LexXrpcProcedure | LexXrpcQuery,
99
- data?: any,
116
+ data?: unknown,
100
117
  opts?: CallOptions,
101
118
  ): Headers {
102
- const headers: Headers = opts?.headers || {}
119
+ // Not using `new Headers(opts?.headers)` to avoid duplicating headers values
120
+ // due to inconsistent casing in headers name. In case of multiple headers
121
+ // with the same name (but using a different case), the last one will be used.
122
+
123
+ // new Headers({ 'content-type': 'foo', 'Content-Type': 'bar' }).get('content-type')
124
+ // => 'foo, bar'
125
+ const headers = new Headers()
126
+
127
+ if (opts?.headers) {
128
+ for (const name in opts.headers) {
129
+ if (headers.has(name)) {
130
+ throw new TypeError(`Duplicate header: ${name}`)
131
+ }
132
+
133
+ const value = opts.headers[name]
134
+ if (value != null) {
135
+ headers.set(name, value)
136
+ }
137
+ }
138
+ }
139
+
103
140
  if (schema.type === 'procedure') {
104
141
  if (opts?.encoding) {
105
- headers['Content-Type'] = opts.encoding
106
- }
107
- if (data && typeof data === 'object') {
108
- if (!headers['Content-Type']) {
109
- headers['Content-Type'] = 'application/json'
142
+ headers.set('content-type', opts.encoding)
143
+ } else if (!headers.has('content-type') && typeof data !== 'undefined') {
144
+ // Special handling of BodyInit types before falling back to JSON encoding
145
+ if (
146
+ data instanceof ArrayBuffer ||
147
+ data instanceof ReadableStream ||
148
+ ArrayBuffer.isView(data)
149
+ ) {
150
+ headers.set('content-type', 'application/octet-stream')
151
+ } else if (data instanceof FormData) {
152
+ // Note: The multipart form data boundary is missing from the header
153
+ // we set here, making that header invalid. This special case will be
154
+ // handled in encodeMethodCallBody()
155
+ headers.set('content-type', 'multipart/form-data')
156
+ } else if (data instanceof URLSearchParams) {
157
+ headers.set(
158
+ 'content-type',
159
+ 'application/x-www-form-urlencoded;charset=UTF-8',
160
+ )
161
+ } else if (isBlobLike(data)) {
162
+ headers.set('content-type', data.type || 'application/octet-stream')
163
+ } else if (typeof data === 'string') {
164
+ headers.set('content-type', 'text/plain;charset=UTF-8')
165
+ }
166
+ // At this point, data is not a valid BodyInit type.
167
+ else if (isIterable(data)) {
168
+ headers.set('content-type', 'application/octet-stream')
169
+ } else if (
170
+ typeof data === 'boolean' ||
171
+ typeof data === 'number' ||
172
+ typeof data === 'string' ||
173
+ typeof data === 'object' // covers "null"
174
+ ) {
175
+ headers.set('content-type', 'application/json')
176
+ } else {
177
+ // symbol, function, bigint
178
+ throw new XRPCError(
179
+ ResponseType.InvalidRequest,
180
+ `Unsupported data type: ${typeof data}`,
181
+ )
110
182
  }
111
183
  }
112
184
  }
113
185
  return headers
114
186
  }
115
187
 
188
+ export function combineHeaders(
189
+ headersInit: undefined | HeadersInit,
190
+ defaultHeaders?: Iterable<[string, undefined | Gettable<null | string>]>,
191
+ ): undefined | HeadersInit {
192
+ if (!defaultHeaders) return headersInit
193
+
194
+ let headers: Headers | undefined = undefined
195
+
196
+ for (const [key, getter] of defaultHeaders) {
197
+ // Ignore undefined values (allowed for convenience when using
198
+ // Object.entries).
199
+ if (getter === undefined) continue
200
+
201
+ // Lazy initialization of the headers object
202
+ headers ??= new Headers(headersInit)
203
+
204
+ if (headers.has(key)) continue
205
+
206
+ const value = typeof getter === 'function' ? getter() : getter
207
+
208
+ if (typeof value === 'string') headers.set(key, value)
209
+ else if (value === null) headers.delete(key)
210
+ else throw new TypeError(`Invalid "${key}" header value: ${typeof value}`)
211
+ }
212
+
213
+ return headers ?? headersInit
214
+ }
215
+
216
+ function isBlobLike(value: unknown): value is Blob {
217
+ if (value == null) return false
218
+ if (typeof value !== 'object') return false
219
+ if (typeof Blob === 'function' && value instanceof Blob) return true
220
+
221
+ // Support for Blobs provided by libraries that don't use the native Blob
222
+ // (e.g. fetch-blob from node-fetch).
223
+ // https://github.com/node-fetch/fetch-blob/blob/a1a182e5978811407bef4ea1632b517567dda01f/index.js#L233-L244
224
+
225
+ const tag = value[Symbol.toStringTag]
226
+ if (tag === 'Blob' || tag === 'File') {
227
+ return 'stream' in value && typeof value.stream === 'function'
228
+ }
229
+
230
+ return false
231
+ }
232
+
233
+ export function isBodyInit(value: unknown): value is BodyInit {
234
+ switch (typeof value) {
235
+ case 'string':
236
+ return true
237
+ case 'object':
238
+ return (
239
+ value instanceof ArrayBuffer ||
240
+ value instanceof FormData ||
241
+ value instanceof URLSearchParams ||
242
+ value instanceof ReadableStream ||
243
+ ArrayBuffer.isView(value) ||
244
+ isBlobLike(value)
245
+ )
246
+ default:
247
+ return false
248
+ }
249
+ }
250
+
251
+ export function isIterable(
252
+ value: unknown,
253
+ ): value is Iterable<unknown> | AsyncIterable<unknown> {
254
+ return (
255
+ value != null &&
256
+ typeof value === 'object' &&
257
+ (Symbol.iterator in value || Symbol.asyncIterator in value)
258
+ )
259
+ }
260
+
116
261
  export function encodeMethodCallBody(
117
262
  headers: Headers,
118
- data?: any,
119
- ): ArrayBuffer | undefined {
120
- if (!headers['content-type'] || typeof data === 'undefined') {
263
+ data?: unknown,
264
+ ): BodyInit | undefined {
265
+ // Silently ignore the body if there is no content-type header.
266
+ const contentType = headers.get('content-type')
267
+ if (!contentType) {
121
268
  return undefined
122
269
  }
123
- if (data instanceof ArrayBuffer) {
270
+
271
+ if (typeof data === 'undefined') {
272
+ // This error would be returned by the server, but we can catch it earlier
273
+ // to avoid un-necessary requests. Note that a content-length of 0 does not
274
+ // necessary mean that the body is "empty" (e.g. an empty txt file).
275
+ throw new XRPCError(
276
+ ResponseType.InvalidRequest,
277
+ `A request body is expected but none was provided`,
278
+ )
279
+ }
280
+
281
+ if (isBodyInit(data)) {
282
+ if (data instanceof FormData && contentType === 'multipart/form-data') {
283
+ // fetch() will encode FormData payload itself, but it won't override the
284
+ // content-type header if already present. This would cause the boundary
285
+ // to be missing from the content-type header, resulting in a 400 error.
286
+ // Deleting the content-type header here to let fetch() re-create it.
287
+ headers.delete('content-type')
288
+ }
289
+
290
+ // Will be encoded by the fetch API.
124
291
  return data
125
292
  }
126
- if (headers['content-type'].startsWith('text/')) {
127
- return new TextEncoder().encode(data.toString())
293
+
294
+ if (isIterable(data)) {
295
+ // Note that some environments support using Iterable & AsyncIterable as the
296
+ // body (e.g. Node's fetch), but not all of them do (browsers).
297
+ return iterableToReadableStream(data)
128
298
  }
129
- if (headers['content-type'].startsWith('application/json')) {
130
- return new TextEncoder().encode(stringifyLex(data))
299
+
300
+ if (contentType.startsWith('text/')) {
301
+ return new TextEncoder().encode(String(data))
131
302
  }
132
- return data
303
+ if (contentType.startsWith('application/json')) {
304
+ const json = stringifyLex(data)
305
+ // Server would return a 400 error if the JSON is invalid (e.g. trying to
306
+ // JSONify a function, or an object that implements toJSON() poorly).
307
+ if (json === undefined) {
308
+ throw new XRPCError(
309
+ ResponseType.InvalidRequest,
310
+ `Failed to encode request body as JSON`,
311
+ )
312
+ }
313
+ return new TextEncoder().encode(json)
314
+ }
315
+
316
+ // At this point, "data" is not a valid BodyInit value, and we don't know how
317
+ // to encode it into one. Passing it to fetch would result in an error. Let's
318
+ // throw our own error instead.
319
+
320
+ const type =
321
+ !data || typeof data !== 'object'
322
+ ? typeof data
323
+ : data.constructor !== Object &&
324
+ typeof data.constructor === 'function' &&
325
+ typeof data.constructor?.name === 'string'
326
+ ? data.constructor.name
327
+ : 'object'
328
+
329
+ throw new XRPCError(
330
+ ResponseType.InvalidRequest,
331
+ `Unable to encode ${type} as ${contentType} data`,
332
+ )
133
333
  }
134
334
 
135
- export function httpResponseCodeToEnum(status: number): ResponseType {
136
- let resCode: ResponseType
137
- if (status in ResponseType) {
138
- resCode = status
139
- } else if (status >= 100 && status < 200) {
140
- resCode = ResponseType.XRPCNotSupported
141
- } else if (status >= 200 && status < 300) {
142
- resCode = ResponseType.Success
143
- } else if (status >= 300 && status < 400) {
144
- resCode = ResponseType.XRPCNotSupported
145
- } else if (status >= 400 && status < 500) {
146
- resCode = ResponseType.InvalidRequest
147
- } else {
148
- resCode = ResponseType.InternalServerError
149
- }
150
- return resCode
335
+ /**
336
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static}
337
+ */
338
+ function iterableToReadableStream(
339
+ iterable: Iterable<unknown> | AsyncIterable<unknown>,
340
+ ): ReadableStream<Uint8Array> {
341
+ // Use the native ReadableStream.from() if available.
342
+ if ('from' in ReadableStream && typeof ReadableStream.from === 'function') {
343
+ return ReadableStream.from(iterable)
344
+ }
345
+
346
+ // Note, in environments where ReadableStream is not available either, we
347
+ // *could* load the iterable into memory and create an Arraybuffer from it.
348
+ // However, this would be a bad idea for large iterables. In order to keep
349
+ // things simple, we'll just allow the anonymous ReadableStream constructor
350
+ // to throw an error in those environments, hinting the user of the lib to find
351
+ // an alternate solution in that case (e.g. use a Blob if available).
352
+
353
+ let generator: AsyncGenerator<unknown, void, undefined>
354
+ return new ReadableStream<Uint8Array>({
355
+ type: 'bytes',
356
+ start() {
357
+ // Wrap the iterable in an async generator to handle both sync and async
358
+ // iterables, and make sure that the return() method exists.
359
+ generator = (async function* () {
360
+ yield* iterable
361
+ })()
362
+ },
363
+ async pull(controller: ReadableStreamDefaultController) {
364
+ const { done, value } = await generator.next()
365
+ if (done) {
366
+ controller.close()
367
+ } else {
368
+ try {
369
+ const buf = toUint8Array(value)
370
+ if (buf) controller.enqueue(buf)
371
+ } catch (cause) {
372
+ // ReadableStream won't call cancel() if the stream is errored.
373
+ await generator.return()
374
+
375
+ controller.error(
376
+ new TypeError(
377
+ 'Converting iterable body to ReadableStream requires Buffer, ArrayBuffer or string values',
378
+ { cause },
379
+ ),
380
+ )
381
+ }
382
+ }
383
+ },
384
+ async cancel() {
385
+ await generator.return()
386
+ },
387
+ })
151
388
  }
152
389
 
390
+ // Browsers don't have Buffer. This syntax is to avoid bundlers from including
391
+ // a Buffer polyfill in the bundle if it's not used elsewhere.
392
+ const globalName = `${{ toString: () => 'Buf' }}fer` as 'Buffer'
393
+ const Buffer =
394
+ typeof globalThis[globalName] === 'function'
395
+ ? globalThis[globalName]
396
+ : undefined
397
+
398
+ const toUint8Array: (value: unknown) => Uint8Array | undefined = Buffer
399
+ ? (value) => {
400
+ // @ts-expect-error Buffer.from will throw if value is not a valid input
401
+ const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)
402
+ return buf.byteLength ? new Uint8Array(buf) : undefined
403
+ }
404
+ : (value) => {
405
+ if (value instanceof ArrayBuffer) {
406
+ const buf = new Uint8Array(value)
407
+ return buf.byteLength ? buf : undefined
408
+ }
409
+
410
+ // Simulate Buffer.from() behavior for strings and and coercion
411
+ if (typeof value === 'string') {
412
+ return value.length ? new TextEncoder().encode(value) : undefined
413
+ } else if (typeof value?.valueOf === 'function') {
414
+ const coerced = value.valueOf()
415
+ if (coerced instanceof ArrayBuffer) {
416
+ const buf = new Uint8Array(coerced)
417
+ return buf.byteLength ? buf : undefined
418
+ } else if (typeof coerced === 'string') {
419
+ return coerced.length ? new TextEncoder().encode(coerced) : undefined
420
+ }
421
+ }
422
+
423
+ throw new TypeError(`Unable to convert "${typeof value}" to Uint8Array`)
424
+ }
425
+
153
426
  export function httpResponseBodyParse(
154
427
  mimeType: string | null,
155
428
  data: ArrayBuffer | undefined,
156
429
  ): any {
157
- if (mimeType) {
158
- if (mimeType.includes('application/json') && data?.byteLength) {
159
- try {
430
+ try {
431
+ if (mimeType) {
432
+ if (mimeType.includes('application/json')) {
160
433
  const str = new TextDecoder().decode(data)
161
434
  return jsonStringToLex(str)
162
- } catch (e) {
163
- throw new XRPCError(
164
- ResponseType.InvalidResponse,
165
- `Failed to parse response body: ${String(e)}`,
166
- )
167
435
  }
168
- }
169
- if (mimeType.startsWith('text/') && data?.byteLength) {
170
- try {
436
+ if (mimeType.startsWith('text/')) {
171
437
  return new TextDecoder().decode(data)
172
- } catch (e) {
173
- throw new XRPCError(
174
- ResponseType.InvalidResponse,
175
- `Failed to parse response body: ${String(e)}`,
176
- )
177
438
  }
178
439
  }
440
+ if (data instanceof ArrayBuffer) {
441
+ return new Uint8Array(data)
442
+ }
443
+ return data
444
+ } catch (cause) {
445
+ throw new XRPCError(
446
+ ResponseType.InvalidResponse,
447
+ undefined,
448
+ `Failed to parse response body: ${String(cause)}`,
449
+ undefined,
450
+ { cause },
451
+ )
179
452
  }
180
- if (data instanceof ArrayBuffer) {
181
- return new Uint8Array(data)
182
- }
183
- return data
184
453
  }
@@ -0,0 +1,108 @@
1
+ import { LexiconDoc, Lexicons, ValidationError } from '@atproto/lexicon'
2
+ import {
3
+ FetchHandler,
4
+ FetchHandlerOptions,
5
+ buildFetchHandler,
6
+ } from './fetch-handler'
7
+ import {
8
+ CallOptions,
9
+ QueryParams,
10
+ ResponseType,
11
+ XRPCError,
12
+ XRPCInvalidResponseError,
13
+ XRPCResponse,
14
+ httpResponseCodeToEnum,
15
+ } from './types'
16
+ import {
17
+ constructMethodCallHeaders,
18
+ constructMethodCallUrl,
19
+ encodeMethodCallBody,
20
+ getMethodSchemaHTTPMethod,
21
+ httpResponseBodyParse,
22
+ isErrorResponseBody,
23
+ } from './util'
24
+
25
+ export class XrpcClient {
26
+ readonly fetchHandler: FetchHandler
27
+ readonly lex: Lexicons
28
+
29
+ constructor(
30
+ fetchHandler: FetchHandler | FetchHandlerOptions,
31
+ lex: Lexicons | Iterable<LexiconDoc>,
32
+ ) {
33
+ this.fetchHandler =
34
+ typeof fetchHandler === 'function'
35
+ ? fetchHandler
36
+ : buildFetchHandler(fetchHandler)
37
+
38
+ this.lex = lex instanceof Lexicons ? lex : new Lexicons(lex)
39
+ }
40
+
41
+ async call(
42
+ methodNsid: string,
43
+ params?: QueryParams,
44
+ data?: unknown,
45
+ opts?: CallOptions,
46
+ ): Promise<XRPCResponse> {
47
+ const def = this.lex.getDefOrThrow(methodNsid)
48
+ if (!def || (def.type !== 'query' && def.type !== 'procedure')) {
49
+ throw new TypeError(
50
+ `Invalid lexicon: ${methodNsid}. Must be a query or procedure.`,
51
+ )
52
+ }
53
+
54
+ //@TODO: should we validate the params and data here?
55
+ // this.lex.assertValidXrpcParams(methodNsid, params)
56
+ // if (data !== undefined) {
57
+ // this.lex.assertValidXrpcInput(methodNsid, data)
58
+ // }
59
+
60
+ const reqUrl = constructMethodCallUrl(methodNsid, def, params)
61
+ const reqMethod = getMethodSchemaHTTPMethod(def)
62
+ const reqHeaders = constructMethodCallHeaders(def, data, opts)
63
+ const reqBody = encodeMethodCallBody(reqHeaders, data)
64
+
65
+ // The duplex field is required for streaming bodies, but not yet reflected
66
+ // anywhere in docs or types. See whatwg/fetch#1438, nodejs/node#46221.
67
+ const init: RequestInit & { duplex: 'half' } = {
68
+ method: reqMethod,
69
+ headers: reqHeaders,
70
+ body: reqBody,
71
+ duplex: 'half',
72
+ signal: opts?.signal,
73
+ }
74
+
75
+ try {
76
+ const response = await this.fetchHandler.call(undefined, reqUrl, init)
77
+
78
+ const resStatus = response.status
79
+ const resHeaders = Object.fromEntries(response.headers.entries())
80
+ const resBodyBytes = await response.arrayBuffer()
81
+ const resBody = httpResponseBodyParse(
82
+ response.headers.get('content-type'),
83
+ resBodyBytes,
84
+ )
85
+
86
+ const resCode = httpResponseCodeToEnum(resStatus)
87
+ if (resCode !== ResponseType.Success) {
88
+ const { error = undefined, message = undefined } =
89
+ resBody && isErrorResponseBody(resBody) ? resBody : {}
90
+ throw new XRPCError(resCode, error, message, resHeaders)
91
+ }
92
+
93
+ try {
94
+ this.lex.assertValidXrpcOutput(methodNsid, resBody)
95
+ } catch (e: unknown) {
96
+ if (e instanceof ValidationError) {
97
+ throw new XRPCInvalidResponseError(methodNsid, e, resBody)
98
+ }
99
+
100
+ throw e
101
+ }
102
+
103
+ return new XRPCResponse(resBody, resHeaders)
104
+ } catch (err) {
105
+ throw XRPCError.from(err)
106
+ }
107
+ }
108
+ }