@atproto/xrpc-server 0.7.19 → 0.9.0

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 +42 -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 +3 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/rate-limiter.d.ts +95 -26
  13. package/dist/rate-limiter.d.ts.map +1 -1
  14. package/dist/rate-limiter.js +179 -85
  15. package/dist/rate-limiter.js.map +1 -1
  16. package/dist/server.d.ts +20 -15
  17. package/dist/server.d.ts.map +1 -1
  18. package/dist/server.js +185 -220
  19. package/dist/server.js.map +1 -1
  20. package/dist/types.d.ts +80 -175
  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 +12 -9
  25. package/dist/util.d.ts.map +1 -1
  26. package/dist/util.js +114 -78
  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 +4 -3
  32. package/src/rate-limiter.ts +270 -104
  33. package/src/server.ts +265 -276
  34. package/src/types.ts +144 -429
  35. package/src/util.ts +131 -85
  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 +8 -11
  44. package/tests/responses.test.ts +12 -15
  45. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,87 +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
- RateLimiterI,
14
- RateLimiterReset,
15
- RateLimiterStatus,
16
- XRPCReqContext,
17
- } from './types'
18
-
19
- 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
+ > = {
20
69
  keyPrefix: string
21
70
  durationMs: number
22
71
  points: number
23
- bypassSecret?: string
24
- bypassIps?: string[]
25
- calcKey?: CalcKeyFn
26
- calcPoints?: CalcPointsFn
72
+ calcKey: CalcKeyFn<C>
73
+ calcPoints: CalcPointsFn<C>
27
74
  failClosed?: boolean
28
75
  }
29
76
 
30
- export class RateLimiter implements RateLimiterI {
31
- public limiter: RateLimiterAbstract
32
- private bypassSecret?: string
33
- private bypassIps?: string[]
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.bypassSecret = opts.bypassSecret
41
- this.bypassIps = opts.bypassIps
42
- this.calcKey = opts.calcKey ?? defaultKey
43
- this.calcPoints = opts.calcPoints ?? defaultPoints
44
- }
45
-
46
- static memory(opts: RateLimiterOpts): RateLimiter {
47
- const limiter = new RateLimiterMemory({
48
- keyPrefix: opts.keyPrefix,
49
- duration: Math.floor(opts.durationMs / 1000),
50
- points: opts.points,
51
- })
52
- return new RateLimiter(limiter, opts)
53
- }
54
-
55
- static redis(storeClient: unknown, opts: RateLimiterOpts): RateLimiter {
56
- const limiter = new RateLimiterRedis({
57
- storeClient,
58
- keyPrefix: opts.keyPrefix,
59
- duration: Math.floor(opts.durationMs / 1000),
60
- points: opts.points,
61
- })
62
- return new RateLimiter(limiter, opts)
89
+ this.failClosed = options.failClosed ?? false
90
+ this.calcKey = options.calcKey
91
+ this.calcPoints = options.calcPoints
63
92
  }
64
93
 
65
94
  async consume(
66
- ctx: XRPCReqContext,
67
- opts?: { calcKey?: CalcKeyFn; calcPoints?: CalcPointsFn },
95
+ ctx: C,
96
+ opts?: RateLimiterConsumeOptions<C>,
68
97
  ): Promise<RateLimiterStatus | RateLimitExceededError | null> {
69
- if (
70
- this.bypassSecret &&
71
- ctx.req.header('x-ratelimit-bypass') === this.bypassSecret
72
- ) {
73
- return null
74
- }
75
- if (this.bypassIps && this.bypassIps.includes(ctx.req.ip)) {
76
- return null
77
- }
78
- const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
98
+ const calcKey = opts?.calcKey ?? this.calcKey
99
+ const key = calcKey(ctx)
79
100
  if (key === null) {
80
101
  return null
81
102
  }
82
- const points = opts?.calcPoints
83
- ? opts.calcPoints(ctx)
84
- : this.calcPoints(ctx)
103
+ const calcPoints = opts?.calcPoints ?? this.calcPoints
104
+ const points = calcPoints(ctx)
85
105
  if (points < 1) {
86
106
  return null
87
107
  }
@@ -111,10 +131,7 @@ export class RateLimiter implements RateLimiterI {
111
131
  }
112
132
  }
113
133
 
114
- async reset(
115
- ctx: XRPCReqContext,
116
- opts?: { calcKey?: CalcKeyFn },
117
- ): Promise<void> {
134
+ async reset(ctx: C, opts?: RateLimiterResetOptions<C>): Promise<void> {
118
135
  const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
119
136
  if (key === null) {
120
137
  return
@@ -128,6 +145,33 @@ export class RateLimiter implements RateLimiterI {
128
145
  }
129
146
  }
130
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
+
131
175
  export const formatLimiterStatus = (
132
176
  limiter: RateLimiterAbstract,
133
177
  res: RateLimiterRes,
@@ -142,46 +186,84 @@ export const formatLimiterStatus = (
142
186
  }
143
187
  }
144
188
 
145
- export const consumeMany = async (
146
- ctx: XRPCReqContext,
147
- fns: RateLimiterConsume[],
148
- ): Promise<RateLimiterStatus | RateLimitExceededError | null> => {
149
- if (fns.length === 0) return null
150
- const results = await Promise.all(fns.map((fn) => fn(ctx)))
151
- const tightestLimit = getTightestLimit(results)
152
- if (tightestLimit === null) {
153
- return null
154
- } else if (tightestLimit instanceof RateLimitExceededError) {
155
- setResHeaders(ctx, tightestLimit.status)
156
- return tightestLimit
157
- } else {
158
- setResHeaders(ctx, tightestLimit)
159
- return tightestLimit
160
- }
189
+ export type WrappedRateLimiterOptions<
190
+ C extends RateLimiterContext = RateLimiterContext,
191
+ > = {
192
+ calcKey?: CalcKeyFn<C>
193
+ calcPoints?: CalcPointsFn<C>
161
194
  }
162
195
 
163
- export const resetMany = async (
164
- ctx: XRPCReqContext,
165
- fns: RateLimiterReset[],
166
- ): Promise<void> => {
167
- if (fns.length === 0) return
168
- await Promise.all(fns.map((fn) => fn(ctx)))
196
+ /**
197
+ * Wraps a {@link RateLimiterI} instance with custom key and points calculation
198
+ * functions.
199
+ */
200
+ export class WrappedRateLimiter<
201
+ C extends RateLimiterContext = RateLimiterContext,
202
+ > implements RateLimiterI<C>
203
+ {
204
+ private constructor(
205
+ private readonly rateLimiter: RateLimiterI<C>,
206
+ private readonly options: Readonly<WrappedRateLimiterOptions<C>>,
207
+ ) {}
208
+
209
+ async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
210
+ return this.rateLimiter.consume(ctx, {
211
+ calcKey: opts?.calcKey ?? this.options.calcKey,
212
+ calcPoints: opts?.calcPoints ?? this.options.calcPoints,
213
+ })
214
+ }
215
+
216
+ async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
217
+ return this.rateLimiter.reset(ctx, {
218
+ calcKey: opts?.calcKey ?? this.options.calcKey,
219
+ })
220
+ }
221
+
222
+ static from<C extends RateLimiterContext = RateLimiterContext>(
223
+ rateLimiter: RateLimiterI<C>,
224
+ { calcKey, calcPoints }: WrappedRateLimiterOptions<C> = {},
225
+ ): RateLimiterI<C> {
226
+ if (!calcKey && !calcPoints) return rateLimiter
227
+ return new WrappedRateLimiter<C>(rateLimiter, { calcKey, calcPoints })
228
+ }
169
229
  }
170
230
 
171
- export const setResHeaders = (
172
- ctx: XRPCReqContext,
173
- status: RateLimiterStatus,
174
- ) => {
175
- ctx.res.setHeader('RateLimit-Limit', status.limit)
176
- ctx.res.setHeader('RateLimit-Remaining', status.remainingPoints)
177
- ctx.res.setHeader(
178
- 'RateLimit-Reset',
179
- Math.floor((Date.now() + status.msBeforeNext) / 1000),
180
- )
181
- ctx.res.setHeader('RateLimit-Policy', `${status.limit};w=${status.duration}`)
231
+ /**
232
+ * Combines multiple rate limiters into one.
233
+ *
234
+ * The combined rate limiter will return the tightest (most restrictive) of all
235
+ * the provided rate limiters.
236
+ */
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
+ ) {}
244
+
245
+ async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
246
+ const promises: ReturnType<RateLimiterConsume>[] = []
247
+ for (const rl of this.rateLimiters) promises.push(rl.consume(ctx, opts))
248
+ return Promise.all(promises).then(getTightestLimit)
249
+ }
250
+
251
+ async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
252
+ const promises: ReturnType<RateLimiterReset>[] = []
253
+ for (const rl of this.rateLimiters) promises.push(rl.reset(ctx, opts))
254
+ await Promise.all(promises)
255
+ }
256
+
257
+ static from<C extends RateLimiterContext = RateLimiterContext>(
258
+ rateLimiters: readonly RateLimiterI<C>[],
259
+ ): RateLimiterI<C> | undefined {
260
+ if (rateLimiters.length === 0) return undefined
261
+ if (rateLimiters.length === 1) return rateLimiters[0]
262
+ return new CombinedRateLimiter(rateLimiters)
263
+ }
182
264
  }
183
265
 
184
- export const getTightestLimit = (
266
+ const getTightestLimit = (
185
267
  resps: (RateLimiterStatus | RateLimitExceededError | null)[],
186
268
  ): RateLimiterStatus | RateLimitExceededError | null => {
187
269
  let lowest: RateLimiterStatus | null = null
@@ -195,7 +277,91 @@ export const getTightestLimit = (
195
277
  return lowest
196
278
  }
197
279
 
198
- // when using a proxy, ensure headers are getting forwarded correctly: `app.set('trust proxy', true)`
199
- // https://expressjs.com/en/guide/behind-proxies.html
200
- const defaultKey: CalcKeyFn = (ctx: XRPCReqContext) => ctx.req.ip
201
- const defaultPoints: CalcPointsFn = () => 1
280
+ export type RouteRateLimiterOptions<
281
+ C extends RateLimiterContext = RateLimiterContext,
282
+ > = {
283
+ bypass?: (ctx: C) => boolean
284
+ }
285
+
286
+ /**
287
+ * Wraps a {@link RateLimiterI} interface into a class that will apply the
288
+ * appropriate headers to the response if a limit is exceeded.
289
+ */
290
+ export class RouteRateLimiter<C extends RateLimiterContext = RateLimiterContext>
291
+ implements RateLimiterI<C>
292
+ {
293
+ constructor(
294
+ private readonly rateLimiter: RateLimiterI<C>,
295
+ private readonly options: Readonly<RouteRateLimiterOptions<C>> = {},
296
+ ) {}
297
+
298
+ async handle(ctx: C): Promise<RateLimiterStatus | null> {
299
+ const { bypass } = this.options
300
+ if (bypass && bypass(ctx)) {
301
+ return null
302
+ }
303
+
304
+ const result = await this.consume(ctx)
305
+ if (result instanceof RateLimitExceededError) {
306
+ setStatusHeaders(ctx, result.status)
307
+ throw result
308
+ } else if (result != null) {
309
+ setStatusHeaders(ctx, result)
310
+ }
311
+
312
+ return result
313
+ }
314
+
315
+ async consume(...args: Parameters<RateLimiterConsume<C>>) {
316
+ return this.rateLimiter.consume(...args)
317
+ }
318
+
319
+ async reset(...args: Parameters<RateLimiterReset<C>>) {
320
+ return this.rateLimiter.reset(...args)
321
+ }
322
+
323
+ static from<C extends RateLimiterContext = RateLimiterContext>(
324
+ rateLimiters: readonly RateLimiterI<C>[],
325
+ { bypass }: RouteRateLimiterOptions<C> = {},
326
+ ): RouteRateLimiter<C> | undefined {
327
+ const rateLimiter = CombinedRateLimiter.from(rateLimiters)
328
+ if (!rateLimiter) return undefined
329
+
330
+ return new RouteRateLimiter(rateLimiter, { bypass })
331
+ }
332
+ }
333
+
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}`)
344
+ }
345
+
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
+ }