@atproto/lex-client 0.0.13 → 0.0.15

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
@@ -1,6 +1,7 @@
1
1
  import { LexError, LexErrorCode, LexErrorData } from '@atproto/lex-data'
2
2
  import {
3
3
  InferMethodError,
4
+ LexValidationError,
4
5
  Procedure,
5
6
  Query,
6
7
  ResultFailure,
@@ -12,6 +13,13 @@ import {
12
13
  parseWWWAuthenticateHeader,
13
14
  } from './www-authenticate.js'
14
15
 
16
+ export type DownstreamError<N extends LexErrorCode = LexErrorCode> = {
17
+ status: number
18
+ headers?: Headers
19
+ encoding?: 'application/json'
20
+ body: LexErrorData<N>
21
+ }
22
+
15
23
  /**
16
24
  * HTTP status codes that indicate a transient error that may succeed on retry.
17
25
  *
@@ -115,7 +123,9 @@ export abstract class XrpcError<
115
123
  */
116
124
  abstract shouldRetry(): boolean
117
125
 
118
- matchesSchema(): this is XrpcError<M, InferMethodError<M>> {
126
+ abstract toDownstreamError(): DownstreamError
127
+
128
+ matchesSchemaErrors(): this is XrpcError<M, InferMethodError<M>> {
119
129
  return this.method.errors?.includes(this.error) ?? false
120
130
  }
121
131
  }
@@ -127,7 +137,7 @@ export abstract class XrpcError<
127
137
  * a non-2xx status with a valid JSON error payload containing `error` and
128
138
  * optional `message` fields.
129
139
  *
130
- * Use {@link matchesSchema} to check if the error matches the method's declared
140
+ * Use {@link matchesSchemaErrors} to check if the error matches the method's declared
131
141
  * error types for type-safe error handling.
132
142
  *
133
143
  * @typeParam M - The XRPC method type
@@ -168,25 +178,32 @@ export class XrpcResponseError<
168
178
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
169
179
  }
170
180
 
171
- override toJSON() {
181
+ override toJSON(): LexErrorData<N> {
172
182
  return this.payload.body
173
183
  }
174
184
 
175
- override toResponse(): Response {
176
- // Re-expose schema-valid errors as-is to downstream clients
177
- if (this.matchesSchema()) {
178
- const status = this.response.status >= 500 ? 502 : this.response.status
179
- return Response.json(this.toJSON(), { status })
185
+ override toDownstreamError(): DownstreamError {
186
+ // If the upstream server returned a 5xx error, we want to return a 502 Bad
187
+ // Gateway to downstream clients, as the issue is with the upstream server,
188
+ // not us. We still return the original error code and message in the body
189
+ // for transparency, but we do not want to expose internal server errors
190
+ // from the upstream server as-is to downstream clients.
191
+ return {
192
+ status: this.response.status === 500 ? 502 : this.status,
193
+ headers: stripHopByHopHeaders(this.headers),
194
+ body: this.toJSON(),
180
195
  }
196
+ }
181
197
 
182
- return this.response.status >= 500
183
- ? // The upstream server had an error, return a generic upstream failure
184
- Response.json({ error: 'UpstreamFailure' }, { status: 502 })
185
- : // If the error is on our side, return a generic internal server error
186
- Response.json({ error: 'InternalServerError' }, { status: 500 })
198
+ get status(): number {
199
+ return this.response.status
187
200
  }
188
201
 
189
- get body(): LexErrorData {
202
+ get headers(): Headers {
203
+ return this.response.headers
204
+ }
205
+
206
+ get body(): LexErrorData<N> {
190
207
  return this.payload.body
191
208
  }
192
209
  }
@@ -240,6 +257,14 @@ export class XrpcAuthenticationError<
240
257
  this.response.headers.get('www-authenticate'),
241
258
  ) ?? {})
242
259
  }
260
+
261
+ override toDownstreamError(): DownstreamError {
262
+ return {
263
+ status: 401,
264
+ headers: stripHopByHopHeaders(this.headers),
265
+ body: this.toJSON(),
266
+ }
267
+ }
243
268
  }
244
269
 
245
270
  /**
@@ -280,8 +305,44 @@ export class XrpcUpstreamError<
280
305
  return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
281
306
  }
282
307
 
283
- override toResponse(): Response {
284
- return Response.json(this.toJSON(), { status: 502 })
308
+ override toDownstreamError(): DownstreamError {
309
+ return { status: 502, body: this.toJSON() }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Error class for invalid XRPC responses that fail schema validation.
315
+ *
316
+ * This is a specific type of {@link XrpcUpstreamError} that indicates the
317
+ * upstream server returned a response that was structurally valid but did not
318
+ * conform to the expected schema for the method. This likely indicates a
319
+ * mismatch between client and server versions or an issue with the server's
320
+ * XRPC implementation.
321
+ *
322
+ * @typeParam M - The XRPC method type
323
+ */
324
+ export class XrpcInvalidResponseError<
325
+ M extends Procedure | Query = Procedure | Query,
326
+ > extends XrpcUpstreamError<M> {
327
+ name = 'XrpcInvalidResponseError'
328
+
329
+ constructor(
330
+ method: M,
331
+ response: Response,
332
+ payload: XrpcResponsePayload,
333
+ readonly cause: LexValidationError,
334
+ ) {
335
+ super(method, response, payload, `Invalid response: ${cause.message}`, {
336
+ cause,
337
+ })
338
+ }
339
+
340
+ override toDownstreamError(): DownstreamError {
341
+ // @NOTE This could be reflected as both a 500 ("we" are at fault) and 502
342
+ // ("they" are at fault). We are using 502 here to allow downstream clients
343
+ // to determine that the issue lies at the interface between us and the
344
+ // upstream server, rather than an issue with our internal processing.
345
+ return { status: 502, body: this.toJSON() }
285
346
  }
286
347
  }
287
348
 
@@ -326,9 +387,13 @@ export class XrpcInternalError<
326
387
  return true
327
388
  }
328
389
 
329
- override toResponse(): Response {
330
- // Do not expose internal error details to downstream clients
331
- return Response.json({ error: this.error }, { status: 500 })
390
+ override toJSON(): LexErrorData<'InternalServerError'> {
391
+ // @NOTE Do not expose internal error details to downstream clients
392
+ return { error: this.error, message: 'Internal Server Error' }
393
+ }
394
+
395
+ override toDownstreamError(): DownstreamError {
396
+ return { status: 500, body: this.toJSON() }
332
397
  }
333
398
  }
334
399
 
@@ -393,3 +458,39 @@ export function asXrpcFailure<M extends Procedure | Query>(
393
458
 
394
459
  return new XrpcInternalError(method, undefined, { cause })
395
460
  }
461
+
462
+ const HOP_BY_HOP_HEADERS = new Set([
463
+ 'connection',
464
+ 'keep-alive',
465
+ 'proxy-authenticate',
466
+ 'proxy-authorization',
467
+ 'te',
468
+ 'trailer',
469
+ 'transfer-encoding',
470
+ 'upgrade',
471
+ ])
472
+
473
+ function stripHopByHopHeaders(headers: Headers): Headers {
474
+ const result = new Headers(headers)
475
+
476
+ // Remove statically known hop-by-hop headers
477
+ for (const name of HOP_BY_HOP_HEADERS) {
478
+ result.delete(name)
479
+ }
480
+
481
+ // Remove headers listed in the "Connection" header
482
+ const connection = headers.get('connection')
483
+ if (connection) {
484
+ for (const name of connection.split(',')) {
485
+ result.delete(name.trim())
486
+ }
487
+ }
488
+
489
+ // These are not actually hop-by-hop headers, but we remove them because the
490
+ // upstream payload gets parsed and re-serialized, so content length and
491
+ // encoding may no longer be accurate.
492
+ result.delete('content-length')
493
+ result.delete('content-encoding')
494
+
495
+ return result
496
+ }
package/src/response.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from '@atproto/lex-schema'
8
8
  import {
9
9
  XrpcAuthenticationError,
10
+ XrpcInvalidResponseError,
10
11
  XrpcResponseError,
11
12
  XrpcUpstreamError,
12
13
  isXrpcErrorPayload,
@@ -70,7 +71,7 @@ export class XrpcResponse<M extends Procedure | Query>
70
71
 
71
72
  /**
72
73
  * @throws {XrpcResponseError} in case of (valid) XRPC error responses. Use
73
- * {@link XrpcResponseError.matchesSchema} to narrow the error type based on
74
+ * {@link XrpcResponseError.matchesSchemaErrors} to narrow the error type based on
74
75
  * the method's declared error schema. This can be narrowed further as a
75
76
  * {@link XrpcAuthenticationError} if the error is an authentication error.
76
77
  * @throws {XrpcUpstreamError} when the response is not a valid XRPC
@@ -162,12 +163,11 @@ export class XrpcResponse<M extends Procedure | Query>
162
163
  const result = method.output.schema.safeParse(payload.body)
163
164
 
164
165
  if (!result.success) {
165
- throw new XrpcUpstreamError(
166
+ throw new XrpcInvalidResponseError(
166
167
  method,
167
168
  response,
168
169
  payload,
169
- `Response validation failed: ${result.reason.message}`,
170
- { cause: result.reason },
170
+ result.reason,
171
171
  )
172
172
  }
173
173
  }
package/src/xrpc.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  InferInput,
5
5
  InferPayload,
6
6
  Main,
7
+ NsidString,
7
8
  Params,
8
9
  Payload,
9
10
  Procedure,
@@ -12,7 +13,7 @@ import {
12
13
  Subscription,
13
14
  getMain,
14
15
  } from '@atproto/lex-schema'
15
- import { Agent } from './agent.js'
16
+ import { Agent, AgentOptions, buildAgent } from './agent.js'
16
17
  import { XrpcFailure, asXrpcFailure } from './errors.js'
17
18
  import { XrpcResponse } from './response.js'
18
19
  import { BinaryBodyInit, CallOptions } from './types.js'
@@ -77,8 +78,7 @@ export type XrpcOptions<M extends Procedure | Query = Procedure | Query> =
77
78
  /**
78
79
  * Makes an XRPC request and throws on failure.
79
80
  *
80
- * This is the low-level function for making XRPC calls. For most use cases,
81
- * prefer using {@link Client.xrpc} which provides a more ergonomic API.
81
+ * This is the low-level function for making XRPC calls.
82
82
  *
83
83
  * @param agent - The {@link Agent} to use for making the request
84
84
  * @param ns - The lexicon method definition
@@ -88,28 +88,35 @@ export type XrpcOptions<M extends Procedure | Query = Procedure | Query> =
88
88
  *
89
89
  * @example
90
90
  * ```typescript
91
+ * const response = await xrpc('https://bsky.network', com.atproto.identity.resolveHandle, {
92
+ * params: { handle: "atproto.com" }
93
+ * })
94
+ * ```
95
+ *
96
+ * @example
97
+ * ```typescript
91
98
  * const response = await xrpc(agent, app.bsky.feed.getTimeline.main, {
92
99
  * params: { limit: 50 }
93
100
  * })
94
101
  * ```
95
102
  */
96
103
  export async function xrpc<const M extends Query | Procedure>(
97
- agent: Agent,
104
+ agentOpts: Agent | AgentOptions,
98
105
  ns: NonNullable<unknown> extends XrpcOptions<M>
99
106
  ? Main<M>
100
107
  : Restricted<'This XRPC method requires an "options" argument'>,
101
108
  ): Promise<XrpcResponse<M>>
102
109
  export async function xrpc<const M extends Query | Procedure>(
103
- agent: Agent,
110
+ agentOpts: Agent | AgentOptions,
104
111
  ns: Main<M>,
105
112
  options: XrpcOptions<M>,
106
113
  ): Promise<XrpcResponse<M>>
107
114
  export async function xrpc<const M extends Query | Procedure>(
108
- agent: Agent,
115
+ agentOpts: Agent | AgentOptions,
109
116
  ns: Main<M>,
110
117
  options: XrpcOptions<M> = {} as XrpcOptions<M>,
111
118
  ): Promise<XrpcResponse<M>> {
112
- const response = await xrpcSafe<M>(agent, ns, options)
119
+ const response = await xrpcSafe<M>(agentOpts, ns, options)
113
120
  if (response.success) return response
114
121
  else throw response
115
122
  }
@@ -141,7 +148,7 @@ export type XrpcResult<M extends Procedure | Query> =
141
148
  *
142
149
  * @example
143
150
  * ```typescript
144
- * const result = await xrpcSafe(agent, app.bsky.actor.getProfile.main, {
151
+ * const result = await xrpcSafe('https://example.com', app.bsky.actor.getProfile, {
145
152
  * params: { actor: 'alice.bsky.social' }
146
153
  * })
147
154
  *
@@ -153,24 +160,25 @@ export type XrpcResult<M extends Procedure | Query> =
153
160
  * ```
154
161
  */
155
162
  export async function xrpcSafe<const M extends Query | Procedure>(
156
- agent: Agent,
163
+ agentOpts: Agent | AgentOptions,
157
164
  ns: NonNullable<unknown> extends XrpcOptions<M>
158
165
  ? Main<M>
159
166
  : Restricted<'This XRPC method requires an "options" argument'>,
160
167
  ): Promise<XrpcResult<M>>
161
168
  export async function xrpcSafe<const M extends Query | Procedure>(
162
- agent: Agent,
169
+ agentOpts: Agent | AgentOptions,
163
170
  ns: Main<M>,
164
171
  options: XrpcOptions<M>,
165
172
  ): Promise<XrpcResult<M>>
166
173
  export async function xrpcSafe<const M extends Query | Procedure>(
167
- agent: Agent,
174
+ agentOpts: Agent | AgentOptions,
168
175
  ns: Main<M>,
169
176
  options: XrpcOptions<M> = {} as XrpcOptions<M>,
170
177
  ): Promise<XrpcResult<M>> {
171
178
  options.signal?.throwIfAborted()
172
179
  const method: M = getMain(ns)
173
180
  try {
181
+ const agent = buildAgent(agentOpts)
174
182
  const url = xrpcRequestUrl(method, options)
175
183
  const request = xrpcRequestInit(method, options)
176
184
  const response = await agent.fetchHandler(url, request)
@@ -183,14 +191,14 @@ export async function xrpcSafe<const M extends Query | Procedure>(
183
191
  function xrpcRequestUrl<M extends Procedure | Query | Subscription>(
184
192
  method: M,
185
193
  options: CallOptions & { params?: Params },
186
- ) {
187
- const path = `/xrpc/${method.nsid}`
194
+ ): `/xrpc/${NsidString}${'' | `?${string}`}` {
195
+ const path = `/xrpc/${method.nsid}` as const
188
196
 
189
197
  const queryString = method.parameters
190
198
  ?.toURLSearchParams(options.params ?? {})
191
199
  .toString()
192
200
 
193
- return queryString ? `${path}?${queryString}` : path
201
+ return queryString ? (`${path}?${queryString}` as const) : path
194
202
  }
195
203
 
196
204
  function xrpcRequestInit<T extends Procedure | Query>(