@atproto/lex-client 0.0.15 → 0.0.17

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/errors.ts CHANGED
@@ -7,12 +7,16 @@ import {
7
7
  ResultFailure,
8
8
  lexErrorDataSchema,
9
9
  } from '@atproto/lex-schema'
10
- import { XrpcResponsePayload } from './util.js'
10
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
11
+ import { Agent } from './agent.js'
12
+ import { XrpcUnknownResponsePayload } from './types.js'
11
13
  import {
12
14
  WWWAuthenticate,
13
15
  parseWWWAuthenticateHeader,
14
16
  } from './www-authenticate.js'
15
17
 
18
+ export type { XrpcUnknownResponsePayload }
19
+
16
20
  export type DownstreamError<N extends LexErrorCode = LexErrorCode> = {
17
21
  status: number
18
22
  headers?: Headers
@@ -66,7 +70,7 @@ export type XrpcErrorPayload<N extends LexErrorCode = LexErrorCode> = {
66
70
  * This function checks whether a given payload matches this schema.
67
71
  */
68
72
  export function isXrpcErrorPayload(
69
- payload: XrpcResponsePayload | null | undefined,
73
+ payload: XrpcUnknownResponsePayload | null | undefined,
70
74
  ): payload is XrpcErrorPayload {
71
75
  return (
72
76
  payload != null &&
@@ -290,7 +294,7 @@ export class XrpcUpstreamError<
290
294
  constructor(
291
295
  method: M,
292
296
  readonly response: Response,
293
- readonly payload: XrpcResponsePayload | null = null,
297
+ readonly payload: XrpcUnknownResponsePayload | null = null,
294
298
  message: string = `Unexpected upstream XRPC response`,
295
299
  options?: ErrorOptions,
296
300
  ) {
@@ -329,7 +333,7 @@ export class XrpcInvalidResponseError<
329
333
  constructor(
330
334
  method: M,
331
335
  response: Response,
332
- payload: XrpcResponsePayload,
336
+ payload: XrpcUnknownResponsePayload,
333
337
  readonly cause: LexValidationError,
334
338
  ) {
335
339
  super(method, response, payload, `Invalid response: ${cause.message}`, {
@@ -347,17 +351,11 @@ export class XrpcInvalidResponseError<
347
351
  }
348
352
 
349
353
  /**
350
- * Error class for internal/client-side errors during XRPC requests.
351
- *
352
- * This represents errors that occur before or during the request that are not
353
- * server responses, such as:
354
- * - Network errors (connection refused, DNS failure)
355
- * - Request timeouts
356
- * - Request aborted via AbortSignal
357
- * - Invalid request construction
354
+ * Error class for unexpected internal/client-side errors during XRPC requests.
358
355
  *
359
- * The error code is always 'InternalServerError' and these errors are
360
- * optimistically considered retryable.
356
+ * The error code is always 'InternalServerError' and these errors not
357
+ * considered retryable as they stem from unforeseen issues in the
358
+ * implementation.
361
359
  *
362
360
  * @typeParam M - The XRPC method type
363
361
  */
@@ -379,15 +377,11 @@ export class XrpcInternalError<
379
377
  return this
380
378
  }
381
379
 
382
- override shouldRetry(): true {
383
- // Ideally, we would inspect the reason to determine if it's retryable
384
- // (by detecting network errors, timeouts, etc.). Since these cases are
385
- // highly platform-dependent, we optimistically assume all internal
386
- // errors are retryable.
387
- return true
380
+ override shouldRetry(): boolean {
381
+ return false
388
382
  }
389
383
 
390
- override toJSON(): LexErrorData<'InternalServerError'> {
384
+ override toJSON(): LexErrorData {
391
385
  // @NOTE Do not expose internal error details to downstream clients
392
386
  return { error: this.error, message: 'Internal Server Error' }
393
387
  }
@@ -397,6 +391,49 @@ export class XrpcInternalError<
397
391
  }
398
392
  }
399
393
 
394
+ /**
395
+ * Special case of XrpcInternalError that specifically represents errors thrown
396
+ * by {@link Agent.fetchHandler} during the XRPC request. This includes:
397
+ * - Network errors (connection refused, DNS failure)
398
+ * - Request timeouts
399
+ * - Request aborted via AbortSignal
400
+ *
401
+ * These errors are optimistically considered retryable, as many fetch errors
402
+ * are transient and may succeed on retry.
403
+ */
404
+ export class XrpcFetchError<
405
+ M extends Procedure | Query = Procedure | Query,
406
+ > extends XrpcInternalError<M> {
407
+ name = 'XrpcFetchError'
408
+
409
+ constructor(method: M, cause: unknown) {
410
+ const message = cause instanceof Error ? cause.message : String(cause)
411
+ super(method, `Unexpected fetchHandler() error: ${message}`, { cause })
412
+ }
413
+
414
+ override shouldRetry(): boolean {
415
+ // Ideally, we would inspect the reason to determine if it's retryable (by
416
+ // detecting network errors, timeouts, etc.). Since these cases are highly
417
+ // platform-dependent, we optimistically assume all fetch errors are
418
+ // transient and retryable.
419
+ return true
420
+ }
421
+
422
+ override toJSON(): LexErrorData {
423
+ // @NOTE Do not expose internal error details to downstream clients
424
+ return { error: this.error, message: 'Failed to perform upstream request' }
425
+ }
426
+
427
+ override toDownstreamError(): DownstreamError {
428
+ // While it might technically be a 500 error, we use 502 Bad Gateway here to
429
+ // indicate that the error occurred while communicating with the upstream
430
+ // server, allowing downstream clients to distinguish between errors in our
431
+ // internal processing (500) and errors in the upstream server or network
432
+ // (502).
433
+ return { status: 502, body: this.toJSON() }
434
+ }
435
+ }
436
+
400
437
  /**
401
438
  * Union type of all possible XRPC failure types.
402
439
  *
package/src/response.ts CHANGED
@@ -1,9 +1,13 @@
1
- import { lexParse } from '@atproto/lex-json'
1
+ import { LexParseOptions, lexParse } from '@atproto/lex-json'
2
2
  import {
3
3
  InferMethodOutputEncoding,
4
+ InferOutput,
5
+ LexValue,
6
+ Payload,
4
7
  Procedure,
5
8
  Query,
6
9
  ResultSuccess,
10
+ Validator,
7
11
  } from '@atproto/lex-schema'
8
12
  import {
9
13
  XrpcAuthenticationError,
@@ -12,12 +16,104 @@ import {
12
16
  XrpcUpstreamError,
13
17
  isXrpcErrorPayload,
14
18
  } from './errors.js'
15
- import { XrpcResponseBody, XrpcResponsePayload } from './util.js'
19
+ import {
20
+ EncodingString,
21
+ XrpcUnknownResponsePayload,
22
+ isEncodingString,
23
+ } from './types.js'
16
24
 
17
25
  const CONTENT_TYPE_BINARY = 'application/octet-stream'
18
26
  const CONTENT_TYPE_JSON = 'application/json'
19
27
 
20
- export type { XrpcResponseBody, XrpcResponsePayload }
28
+ // @NOTE the output schema is used in "parse" mode (safeParse), which means that
29
+ // defaults will be applied and coercions will be performed, so we need to use
30
+ // InferOutput here to get the final parsed type, not Infer/InferInput. For this
31
+ // reason, we cannot use InferMethodOutputBody and InferMethodOutput from
32
+ // lex-schema here.
33
+
34
+ type InferEncodingType<TEncoding extends string> = TEncoding extends '*/*'
35
+ ? EncodingString
36
+ : TEncoding extends `${infer T extends string}/*`
37
+ ? `${T}/${string}`
38
+ : TEncoding
39
+
40
+ type InferBodyType<
41
+ TEncoding extends string,
42
+ TSchema,
43
+ > = TSchema extends Validator
44
+ ? InferOutput<TSchema>
45
+ : TEncoding extends `application/json`
46
+ ? LexValue
47
+ : Uint8Array
48
+
49
+ /**
50
+ * The body type of an XRPC response, inferred from the method's output schema.
51
+ *
52
+ * For JSON responses, this is the parsed LexValue. For binary responses,
53
+ * this is a Uint8Array.
54
+ *
55
+ * @typeParam M - The XRPC method type (Procedure or Query)
56
+ */
57
+ export type XrpcResponseBody<M extends Procedure | Query> =
58
+ M['output'] extends Payload<infer TEncoding, infer TSchema>
59
+ ? TEncoding extends string
60
+ ? InferBodyType<TEncoding, TSchema>
61
+ : undefined
62
+ : never
63
+
64
+ /**
65
+ * The full payload type of an XRPC response, including body and encoding.
66
+ *
67
+ * Returns `null` for methods that have no output.
68
+ *
69
+ * @typeParam M - The XRPC method type (Procedure or Query)
70
+ */
71
+ export type XrpcResponsePayload<M extends Procedure | Query> =
72
+ M['output'] extends Payload<infer TEncoding, infer TSchema>
73
+ ? TEncoding extends string
74
+ ? {
75
+ encoding: InferEncodingType<TEncoding>
76
+ body: InferBodyType<TEncoding, TSchema>
77
+ }
78
+ : undefined
79
+ : never
80
+
81
+ export type XrpcResponseOptions = {
82
+ /**
83
+ * Whether to validate the response against the method's output schema.
84
+ * Disabling this can improve performance but may lead to runtime errors if
85
+ * the response does not conform to the expected schema. Only set this to
86
+ * `false` if you are certain that the upstream service will always return
87
+ * valid responses.
88
+ *
89
+ * @default true
90
+ */
91
+ validateResponse?: boolean
92
+
93
+ /**
94
+ * Whether to strictly process response payloads according to Lex encoding
95
+ * rules. By default, the client will reject responses with invalid Lex data
96
+ * (floats and invalid $bytes / $link objects).
97
+ *
98
+ * Setting this option to `false` will allow the client to accept such
99
+ * responses in a non-strict mode, where invalid Lex data will be returned
100
+ * as-is (e.g., floats will not be rejected, and invalid $bytes / $link
101
+ * objects will not be converted to Uint8Array / Cid). When in non-strict
102
+ * mode, the validation will also be relaxed when validating the response
103
+ * against the method's output schema, allowing values that do not strictly
104
+ * conform to the schema (e.g. datetime strings that are not valid RFC3339
105
+ * format, blobs that are not of the right size/mime-type, etc.) to be
106
+ * accepted as long as their basic structure is correct.
107
+ *
108
+ * When validation is enabled (the default), the values defined through the
109
+ * method schema will be enforced, ensuring that the client can still process
110
+ * the response even if the server returns invalid Lex data.
111
+ *
112
+ * @default true
113
+ * @see {@link LexParseOptions.strict}
114
+ */
115
+ strictResponseProcessing?: boolean
116
+ }
21
117
 
22
118
  /**
23
119
  * Small container for XRPC response data.
@@ -80,7 +176,7 @@ export class XrpcResponse<M extends Procedure | Query>
80
176
  static async fromFetchResponse<const M extends Procedure | Query>(
81
177
  method: M,
82
178
  response: Response,
83
- options?: { validateResponse?: boolean },
179
+ options?: XrpcResponseOptions,
84
180
  ): Promise<XrpcResponse<M>> {
85
181
  // @NOTE The body MUST either be read or canceled to avoid resource leaks.
86
182
  // Since nothing should cause an exception before "readPayload" is
@@ -89,17 +185,9 @@ export class XrpcResponse<M extends Procedure | Query>
89
185
  // @NOTE redirect is set to 'follow', so we shouldn't get 3xx responses here
90
186
  if (response.status < 200 || response.status >= 300) {
91
187
  // Always parse json for error responses
92
- const payload = await readPayload(response, { parse: true }).catch(
93
- (cause) => {
94
- throw new XrpcUpstreamError(
95
- method,
96
- response,
97
- null,
98
- 'Unable to parse response payload',
99
- { cause },
100
- )
101
- },
102
- )
188
+ const payload = await readPayload(method, response, {
189
+ parse: { strict: options?.strictResponseProcessing ?? true },
190
+ })
103
191
 
104
192
  // Properly formatted XRPC error response ?
105
193
  if (response.status >= 400 && isXrpcErrorPayload(payload)) {
@@ -121,17 +209,11 @@ export class XrpcResponse<M extends Procedure | Query>
121
209
  )
122
210
  }
123
211
 
124
- // Only parse json if the schema expects it
125
- const payload = await readPayload(response, {
126
- parse: method.output.encoding === CONTENT_TYPE_JSON,
127
- }).catch((cause) => {
128
- throw new XrpcUpstreamError(
129
- method,
130
- response,
131
- null,
132
- 'Unable to parse response payload',
133
- { cause },
134
- )
212
+ const payload = await readPayload(method, response, {
213
+ // Only parse json if the schema expects it
214
+ parse: method.output.encoding === CONTENT_TYPE_JSON && {
215
+ strict: options?.strictResponseProcessing ?? true,
216
+ },
135
217
  })
136
218
 
137
219
  // Response is successful (2xx). Validate payload (data and encoding) against schema.
@@ -160,7 +242,9 @@ export class XrpcResponse<M extends Procedure | Query>
160
242
 
161
243
  // Assert valid response body.
162
244
  if (method.output.schema && options?.validateResponse !== false) {
163
- const result = method.output.schema.safeParse(payload.body)
245
+ const result = method.output.schema.safeParse(payload.body, {
246
+ strict: options?.strictResponseProcessing ?? true,
247
+ })
164
248
 
165
249
  if (!result.success) {
166
250
  throw new XrpcInvalidResponseError(
@@ -170,6 +254,18 @@ export class XrpcResponse<M extends Procedure | Query>
170
254
  result.reason,
171
255
  )
172
256
  }
257
+
258
+ const parsedPayload = {
259
+ body: result.value,
260
+ encoding: payload.encoding,
261
+ } as XrpcResponsePayload<M>
262
+
263
+ return new XrpcResponse<M>(
264
+ method,
265
+ response.status,
266
+ response.headers,
267
+ parsedPayload,
268
+ )
173
269
  }
174
270
  }
175
271
 
@@ -182,50 +278,77 @@ export class XrpcResponse<M extends Procedure | Query>
182
278
  }
183
279
  }
184
280
 
281
+ type ReadPayloadOptions = {
282
+ /**
283
+ * Whether to parse the response body as JSON and convert it to LexValue.
284
+ *
285
+ * @default false
286
+ */
287
+ parse?: false | LexParseOptions
288
+ }
289
+
185
290
  /**
186
291
  * @note this function always consumes the response body
187
292
  */
188
293
  async function readPayload(
294
+ method: Query | Procedure,
189
295
  response: Response,
190
- options?: { parse?: boolean },
191
- ): Promise<XrpcResponsePayload> {
192
- // @TODO Should we limit the maximum response size here (this could also be
193
- // done by the FetchHandler)?
194
-
195
- const encoding = response.headers
196
- .get('content-type')
197
- ?.split(';')[0]
198
- .trim()
199
- .toLowerCase()
200
-
201
- // Response content-type is undefined
202
- if (!encoding) {
203
- // If the body is empty, return undefined (= no payload)
204
- const body = await response.arrayBuffer()
205
- if (body.byteLength === 0) return undefined
206
-
207
- // If we got data despite no content-type, treat it as binary
208
- return {
209
- encoding: CONTENT_TYPE_BINARY,
210
- body: new Uint8Array(body),
296
+ options?: ReadPayloadOptions,
297
+ ): Promise<undefined | XrpcUnknownResponsePayload> {
298
+ try {
299
+ // @TODO Should we limit the maximum response size here (this could also be
300
+ // done by the FetchHandler)?
301
+
302
+ const encoding = response.headers
303
+ .get('content-type')
304
+ ?.split(';')[0]
305
+ .trim()
306
+ .toLowerCase()
307
+
308
+ // Response content-type is undefined
309
+ if (!encoding) {
310
+ // If the body is empty, return undefined (= no payload)
311
+ const arrayBuffer = await response.arrayBuffer()
312
+ if (arrayBuffer.byteLength === 0) return undefined
313
+
314
+ // If we got data despite no content-type, treat it as binary
315
+ return {
316
+ encoding: CONTENT_TYPE_BINARY,
317
+ body: new Uint8Array(arrayBuffer),
318
+ }
211
319
  }
212
- }
213
320
 
214
- if (options?.parse && encoding === CONTENT_TYPE_JSON) {
215
- // @NOTE It might be worth returning the raw bytes here (Uint8Array) and
216
- // perform the lex parsing using cborg/json, allowing to do
217
- // bytes->LexValue in one step instead of bytes->text->JSON->LexValue.
218
- // This would require adding encode/decode utilities to lex-json (similar
219
- // to @ipld/dag-json)
220
- const text = await response.text()
321
+ if (!isEncodingString(encoding)) {
322
+ throw new TypeError(`Invalid content-type "${encoding}" in response`)
323
+ }
221
324
 
222
- // @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
223
- // using a reviver function during JSON.parse should be faster than
224
- // parsing to JSON then converting to Lex (?)
325
+ if (options?.parse && encoding === CONTENT_TYPE_JSON) {
326
+ // @NOTE It might be worth returning the raw bytes here (Uint8Array) and
327
+ // perform the lex parsing using cborg/json, allowing to do
328
+ // bytes->LexValue in one step instead of bytes->text->JSON->LexValue.
329
+ // This would require adding encode/decode utilities to lex-json (similar
330
+ // to @ipld/dag-json)
331
+ const text = await response.text()
225
332
 
226
- // @TODO verify statement above
227
- return { encoding, body: lexParse(text) }
228
- }
333
+ // @NOTE Using `lexParse(text)` (instead of `jsonToLex(json)`) here as
334
+ // using a reviver function during JSON.parse should be faster than
335
+ // parsing to JSON then converting to Lex (?)
336
+
337
+ // @TODO verify statement above
338
+ return { encoding, body: lexParse(text, options.parse) }
339
+ }
229
340
 
230
- return { encoding, body: new Uint8Array(await response.arrayBuffer()) }
341
+ const arrayBuffer = await response.arrayBuffer()
342
+ return { encoding, body: new Uint8Array(arrayBuffer) }
343
+ } catch (cause) {
344
+ const message = 'Unable to parse response payload'
345
+ const messageDetail = cause instanceof TypeError ? cause.message : undefined
346
+ throw new XrpcUpstreamError(
347
+ method,
348
+ response,
349
+ null,
350
+ messageDetail ? `${message}: ${messageDetail}` : message,
351
+ { cause },
352
+ )
353
+ }
231
354
  }
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { DidString, UnknownString } from '@atproto/lex-schema'
1
+ import { DidString, LexValue, UnknownString } from '@atproto/lex-schema'
2
2
 
3
- export type { DidString, UnknownString }
3
+ export type { DidString, LexValue, UnknownString }
4
4
 
5
5
  /**
6
6
  * Service identifier fragment for DID service endpoints.
@@ -22,44 +22,6 @@ export type DidServiceIdentifier = 'atproto_labeler' | UnknownString
22
22
  */
23
23
  export type Service = `${DidString}#${DidServiceIdentifier}`
24
24
 
25
- /**
26
- * Common options available for all XRPC calls.
27
- *
28
- * These options can be passed to any method that makes XRPC requests,
29
- * including `xrpc()`, `call()`, and record operations.
30
- */
31
- export type CallOptions = {
32
- /** Labeler DIDs to request labels from for content moderation. */
33
- labelers?: Iterable<DidString>
34
- /** AbortSignal to cancel the request. */
35
- signal?: AbortSignal
36
- /** Additional HTTP headers to include in the request. */
37
- headers?: HeadersInit
38
- /** Service proxy identifier for routing requests through a specific service. */
39
- service?: Service
40
-
41
- /**
42
- * Whether to validate the request against the method's input schema. Enabling
43
- * this can help catch errors early but may have a performance cost. This
44
- * would typically only be set to `true` in development or debugging
45
- * scenarios.
46
- *
47
- * @default false
48
- */
49
- validateRequest?: boolean
50
-
51
- /**
52
- * Whether to validate the response against the method's output schema.
53
- * Disabling this can improve performance but may lead to runtime errors if
54
- * the response does not conform to the expected schema. Only set this to
55
- * `false` if you are certain that the upstream service will always return
56
- * valid responses.
57
- *
58
- * @default true
59
- */
60
- validateResponse?: boolean
61
- }
62
-
63
25
  /**
64
26
  * Valid input types for binary request bodies.
65
27
  *
@@ -92,3 +54,18 @@ export type BinaryBodyInit =
92
54
  | ReadableStream<Uint8Array>
93
55
  | AsyncIterable<Uint8Array>
94
56
  | string
57
+
58
+ export type EncodingString = `${string}/${string}`
59
+
60
+ export function isEncodingString(
61
+ contentType: string,
62
+ ): contentType is EncodingString {
63
+ return contentType.includes('/')
64
+ }
65
+
66
+ export type XrpcUnknownResponsePayload<
67
+ TBinary extends BinaryBodyInit = Uint8Array,
68
+ > = {
69
+ encoding: EncodingString
70
+ body: LexValue | TBinary
71
+ }