@atproto/xrpc-server 0.8.0 → 0.9.1

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 (45) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/auth.js +11 -11
  3. package/dist/auth.js.map +1 -1
  4. package/dist/errors.d.ts +67 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +202 -0
  7. package/dist/errors.js.map +1 -0
  8. package/dist/index.d.ts +4 -3
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +5 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/rate-limiter.d.ts +69 -32
  13. package/dist/rate-limiter.d.ts.map +1 -1
  14. package/dist/rate-limiter.js +58 -41
  15. package/dist/rate-limiter.js.map +1 -1
  16. package/dist/server.d.ts +19 -14
  17. package/dist/server.d.ts.map +1 -1
  18. package/dist/server.js +151 -137
  19. package/dist/server.js.map +1 -1
  20. package/dist/types.d.ts +80 -178
  21. package/dist/types.d.ts.map +1 -1
  22. package/dist/types.js +9 -226
  23. package/dist/types.js.map +1 -1
  24. package/dist/util.d.ts +9 -8
  25. package/dist/util.d.ts.map +1 -1
  26. package/dist/util.js +148 -108
  27. package/dist/util.js.map +1 -1
  28. package/package.json +4 -3
  29. package/src/auth.ts +1 -1
  30. package/src/errors.ts +293 -0
  31. package/src/index.ts +9 -3
  32. package/src/rate-limiter.ts +188 -96
  33. package/src/server.ts +198 -154
  34. package/src/types.ts +144 -439
  35. package/src/util.ts +176 -125
  36. package/tests/auth.test.ts +2 -2
  37. package/tests/bodies.test.ts +18 -27
  38. package/tests/errors.test.ts +1 -1
  39. package/tests/ipld.test.ts +15 -14
  40. package/tests/parameters.test.ts +4 -7
  41. package/tests/procedures.test.ts +22 -34
  42. package/tests/queries.test.ts +9 -12
  43. package/tests/rate-limiter.test.ts +7 -7
  44. package/tests/responses.test.ts +12 -15
  45. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,76 +1,107 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http'
1
2
  import {
2
3
  RateLimiterAbstract,
3
4
  RateLimiterMemory,
4
5
  RateLimiterRedis,
5
6
  RateLimiterRes,
6
7
  } from 'rate-limiter-flexible'
8
+ import { ResponseType, XRPCError } from './errors'
7
9
  import { logger } from './logger'
8
- import {
9
- CalcKeyFn,
10
- CalcPointsFn,
11
- RateLimitExceededError,
12
- RateLimiterConsume,
13
- RateLimiterConsumeOptions,
14
- RateLimiterI,
15
- RateLimiterReset,
16
- RateLimiterResetOptions,
17
- RateLimiterStatus,
18
- XRPCReqContext,
19
- } from './types'
20
- import { setHeaders } from './util'
21
-
22
- export type RateLimiterOpts = {
10
+
11
+ // @NOTE Do not depend (directly or indirectly) on "./types" here, as it would
12
+ // create a circular dependency.
13
+
14
+ export interface RateLimiterContext {
15
+ req: IncomingMessage
16
+ res?: ServerResponse
17
+ }
18
+
19
+ export type CalcKeyFn<C extends RateLimiterContext = RateLimiterContext> = (
20
+ ctx: C,
21
+ ) => string | null
22
+ export type CalcPointsFn<C extends RateLimiterContext = RateLimiterContext> = (
23
+ ctx: C,
24
+ ) => number
25
+
26
+ export interface RateLimiterI<
27
+ C extends RateLimiterContext = RateLimiterContext,
28
+ > {
29
+ consume: RateLimiterConsume<C>
30
+ reset: RateLimiterReset<C>
31
+ }
32
+
33
+ export type RateLimiterConsumeOptions<
34
+ C extends RateLimiterContext = RateLimiterContext,
35
+ > = {
36
+ calcKey?: CalcKeyFn<C>
37
+ calcPoints?: CalcPointsFn<C>
38
+ }
39
+
40
+ export type RateLimiterConsume<
41
+ C extends RateLimiterContext = RateLimiterContext,
42
+ > = (
43
+ ctx: C,
44
+ opts?: RateLimiterConsumeOptions<C>,
45
+ ) => Promise<RateLimiterStatus | RateLimitExceededError | null>
46
+
47
+ export type RateLimiterStatus = {
48
+ limit: number
49
+ duration: number
50
+ remainingPoints: number
51
+ msBeforeNext: number
52
+ consumedPoints: number
53
+ isFirstInDuration: boolean
54
+ }
55
+
56
+ export type RateLimiterResetOptions<
57
+ C extends RateLimiterContext = RateLimiterContext,
58
+ > = {
59
+ calcKey?: CalcKeyFn<C>
60
+ }
61
+
62
+ export type RateLimiterReset<
63
+ C extends RateLimiterContext = RateLimiterContext,
64
+ > = (ctx: C, opts?: RateLimiterResetOptions<C>) => Promise<void>
65
+
66
+ export type RateLimiterOptions<
67
+ C extends RateLimiterContext = RateLimiterContext,
68
+ > = {
23
69
  keyPrefix: string
24
70
  durationMs: number
25
71
  points: number
26
- calcKey?: CalcKeyFn
27
- calcPoints?: CalcPointsFn
72
+ calcKey: CalcKeyFn<C>
73
+ calcPoints: CalcPointsFn<C>
28
74
  failClosed?: boolean
29
75
  }
30
76
 
31
- export class RateLimiter implements RateLimiterI {
32
- public limiter: RateLimiterAbstract
33
-
34
- private failClosed?: boolean
35
- public calcKey: CalcKeyFn
36
- public calcPoints: CalcPointsFn
77
+ export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
78
+ implements RateLimiterI<C>
79
+ {
80
+ private readonly failClosed?: boolean
81
+ private readonly calcKey: CalcKeyFn<C>
82
+ private readonly calcPoints: CalcPointsFn<C>
37
83
 
38
- constructor(limiter: RateLimiterAbstract, opts: RateLimiterOpts) {
84
+ constructor(
85
+ public limiter: RateLimiterAbstract,
86
+ options: RateLimiterOptions<C>,
87
+ ) {
39
88
  this.limiter = limiter
40
- this.calcKey = opts.calcKey ?? defaultKey
41
- this.calcPoints = opts.calcPoints ?? defaultPoints
42
- }
43
-
44
- static memory(opts: RateLimiterOpts): RateLimiter {
45
- const limiter = new RateLimiterMemory({
46
- keyPrefix: opts.keyPrefix,
47
- duration: Math.floor(opts.durationMs / 1000),
48
- points: opts.points,
49
- })
50
- return new RateLimiter(limiter, opts)
51
- }
52
-
53
- static redis(storeClient: unknown, opts: RateLimiterOpts): RateLimiter {
54
- const limiter = new RateLimiterRedis({
55
- storeClient,
56
- keyPrefix: opts.keyPrefix,
57
- duration: Math.floor(opts.durationMs / 1000),
58
- points: opts.points,
59
- })
60
- return new RateLimiter(limiter, opts)
89
+ this.failClosed = options.failClosed ?? false
90
+ this.calcKey = options.calcKey
91
+ this.calcPoints = options.calcPoints
61
92
  }
62
93
 
63
94
  async consume(
64
- ctx: XRPCReqContext,
65
- opts?: RateLimiterConsumeOptions,
95
+ ctx: C,
96
+ opts?: RateLimiterConsumeOptions<C>,
66
97
  ): Promise<RateLimiterStatus | RateLimitExceededError | null> {
67
- const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
98
+ const calcKey = opts?.calcKey ?? this.calcKey
99
+ const key = calcKey(ctx)
68
100
  if (key === null) {
69
101
  return null
70
102
  }
71
- const points = opts?.calcPoints
72
- ? opts.calcPoints(ctx)
73
- : this.calcPoints(ctx)
103
+ const calcPoints = opts?.calcPoints ?? this.calcPoints
104
+ const points = calcPoints(ctx)
74
105
  if (points < 1) {
75
106
  return null
76
107
  }
@@ -100,10 +131,7 @@ export class RateLimiter implements RateLimiterI {
100
131
  }
101
132
  }
102
133
 
103
- async reset(
104
- ctx: XRPCReqContext,
105
- opts?: RateLimiterResetOptions,
106
- ): Promise<void> {
134
+ async reset(ctx: C, opts?: RateLimiterResetOptions<C>): Promise<void> {
107
135
  const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
108
136
  if (key === null) {
109
137
  return
@@ -117,6 +145,33 @@ export class RateLimiter implements RateLimiterI {
117
145
  }
118
146
  }
119
147
 
148
+ export class MemoryRateLimiter<
149
+ C extends RateLimiterContext = RateLimiterContext,
150
+ > extends RateLimiter<C> {
151
+ constructor(options: RateLimiterOptions<C>) {
152
+ const limiter = new RateLimiterMemory({
153
+ keyPrefix: options.keyPrefix,
154
+ duration: Math.floor(options.durationMs / 1000),
155
+ points: options.points,
156
+ })
157
+ super(limiter, options)
158
+ }
159
+ }
160
+
161
+ export class RedisRateLimiter<
162
+ C extends RateLimiterContext = RateLimiterContext,
163
+ > extends RateLimiter<C> {
164
+ constructor(storeClient: unknown, options: RateLimiterOptions<C>) {
165
+ const limiter = new RateLimiterRedis({
166
+ storeClient,
167
+ keyPrefix: options.keyPrefix,
168
+ duration: Math.floor(options.durationMs / 1000),
169
+ points: options.points,
170
+ })
171
+ super(limiter, options)
172
+ }
173
+ }
174
+
120
175
  export const formatLimiterStatus = (
121
176
  limiter: RateLimiterAbstract,
122
177
  res: RateLimiterRes,
@@ -131,40 +186,45 @@ export const formatLimiterStatus = (
131
186
  }
132
187
  }
133
188
 
134
- export type WrappedRateLimiterOptions = {
135
- calcKey?: CalcKeyFn
136
- calcPoints?: CalcPointsFn
189
+ export type WrappedRateLimiterOptions<
190
+ C extends RateLimiterContext = RateLimiterContext,
191
+ > = {
192
+ calcKey?: CalcKeyFn<C>
193
+ calcPoints?: CalcPointsFn<C>
137
194
  }
138
195
 
139
196
  /**
140
197
  * Wraps a {@link RateLimiterI} instance with custom key and points calculation
141
198
  * functions.
142
199
  */
143
- export class WrappedRateLimiter implements RateLimiterI {
200
+ export class WrappedRateLimiter<
201
+ C extends RateLimiterContext = RateLimiterContext,
202
+ > implements RateLimiterI<C>
203
+ {
144
204
  private constructor(
145
- private readonly rateLimiter: RateLimiterI,
146
- private readonly options: Readonly<WrappedRateLimiterOptions>,
205
+ private readonly rateLimiter: RateLimiterI<C>,
206
+ private readonly options: Readonly<WrappedRateLimiterOptions<C>>,
147
207
  ) {}
148
208
 
149
- async consume(ctx: XRPCReqContext, opts?: RateLimiterConsumeOptions) {
209
+ async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
150
210
  return this.rateLimiter.consume(ctx, {
151
211
  calcKey: opts?.calcKey ?? this.options.calcKey,
152
212
  calcPoints: opts?.calcPoints ?? this.options.calcPoints,
153
213
  })
154
214
  }
155
215
 
156
- async reset(ctx: XRPCReqContext, opts?: RateLimiterResetOptions) {
216
+ async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
157
217
  return this.rateLimiter.reset(ctx, {
158
218
  calcKey: opts?.calcKey ?? this.options.calcKey,
159
219
  })
160
220
  }
161
221
 
162
- static from(
163
- rateLimiter: RateLimiterI,
164
- { calcKey, calcPoints }: WrappedRateLimiterOptions = {},
165
- ): RateLimiterI {
222
+ static from<C extends RateLimiterContext = RateLimiterContext>(
223
+ rateLimiter: RateLimiterI<C>,
224
+ { calcKey, calcPoints }: WrappedRateLimiterOptions<C> = {},
225
+ ): RateLimiterI<C> {
166
226
  if (!calcKey && !calcPoints) return rateLimiter
167
- return new WrappedRateLimiter(rateLimiter, { calcKey, calcPoints })
227
+ return new WrappedRateLimiter<C>(rateLimiter, { calcKey, calcPoints })
168
228
  }
169
229
  }
170
230
 
@@ -174,22 +234,29 @@ export class WrappedRateLimiter implements RateLimiterI {
174
234
  * The combined rate limiter will return the tightest (most restrictive) of all
175
235
  * the provided rate limiters.
176
236
  */
177
- export class CombinedRateLimiter implements RateLimiterI {
178
- private constructor(private readonly rateLimiters: readonly RateLimiterI[]) {}
237
+ export class CombinedRateLimiter<
238
+ C extends RateLimiterContext = RateLimiterContext,
239
+ > implements RateLimiterI<C>
240
+ {
241
+ private constructor(
242
+ private readonly rateLimiters: readonly RateLimiterI<C>[],
243
+ ) {}
179
244
 
180
- async consume(ctx: XRPCReqContext, opts?: RateLimiterConsumeOptions) {
245
+ async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
181
246
  const promises: ReturnType<RateLimiterConsume>[] = []
182
247
  for (const rl of this.rateLimiters) promises.push(rl.consume(ctx, opts))
183
248
  return Promise.all(promises).then(getTightestLimit)
184
249
  }
185
250
 
186
- async reset(ctx: XRPCReqContext, opts?: RateLimiterResetOptions) {
251
+ async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
187
252
  const promises: ReturnType<RateLimiterReset>[] = []
188
253
  for (const rl of this.rateLimiters) promises.push(rl.reset(ctx, opts))
189
254
  await Promise.all(promises)
190
255
  }
191
256
 
192
- static from(rateLimiters: readonly RateLimiterI[]): RateLimiterI | undefined {
257
+ static from<C extends RateLimiterContext = RateLimiterContext>(
258
+ rateLimiters: readonly RateLimiterI<C>[],
259
+ ): RateLimiterI<C> | undefined {
193
260
  if (rateLimiters.length === 0) return undefined
194
261
  if (rateLimiters.length === 1) return rateLimiters[0]
195
262
  return new CombinedRateLimiter(rateLimiters)
@@ -210,21 +277,25 @@ const getTightestLimit = (
210
277
  return lowest
211
278
  }
212
279
 
213
- export type RouteRateLimiterOptions = {
214
- bypass?: (ctx: XRPCReqContext) => boolean
280
+ export type RouteRateLimiterOptions<
281
+ C extends RateLimiterContext = RateLimiterContext,
282
+ > = {
283
+ bypass?: (ctx: C) => boolean
215
284
  }
216
285
 
217
286
  /**
218
287
  * Wraps a {@link RateLimiterI} interface into a class that will apply the
219
288
  * appropriate headers to the response if a limit is exceeded.
220
289
  */
221
- export class RouteRateLimiter implements RateLimiterI {
290
+ export class RouteRateLimiter<C extends RateLimiterContext = RateLimiterContext>
291
+ implements RateLimiterI<C>
292
+ {
222
293
  constructor(
223
- private readonly rateLimiter: RateLimiterI,
224
- private readonly options: Readonly<RouteRateLimiterOptions> = {},
294
+ private readonly rateLimiter: RateLimiterI<C>,
295
+ private readonly options: Readonly<RouteRateLimiterOptions<C>> = {},
225
296
  ) {}
226
297
 
227
- async handle(ctx: XRPCReqContext): Promise<RateLimiterStatus | null> {
298
+ async handle(ctx: C): Promise<RateLimiterStatus | null> {
228
299
  const { bypass } = this.options
229
300
  if (bypass && bypass(ctx)) {
230
301
  return null
@@ -241,18 +312,18 @@ export class RouteRateLimiter implements RateLimiterI {
241
312
  return result
242
313
  }
243
314
 
244
- async consume(...args: Parameters<RateLimiterConsume>) {
315
+ async consume(...args: Parameters<RateLimiterConsume<C>>) {
245
316
  return this.rateLimiter.consume(...args)
246
317
  }
247
318
 
248
- async reset(...args: Parameters<RateLimiterReset>) {
319
+ async reset(...args: Parameters<RateLimiterReset<C>>) {
249
320
  return this.rateLimiter.reset(...args)
250
321
  }
251
322
 
252
- static from(
253
- rateLimiters: readonly RateLimiterI[],
254
- { bypass }: RouteRateLimiterOptions = {},
255
- ): RouteRateLimiter | undefined {
323
+ static from<C extends RateLimiterContext = RateLimiterContext>(
324
+ rateLimiters: readonly RateLimiterI<C>[],
325
+ { bypass }: RouteRateLimiterOptions<C> = {},
326
+ ): RouteRateLimiter<C> | undefined {
256
327
  const rateLimiter = CombinedRateLimiter.from(rateLimiters)
257
328
  if (!rateLimiter) return undefined
258
329
 
@@ -260,16 +331,37 @@ export class RouteRateLimiter implements RateLimiterI {
260
331
  }
261
332
  }
262
333
 
263
- function setStatusHeaders(ctx: XRPCReqContext, status: RateLimiterStatus) {
264
- setHeaders(ctx.res, {
265
- 'RateLimit-Limit': status.limit,
266
- 'RateLimit-Reset': Math.floor((Date.now() + status.msBeforeNext) / 1000),
267
- 'RateLimit-Remaining': status.remainingPoints,
268
- 'RateLimit-Policy': `${status.limit};w=${status.duration}`,
269
- })
334
+ function setStatusHeaders<C extends RateLimiterContext = RateLimiterContext>(
335
+ ctx: C,
336
+ status: RateLimiterStatus,
337
+ ) {
338
+ const resetAt = Math.floor((Date.now() + status.msBeforeNext) / 1e3)
339
+
340
+ ctx.res?.setHeader('RateLimit-Limit', status.limit)
341
+ ctx.res?.setHeader('RateLimit-Reset', resetAt)
342
+ ctx.res?.setHeader('RateLimit-Remaining', status.remainingPoints)
343
+ ctx.res?.setHeader('RateLimit-Policy', `${status.limit};w=${status.duration}`)
270
344
  }
271
345
 
272
- // when using a proxy, ensure headers are getting forwarded correctly: `app.set('trust proxy', true)`
273
- // https://expressjs.com/en/guide/behind-proxies.html
274
- const defaultKey: CalcKeyFn = (ctx: XRPCReqContext) => ctx.req.ip
275
- const defaultPoints: CalcPointsFn = () => 1
346
+ export class RateLimitExceededError extends XRPCError {
347
+ constructor(
348
+ public status: RateLimiterStatus,
349
+ errorMessage?: string,
350
+ customErrorName?: string,
351
+ options?: ErrorOptions,
352
+ ) {
353
+ super(
354
+ ResponseType.RateLimitExceeded,
355
+ errorMessage,
356
+ customErrorName,
357
+ options,
358
+ )
359
+ }
360
+
361
+ [Symbol.hasInstance](instance: unknown): boolean {
362
+ return (
363
+ instance instanceof XRPCError &&
364
+ instance.type === ResponseType.RateLimitExceeded
365
+ )
366
+ }
367
+ }