@atproto/lex-client 0.0.16 → 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/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
+ }
package/src/util.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
- buildAtprotoHeaders,
3
+ buildXrpcRequestHeaders,
4
4
  isAsyncIterable,
5
5
  isBlobLike,
6
6
  toReadableStream,
@@ -111,24 +111,24 @@ describe(isAsyncIterable, () => {
111
111
  })
112
112
 
113
113
  // ============================================================================
114
- // buildAtprotoHeaders
114
+ // buildXrpcRequestHeaders
115
115
  // ============================================================================
116
116
 
117
- describe(buildAtprotoHeaders, () => {
117
+ describe(buildXrpcRequestHeaders, () => {
118
118
  it('returns empty headers when no options are set', () => {
119
- const headers = buildAtprotoHeaders({})
119
+ const headers = buildXrpcRequestHeaders({})
120
120
  expect([...headers.entries()]).toEqual([])
121
121
  })
122
122
 
123
123
  it('sets atproto-proxy header from service option', () => {
124
- const headers = buildAtprotoHeaders({
124
+ const headers = buildXrpcRequestHeaders({
125
125
  service: 'did:plc:1234#atproto_labeler',
126
126
  })
127
127
  expect(headers.get('atproto-proxy')).toBe('did:plc:1234#atproto_labeler')
128
128
  })
129
129
 
130
130
  it('does not override existing atproto-proxy header', () => {
131
- const headers = buildAtprotoHeaders({
131
+ const headers = buildXrpcRequestHeaders({
132
132
  headers: { 'atproto-proxy': 'did:plc:existing#service' },
133
133
  service: 'did:plc:new#service',
134
134
  })
@@ -136,7 +136,7 @@ describe(buildAtprotoHeaders, () => {
136
136
  })
137
137
 
138
138
  it('sets atproto-accept-labelers from labelers option', () => {
139
- const headers = buildAtprotoHeaders({
139
+ const headers = buildXrpcRequestHeaders({
140
140
  labelers: ['did:plc:labeler1', 'did:plc:labeler2'] as const,
141
141
  })
142
142
  expect(headers.get('atproto-accept-labelers')).toBe(
@@ -145,7 +145,7 @@ describe(buildAtprotoHeaders, () => {
145
145
  })
146
146
 
147
147
  it('appends to existing atproto-accept-labelers header', () => {
148
- const headers = buildAtprotoHeaders({
148
+ const headers = buildXrpcRequestHeaders({
149
149
  headers: { 'atproto-accept-labelers': 'did:plc:existing' },
150
150
  labelers: ['did:plc:new'] as const,
151
151
  })
@@ -155,7 +155,7 @@ describe(buildAtprotoHeaders, () => {
155
155
  })
156
156
 
157
157
  it('passes through base headers', () => {
158
- const headers = buildAtprotoHeaders({
158
+ const headers = buildXrpcRequestHeaders({
159
159
  headers: { Authorization: 'Bearer token123' },
160
160
  })
161
161
  expect(headers.get('Authorization')).toBe('Bearer token123')
@@ -163,12 +163,12 @@ describe(buildAtprotoHeaders, () => {
163
163
 
164
164
  it('accepts Headers instance as base headers', () => {
165
165
  const base = new Headers({ 'X-Custom': 'value' })
166
- const headers = buildAtprotoHeaders({ headers: base })
166
+ const headers = buildXrpcRequestHeaders({ headers: base })
167
167
  expect(headers.get('X-Custom')).toBe('value')
168
168
  })
169
169
 
170
170
  it('sets empty header for empty labelers iterable', () => {
171
- const headers = buildAtprotoHeaders({ labelers: [] })
171
+ const headers = buildXrpcRequestHeaders({ labelers: [] })
172
172
  // An empty array still sets the header (to empty string), distinguishing
173
173
  // "no labelers requested" from "labelers option not provided"
174
174
  expect(headers.has('atproto-accept-labelers')).toBe(true)
package/src/util.ts CHANGED
@@ -1,32 +1,23 @@
1
- import {
2
- DidString,
3
- InferMethodOutput,
4
- InferMethodOutputBody,
5
- Procedure,
6
- Query,
7
- } from '@atproto/lex-schema'
1
+ import type { DidString, Service } from './types.js'
8
2
 
9
- /**
10
- * The body type of an XRPC response, inferred from the method's output schema.
11
- *
12
- * For JSON responses, this is the parsed LexValue. For binary responses,
13
- * this is a Uint8Array.
14
- *
15
- * @typeParam M - The XRPC method type (Procedure or Query)
16
- */
17
- export type XrpcResponseBody<M extends Procedure | Query = Procedure | Query> =
18
- InferMethodOutputBody<M, Uint8Array>
3
+ export function applyDefaults<
4
+ TDefaults extends Record<string, unknown>,
5
+ TOptions extends {
6
+ [K in keyof TDefaults]?: TDefaults[K]
7
+ },
8
+ >(options: TOptions, defaults: TDefaults): TOptions & TDefaults {
9
+ const combined: Partial<TDefaults> = { ...options }
19
10
 
20
- /**
21
- * The full payload type of an XRPC response, including body and encoding.
22
- *
23
- * Returns `null` for methods that have no output.
24
- *
25
- * @typeParam M - The XRPC method type (Procedure or Query)
26
- */
27
- export type XrpcResponsePayload<
28
- M extends Procedure | Query = Procedure | Query,
29
- > = InferMethodOutput<M, Uint8Array>
11
+ // @NOTE We make sure that options with an explicit `undefined` value get the
12
+ // default, since spreading doesn't override with `undefined`.
13
+ for (const key of Object.keys(defaults) as (keyof typeof defaults)[]) {
14
+ if (options[key] === undefined) {
15
+ combined[key] = defaults[key]
16
+ }
17
+ }
18
+
19
+ return combined as TOptions & TDefaults
20
+ }
30
21
 
31
22
  /**
32
23
  * Type guard to check if a value is {@link Blob}-like.
@@ -64,6 +55,17 @@ export function isAsyncIterable<T>(
64
55
  )
65
56
  }
66
57
 
58
+ export type XrpcRequestHeadersOptions = {
59
+ /** Additional HTTP headers to include in the request. */
60
+ headers?: HeadersInit
61
+
62
+ /** Labeler DIDs to request labels from for content moderation. */
63
+ labelers?: Iterable<DidString>
64
+
65
+ /** Service proxy identifier for routing requests through a specific service. */
66
+ service?: Service
67
+ }
68
+
67
69
  /**
68
70
  * Builds HTTP headers for AT Protocol requests.
69
71
  *
@@ -71,17 +73,12 @@ export function isAsyncIterable<T>(
71
73
  * - `atproto-proxy`: Service routing header (if service is specified)
72
74
  * - `atproto-accept-labelers`: Comma-separated list of labeler DIDs
73
75
  *
74
- * @param options - Header building options
75
- * @param options.headers - Base headers to include
76
- * @param options.service - Service proxy identifier
77
- * @param options.labelers - Labeler DIDs to request labels from
76
+ * @see {@link XrpcRequestHeadersOptions}
78
77
  * @returns A new Headers object with AT Protocol headers added
79
78
  */
80
- export function buildAtprotoHeaders(options: {
81
- headers?: HeadersInit
82
- service?: `${DidString}#${string}`
83
- labelers?: Iterable<DidString>
84
- }): Headers {
79
+ export function buildXrpcRequestHeaders(
80
+ options: XrpcRequestHeadersOptions,
81
+ ): Headers {
85
82
  const headers = new Headers(options?.headers)
86
83
 
87
84
  if (options.service && !headers.has('atproto-proxy')) {