@atproto/lex-client 0.0.10 → 0.0.12

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/agent.d.ts +72 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +46 -1
  5. package/dist/agent.js.map +1 -1
  6. package/dist/client.d.ts +442 -46
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +145 -1
  9. package/dist/client.js.map +1 -1
  10. package/dist/errors.d.ts +202 -48
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +208 -65
  13. package/dist/errors.js.map +1 -1
  14. package/dist/lexicons/com/atproto/repo/createRecord.defs.d.ts +20 -20
  15. package/dist/lexicons/com/atproto/repo/deleteRecord.defs.d.ts +12 -12
  16. package/dist/lexicons/com/atproto/repo/getRecord.defs.d.ts +6 -6
  17. package/dist/lexicons/com/atproto/repo/listRecords.defs.d.ts +6 -6
  18. package/dist/lexicons/com/atproto/repo/putRecord.defs.d.ts +22 -22
  19. package/dist/lexicons/com/atproto/repo/uploadBlob.defs.d.ts +2 -2
  20. package/dist/response.d.ts +17 -6
  21. package/dist/response.d.ts.map +1 -1
  22. package/dist/response.js +45 -32
  23. package/dist/response.js.map +1 -1
  24. package/dist/types.d.ts +51 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js.map +1 -1
  27. package/dist/util.d.ts +40 -5
  28. package/dist/util.d.ts.map +1 -1
  29. package/dist/util.js +22 -0
  30. package/dist/util.js.map +1 -1
  31. package/dist/www-authenticate.d.ts +35 -0
  32. package/dist/www-authenticate.d.ts.map +1 -0
  33. package/dist/www-authenticate.js +57 -0
  34. package/dist/www-authenticate.js.map +1 -0
  35. package/dist/xrpc.d.ts +82 -10
  36. package/dist/xrpc.d.ts.map +1 -1
  37. package/dist/xrpc.js +15 -28
  38. package/dist/xrpc.js.map +1 -1
  39. package/package.json +7 -7
  40. package/src/agent.ts +101 -1
  41. package/src/client.ts +428 -15
  42. package/src/errors.ts +308 -120
  43. package/src/response.ts +68 -63
  44. package/src/types.ts +52 -0
  45. package/src/util.ts +50 -5
  46. package/src/www-authenticate.test.ts +227 -0
  47. package/src/www-authenticate.ts +101 -0
  48. package/src/xrpc.ts +100 -53
package/src/errors.ts CHANGED
@@ -1,25 +1,48 @@
1
1
  import { LexError, LexErrorCode, LexErrorData } from '@atproto/lex-data'
2
- import { l } from '@atproto/lex-schema'
3
- import { XrpcPayload } from './util.js'
2
+ import {
3
+ InferMethodError,
4
+ Procedure,
5
+ Query,
6
+ ResultFailure,
7
+ lexErrorDataSchema,
8
+ } from '@atproto/lex-schema'
9
+ import { XrpcResponsePayload } from './util.js'
10
+ import {
11
+ WWWAuthenticate,
12
+ parseWWWAuthenticateHeader,
13
+ } from './www-authenticate.js'
14
+
15
+ /**
16
+ * HTTP status codes that indicate a transient error that may succeed on retry.
17
+ *
18
+ * Includes:
19
+ * - 408 Request Timeout
20
+ * - 425 Too Early
21
+ * - 429 Too Many Requests (rate limited)
22
+ * - 500 Internal Server Error
23
+ * - 502 Bad Gateway
24
+ * - 503 Service Unavailable
25
+ * - 504 Gateway Timeout
26
+ * - 522 Connection Timed Out (Cloudflare)
27
+ * - 524 A Timeout Occurred (Cloudflare)
28
+ */
29
+ export const RETRYABLE_HTTP_STATUS_CODES: ReadonlySet<number> = new Set([
30
+ 408, 425, 429, 500, 502, 503, 504, 522, 524,
31
+ ])
4
32
 
5
33
  export { LexError }
6
34
  export type { LexErrorCode, LexErrorData }
7
35
 
8
- export type XrpcErrorPayload<N extends LexErrorCode = LexErrorCode> =
9
- XrpcPayload<LexErrorData<N>, 'application/json'>
10
-
11
- export class XrpcError<
12
- N extends LexErrorCode = LexErrorCode,
13
- > extends LexError<N> {
14
- name = 'XrpcError'
15
-
16
- constructor(
17
- error: N,
18
- message: string = `${error} Lexicon RPC error`,
19
- options?: ErrorOptions,
20
- ) {
21
- super(error, message, options)
22
- }
36
+ /**
37
+ * The payload structure for XRPC error responses.
38
+ *
39
+ * All XRPC errors return JSON with an `error` code and optional `message`.
40
+ *
41
+ * @typeParam N - The specific error code type
42
+ */
43
+ export type XrpcErrorPayload<N extends LexErrorCode = LexErrorCode> = {
44
+ body: LexErrorData<N>
45
+ encoding: 'application/json'
23
46
  }
24
47
 
25
48
  /**
@@ -35,173 +58,338 @@ export class XrpcError<
35
58
  * This function checks whether a given payload matches this schema.
36
59
  */
37
60
  export function isXrpcErrorPayload(
38
- payload: XrpcPayload | null,
61
+ payload: XrpcResponsePayload | null | undefined,
39
62
  ): payload is XrpcErrorPayload {
40
63
  return (
41
- payload !== null &&
64
+ payload != null &&
42
65
  payload.encoding === 'application/json' &&
43
- l.lexErrorData.matches(payload.body)
66
+ lexErrorDataSchema.matches(payload.body)
44
67
  )
45
68
  }
46
69
 
47
70
  /**
48
- * Interface representing a failed XRPC request result.
71
+ * Abstract base class for all XRPC errors.
72
+ *
73
+ * Extends {@link LexError} and implements {@link ResultFailure} for use with
74
+ * safe/result-based error handling patterns.
75
+ *
76
+ * @typeParam M - The XRPC method type (Procedure or Query)
77
+ * @typeParam N - The error code type
78
+ * @typeParam TReason - The reason type for ResultFailure
79
+ *
80
+ * @see {@link XrpcResponseError} - For valid XRPC error responses
81
+ * @see {@link XrpcUpstreamError} - For invalid/unexpected responses
82
+ * @see {@link XrpcInternalError} - For network/internal errors
49
83
  */
50
- type LexRpcFailureResult<N extends LexErrorCode, E> = l.ResultFailure<E> & {
51
- readonly error: N
52
- shouldRetry(): boolean
53
- matchesSchema(): boolean
84
+ export abstract class XrpcError<
85
+ M extends Procedure | Query = Procedure | Query,
86
+ N extends LexErrorCode = LexErrorCode,
87
+ TReason = unknown,
88
+ >
89
+ extends LexError<N>
90
+ implements ResultFailure<TReason>
91
+ {
92
+ name = 'XrpcError'
93
+
94
+ constructor(
95
+ readonly method: M,
96
+ error: N,
97
+ message: string = `${error} Lexicon RPC error`,
98
+ options?: ErrorOptions,
99
+ ) {
100
+ super(error, message, options)
101
+ }
102
+
103
+ /**
104
+ * @see {@link ResultFailure.success}
105
+ */
106
+ readonly success = false as const
107
+
108
+ /**
109
+ * @see {@link ResultFailure.reason}
110
+ */
111
+ abstract readonly reason: TReason
112
+
113
+ /**
114
+ * Indicates whether the error is transient and can be retried.
115
+ */
116
+ abstract shouldRetry(): boolean
117
+
118
+ matchesSchema(): this is XrpcError<M, InferMethodError<M>> {
119
+ return this.method.errors?.includes(this.error) ?? false
120
+ }
54
121
  }
55
122
 
56
123
  /**
57
- * Class used to represent an HTTP request that resulted in an XRPC method error
58
- * That is, a non-2xx response with a valid XRPC error payload.
124
+ * Error class for valid XRPC error responses from the server.
125
+ *
126
+ * This represents a properly formatted XRPC error where the server returned
127
+ * a non-2xx status with a valid JSON error payload containing `error` and
128
+ * optional `message` fields.
129
+ *
130
+ * Use {@link matchesSchema} to check if the error matches the method's declared
131
+ * error types for type-safe error handling.
132
+ *
133
+ * @typeParam M - The XRPC method type
134
+ * @typeParam N - The error code type (inferred from method or generic)
135
+ *
136
+ * @example Handling specific errors
137
+ * ```typescript
138
+ * try {
139
+ * await client.xrpc(someMethod, options)
140
+ * } catch (err) {
141
+ * if (err instanceof XrpcResponseError && err.error === 'RecordNotFound') {
142
+ * // Handle not found case
143
+ * }
144
+ * }
145
+ * ```
59
146
  */
60
147
  export class XrpcResponseError<
61
- M extends l.Procedure | l.Query = l.Procedure | l.Query,
62
- N extends LexErrorCode = LexErrorCode,
63
- >
64
- extends XrpcError<N>
65
- implements LexRpcFailureResult<N, XrpcResponseError<M, N>>
66
- {
148
+ M extends Procedure | Query = Procedure | Query,
149
+ N extends LexErrorCode = InferMethodError<M> | LexErrorCode,
150
+ > extends XrpcError<M, N, XrpcResponseError<M, N>> {
67
151
  name = 'XrpcResponseError'
68
152
 
69
153
  constructor(
70
- readonly method: M,
71
- readonly status: number,
72
- readonly headers: Headers,
154
+ method: M,
155
+ readonly response: Response,
73
156
  readonly payload: XrpcErrorPayload<N>,
74
157
  options?: ErrorOptions,
75
158
  ) {
76
159
  const { error, message } = payload.body
77
- super(error, message, options)
160
+ super(method, error, message, options)
78
161
  }
79
162
 
80
- readonly success = false
163
+ override get reason(): this {
164
+ return this
165
+ }
81
166
 
82
- get reason(): this {
83
- return this as this
167
+ override shouldRetry(): boolean {
168
+ return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
84
169
  }
85
170
 
86
- get body(): LexErrorData {
171
+ override toJSON() {
87
172
  return this.payload.body
88
173
  }
89
174
 
90
- matchesSchema(): this is M extends {
91
- errors: readonly (infer E extends string)[]
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 })
180
+ }
181
+
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 })
92
187
  }
93
- ? XrpcResponseError<M, E>
94
- : never {
95
- return this.method.errors?.includes(this.error) ?? false
188
+
189
+ get body(): LexErrorData {
190
+ return this.payload.body
96
191
  }
192
+ }
97
193
 
98
- shouldRetry(): boolean {
99
- // Do not retry client errors
100
- if (this.status < 500) return false
194
+ export type { WWWAuthenticate }
101
195
 
102
- return true
103
- }
196
+ /**
197
+ * Error class for 401 Unauthorized XRPC responses.
198
+ *
199
+ * Extends {@link XrpcResponseError} with access to parsed WWW-Authenticate header
200
+ * information, useful for implementing authentication flows.
201
+ *
202
+ * Authentication errors are never retryable as they require user intervention
203
+ * (e.g., re-authentication, token refresh).
204
+ *
205
+ * @typeParam M - The XRPC method type
206
+ * @typeParam N - The error code type
207
+ *
208
+ * @example Handling authentication errors
209
+ * ```typescript
210
+ * try {
211
+ * await client.xrpc(someMethod, options)
212
+ * } catch (err) {
213
+ * if (err instanceof XrpcAuthenticationError) {
214
+ * const { DPoP } = err.wwwAuthenticate
215
+ * if (DPoP?.error === 'use_dpop_nonce') {
216
+ * // Handle DPoP nonce requirement
217
+ * }
218
+ * }
219
+ * }
220
+ * ```
221
+ */
222
+ export class XrpcAuthenticationError<
223
+ M extends Procedure | Query = Procedure | Query,
224
+ N extends LexErrorCode = LexErrorCode,
225
+ > extends XrpcResponseError<M, N> {
226
+ name = 'XrpcAuthenticationError'
104
227
 
105
- toJSON() {
106
- return this.payload.body
228
+ override shouldRetry(): boolean {
229
+ return false
107
230
  }
108
231
 
109
- toResponse(): Response {
110
- const { status, headers } = this
111
- return Response.json(this.toJSON(), { status, headers })
232
+ #wwwAuthenticateCached?: WWWAuthenticate
233
+ /**
234
+ * Parsed WWW-Authenticate header from the response.
235
+ * Contains authentication scheme parameters (e.g., Bearer realm, DPoP nonce).
236
+ */
237
+ get wwwAuthenticate(): WWWAuthenticate {
238
+ return (this.#wwwAuthenticateCached ??=
239
+ parseWWWAuthenticateHeader(
240
+ this.response.headers.get('www-authenticate'),
241
+ ) ?? {})
112
242
  }
113
243
  }
114
244
 
115
245
  /**
116
- * This class represents an invalid XRPC response from the server.
246
+ * Error class for invalid or unprocessable XRPC responses from upstream servers.
247
+ *
248
+ * This occurs when the server returns a response that doesn't conform to the
249
+ * XRPC protocol, such as:
250
+ * - Missing or invalid Content-Type header
251
+ * - Response body that doesn't match the method's output schema
252
+ * - Non-JSON error responses
253
+ * - Responses from non-XRPC endpoints
254
+ *
255
+ * The error code is always 'UpstreamFailure' and maps to HTTP 502 Bad Gateway
256
+ * when converted to a response.
257
+ *
258
+ * @typeParam M - The XRPC method type
117
259
  */
118
260
  export class XrpcUpstreamError<
119
- N extends 'InvalidResponse' | 'UpstreamFailure' =
120
- | 'InvalidResponse'
121
- | 'UpstreamFailure',
122
- >
123
- extends XrpcError<N>
124
- implements LexRpcFailureResult<N, XrpcUpstreamError<N>>
125
- {
126
- name = 'XrpcUpstreamError' as const
127
-
128
- // For debugging purposes, we keep the response details here
129
- readonly response: {
130
- status: number
131
- headers: Headers
132
- payload: XrpcPayload | null
133
- }
261
+ M extends Procedure | Query = Procedure | Query,
262
+ > extends XrpcError<M, 'UpstreamFailure', XrpcUpstreamError<M>> {
263
+ name = 'XrpcUpstreamError'
134
264
 
135
265
  constructor(
136
- error: N,
137
- message: string,
138
- response: { status: number; headers: Headers },
139
- payload: XrpcPayload | null,
266
+ method: M,
267
+ readonly response: Response,
268
+ readonly payload: XrpcResponsePayload | null = null,
269
+ message: string = `Unexpected upstream XRPC response`,
140
270
  options?: ErrorOptions,
141
271
  ) {
142
- super(error, message, { cause: options?.cause })
143
- this.response = {
144
- status: response.status,
145
- headers: response.headers,
146
- payload,
147
- }
272
+ super(method, 'UpstreamFailure', message, options)
148
273
  }
149
274
 
150
- readonly success = false as const
151
-
152
- get reason(): this {
275
+ override get reason(): this {
153
276
  return this
154
277
  }
155
278
 
156
- matchesSchema(): false {
157
- return false
279
+ override shouldRetry(): boolean {
280
+ return RETRYABLE_HTTP_STATUS_CODES.has(this.response.status)
158
281
  }
159
282
 
160
- shouldRetry(): boolean {
161
- // Do not retry client errors
162
- return this.response.status >= 500
163
- }
164
-
165
- toResponse(): Response {
283
+ override toResponse(): Response {
166
284
  return Response.json(this.toJSON(), { status: 502 })
167
285
  }
168
286
  }
169
287
 
170
- export class XrpcUnexpectedError
171
- extends XrpcError<'InternalServerError'>
172
- implements LexRpcFailureResult<'InternalServerError', unknown>
173
- {
174
- name = 'XrpcUnexpectedError' as const
175
-
176
- protected constructor(message: string, options: Required<ErrorOptions>) {
177
- super('InternalServerError', message, options)
178
- }
179
-
180
- readonly success = false
181
-
182
- get reason() {
183
- return this.cause
288
+ /**
289
+ * Error class for internal/client-side errors during XRPC requests.
290
+ *
291
+ * This represents errors that occur before or during the request that are not
292
+ * server responses, such as:
293
+ * - Network errors (connection refused, DNS failure)
294
+ * - Request timeouts
295
+ * - Request aborted via AbortSignal
296
+ * - Invalid request construction
297
+ *
298
+ * The error code is always 'InternalServerError' and these errors are
299
+ * optimistically considered retryable.
300
+ *
301
+ * @typeParam M - The XRPC method type
302
+ */
303
+ export class XrpcInternalError<
304
+ M extends Procedure | Query = Procedure | Query,
305
+ > extends XrpcError<M, 'InternalServerError', XrpcInternalError<M>> {
306
+ name = 'XrpcInternalError'
307
+
308
+ constructor(method: M, message?: string, options?: ErrorOptions) {
309
+ super(
310
+ method,
311
+ 'InternalServerError',
312
+ message ?? 'Unable to fulfill XRPC request',
313
+ options,
314
+ )
184
315
  }
185
316
 
186
- matchesSchema(): false {
187
- return false
317
+ override get reason(): this {
318
+ return this
188
319
  }
189
320
 
190
- shouldRetry(): boolean {
321
+ override shouldRetry(): true {
322
+ // Ideally, we would inspect the reason to determine if it's retryable
323
+ // (by detecting network errors, timeouts, etc.). Since these cases are
324
+ // highly platform-dependent, we optimistically assume all internal
325
+ // errors are retryable.
191
326
  return true
192
327
  }
193
328
 
194
- toResponse(): Response {
195
- return Response.json(this.toJSON(), { status: 500 })
329
+ override toResponse(): Response {
330
+ // Do not expose internal error details to downstream clients
331
+ return Response.json({ error: this.error }, { status: 500 })
196
332
  }
333
+ }
334
+
335
+ /**
336
+ * Union type of all possible XRPC failure types.
337
+ *
338
+ * Used as the return type for safe/non-throwing XRPC methods. Check the
339
+ * `success` property to distinguish between success and failure:
340
+ *
341
+ * @typeParam M - The XRPC method type
342
+ *
343
+ * @example
344
+ * ```typescript
345
+ * const result = await client.xrpcSafe(someMethod, options)
346
+ * if (result.success) {
347
+ * console.log(result.body) // XrpcResponse
348
+ * } else {
349
+ * // result is XrpcFailure (XrpcResponseError | XrpcUpstreamError | XrpcInternalError)
350
+ * console.error(result.error, result.message)
351
+ * }
352
+ * ```
353
+ */
354
+ export type XrpcFailure<M extends Procedure | Query = Procedure | Query> =
355
+ // The server returned a valid XRPC error response
356
+ | XrpcResponseError<M>
357
+ // The response was not a valid XRPC response, or it does not match the schema
358
+ | XrpcUpstreamError<M>
359
+ // Something went wrong (network error, etc.)
360
+ | XrpcInternalError<M>
197
361
 
198
- static from(
199
- cause: unknown,
200
- message: string = cause instanceof LexError
201
- ? cause.message
202
- : 'XRPC request failed',
203
- ): XrpcUnexpectedError {
204
- if (cause instanceof XrpcUnexpectedError) return cause
205
- return new XrpcUnexpectedError(message, { cause })
362
+ /**
363
+ * Converts an unknown error into an appropriate {@link XrpcFailure} type.
364
+ *
365
+ * If the error is already an XrpcFailure for the given method, returns it as-is.
366
+ * Otherwise, wraps it in an {@link XrpcInternalError}.
367
+ *
368
+ * @param method - The XRPC method that was called
369
+ * @param cause - The error to convert
370
+ * @returns An XrpcFailure instance
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * try {
375
+ * const response = await fetch(...)
376
+ * // ... process response
377
+ * } catch (err) {
378
+ * return asXrpcFailure(method, err)
379
+ * }
380
+ * ```
381
+ */
382
+ export function asXrpcFailure<M extends Procedure | Query>(
383
+ method: M,
384
+ cause: unknown,
385
+ ): XrpcFailure<M> {
386
+ if (
387
+ cause instanceof XrpcResponseError ||
388
+ cause instanceof XrpcUpstreamError ||
389
+ cause instanceof XrpcInternalError
390
+ ) {
391
+ if (cause.method === method) return cause
206
392
  }
393
+
394
+ return new XrpcInternalError(method, undefined, { cause })
207
395
  }