@atproto/xrpc-server 0.11.0 → 0.11.2

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.
@@ -1,4 +1,3 @@
1
- import { IncomingMessage, ServerResponse } from 'node:http'
2
1
  import {
3
2
  RateLimiterAbstract,
4
3
  RateLimiterMemory,
@@ -6,43 +5,29 @@ import {
6
5
  RateLimiterRes,
7
6
  } from 'rate-limiter-flexible'
8
7
  import { ResponseType, XRPCError } from './errors.js'
9
- import { logger } from './logger.js'
10
8
 
11
9
  // @NOTE Do not depend (directly or indirectly) on "./types" here, as it would
12
10
  // create a circular dependency.
13
11
 
14
- export interface RateLimiterContext {
15
- req: IncomingMessage
16
- res?: ServerResponse
17
- }
12
+ export type { RateLimiterAbstract }
18
13
 
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
14
+ export type CalcKeyFn<C = unknown> = (ctx: C) => string | null
15
+ export type CalcPointsFn<C = unknown> = (ctx: C) => number
25
16
 
26
- export interface RateLimiterI<
27
- C extends RateLimiterContext = RateLimiterContext,
28
- > {
17
+ export interface RateLimiterI<C = unknown> {
29
18
  consume: RateLimiterConsume<C>
30
19
  reset: RateLimiterReset<C>
31
20
  }
32
21
 
33
- export type RateLimiterConsumeOptions<
34
- C extends RateLimiterContext = RateLimiterContext,
35
- > = {
22
+ export type RateLimiterConsumeOptions<C = unknown> = {
36
23
  calcKey?: CalcKeyFn<C>
37
24
  calcPoints?: CalcPointsFn<C>
38
25
  }
39
26
 
40
- export type RateLimiterConsume<
41
- C extends RateLimiterContext = RateLimiterContext,
42
- > = (
27
+ export type RateLimiterConsume<C = unknown> = (
43
28
  ctx: C,
44
29
  opts?: RateLimiterConsumeOptions<C>,
45
- ) => Promise<RateLimiterStatus | RateLimitExceededError | null>
30
+ ) => Promise<RateLimiterStatus | null>
46
31
 
47
32
  export type RateLimiterStatus = {
48
33
  limit: number
@@ -53,31 +38,41 @@ export type RateLimiterStatus = {
53
38
  isFirstInDuration: boolean
54
39
  }
55
40
 
56
- export type RateLimiterResetOptions<
57
- C extends RateLimiterContext = RateLimiterContext,
58
- > = {
41
+ export type RateLimiterResetOptions<C = unknown> = {
59
42
  calcKey?: CalcKeyFn<C>
60
43
  }
61
44
 
62
- export type RateLimiterReset<
63
- C extends RateLimiterContext = RateLimiterContext,
64
- > = (ctx: C, opts?: RateLimiterResetOptions<C>) => Promise<void>
45
+ export type RateLimiterReset<C = unknown> = (
46
+ ctx: C,
47
+ opts?: RateLimiterResetOptions<C>,
48
+ ) => Promise<void>
65
49
 
66
- export type RateLimiterOptions<
67
- C extends RateLimiterContext = RateLimiterContext,
68
- > = {
50
+ export type RateLimiterErrorHandlerDetails = {
51
+ key: string
52
+ points: number
53
+ limiter: {
54
+ keyPrefix: string
55
+ points: number
56
+ duration: number
57
+ }
58
+ }
59
+ export type RateLimiterErrorHandler<C = unknown> = (
60
+ err: unknown,
61
+ ctx: C,
62
+ details: RateLimiterErrorHandlerDetails,
63
+ ) => Promise<RateLimiterStatus | null>
64
+
65
+ export type RateLimiterOptions<C = unknown> = {
69
66
  keyPrefix: string
70
67
  durationMs: number
71
68
  points: number
72
69
  calcKey: CalcKeyFn<C>
73
70
  calcPoints: CalcPointsFn<C>
74
- failClosed?: boolean
71
+ onError?: RateLimiterErrorHandler<C>
75
72
  }
76
73
 
77
- export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
78
- implements RateLimiterI<C>
79
- {
80
- private readonly failClosed?: boolean
74
+ export class RateLimiter<C = unknown> implements RateLimiterI<C> {
75
+ private readonly onError?: RateLimiterErrorHandler<C>
81
76
  private readonly calcKey: CalcKeyFn<C>
82
77
  private readonly calcPoints: CalcPointsFn<C>
83
78
 
@@ -86,7 +81,7 @@ export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
86
81
  options: RateLimiterOptions<C>,
87
82
  ) {
88
83
  this.limiter = limiter
89
- this.failClosed = options.failClosed ?? false
84
+ this.onError = options.onError
90
85
  this.calcKey = options.calcKey
91
86
  this.calcPoints = options.calcPoints
92
87
  }
@@ -94,7 +89,7 @@ export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
94
89
  async consume(
95
90
  ctx: C,
96
91
  opts?: RateLimiterConsumeOptions<C>,
97
- ): Promise<RateLimiterStatus | RateLimitExceededError | null> {
92
+ ): Promise<RateLimiterStatus | null> {
98
93
  const calcKey = opts?.calcKey ?? this.calcKey
99
94
  const key = calcKey(ctx)
100
95
  if (key === null) {
@@ -105,28 +100,26 @@ export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
105
100
  if (points < 1) {
106
101
  return null
107
102
  }
103
+ const { limiter } = this
108
104
  try {
109
- const res = await this.limiter.consume(key, points)
110
- return formatLimiterStatus(this.limiter, res)
105
+ const res = await limiter.consume(key, points)
106
+ return formatLimiterStatus(limiter, res)
111
107
  } catch (err) {
112
- // yes this library rejects with a res not an error
113
108
  if (err instanceof RateLimiterRes) {
114
- const status = formatLimiterStatus(this.limiter, err)
115
- return new RateLimitExceededError(status)
109
+ // Wrap rate-limiter-flexible error into our own error type
110
+ const status = formatLimiterStatus(limiter, err)
111
+ throw new RateLimitExceededError(status)
112
+ } else if (err instanceof RateLimitExceededError) {
113
+ // Propagate RateLimitExceededError errors
114
+ throw err
116
115
  } else {
117
- if (this.failClosed) {
118
- throw err
119
- }
120
- logger.error(
121
- {
122
- err,
123
- keyPrefix: this.limiter.keyPrefix,
124
- points: this.limiter.points,
125
- duration: this.limiter.duration,
126
- },
127
- 'rate limiter failed to consume points',
128
- )
129
- return null
116
+ // Most likely a system error (failed to connect to Redis, etc). Allow
117
+ // the caller to decide how to handle it (fail open by allowing the
118
+ // request, fail closed by rejecting the request, etc).
119
+ const { onError } = this
120
+ if (onError) return onError(err, ctx, { key, points, limiter })
121
+
122
+ throw err
130
123
  }
131
124
  }
132
125
  }
@@ -145,9 +138,7 @@ export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
145
138
  }
146
139
  }
147
140
 
148
- export class MemoryRateLimiter<
149
- C extends RateLimiterContext = RateLimiterContext,
150
- > extends RateLimiter<C> {
141
+ export class MemoryRateLimiter<C = unknown> extends RateLimiter<C> {
151
142
  constructor(options: RateLimiterOptions<C>) {
152
143
  const limiter = new RateLimiterMemory({
153
144
  keyPrefix: options.keyPrefix,
@@ -158,9 +149,7 @@ export class MemoryRateLimiter<
158
149
  }
159
150
  }
160
151
 
161
- export class RedisRateLimiter<
162
- C extends RateLimiterContext = RateLimiterContext,
163
- > extends RateLimiter<C> {
152
+ export class RedisRateLimiter<C = unknown> extends RateLimiter<C> {
164
153
  constructor(storeClient: unknown, options: RateLimiterOptions<C>) {
165
154
  const limiter = new RateLimiterRedis({
166
155
  storeClient,
@@ -186,9 +175,7 @@ export const formatLimiterStatus = (
186
175
  }
187
176
  }
188
177
 
189
- export type WrappedRateLimiterOptions<
190
- C extends RateLimiterContext = RateLimiterContext,
191
- > = {
178
+ export type WrappedRateLimiterOptions<C = unknown> = {
192
179
  calcKey?: CalcKeyFn<C>
193
180
  calcPoints?: CalcPointsFn<C>
194
181
  }
@@ -197,10 +184,7 @@ export type WrappedRateLimiterOptions<
197
184
  * Wraps a {@link RateLimiterI} instance with custom key and points calculation
198
185
  * functions.
199
186
  */
200
- export class WrappedRateLimiter<
201
- C extends RateLimiterContext = RateLimiterContext,
202
- > implements RateLimiterI<C>
203
- {
187
+ export class WrappedRateLimiter<C = unknown> implements RateLimiterI<C> {
204
188
  private constructor(
205
189
  private readonly rateLimiter: RateLimiterI<C>,
206
190
  private readonly options: Readonly<WrappedRateLimiterOptions<C>>,
@@ -219,7 +203,7 @@ export class WrappedRateLimiter<
219
203
  })
220
204
  }
221
205
 
222
- static from<C extends RateLimiterContext = RateLimiterContext>(
206
+ static from<C = unknown>(
223
207
  rateLimiter: RateLimiterI<C>,
224
208
  { calcKey, calcPoints }: WrappedRateLimiterOptions<C> = {},
225
209
  ): RateLimiterI<C> {
@@ -234,10 +218,7 @@ export class WrappedRateLimiter<
234
218
  * The combined rate limiter will return the tightest (most restrictive) of all
235
219
  * the provided rate limiters.
236
220
  */
237
- export class CombinedRateLimiter<
238
- C extends RateLimiterContext = RateLimiterContext,
239
- > implements RateLimiterI<C>
240
- {
221
+ export class CombinedRateLimiter<C = unknown> implements RateLimiterI<C> {
241
222
  private constructor(
242
223
  private readonly rateLimiters: readonly RateLimiterI<C>[],
243
224
  ) {}
@@ -245,7 +226,18 @@ export class CombinedRateLimiter<
245
226
  async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
246
227
  const promises: ReturnType<RateLimiterConsume>[] = []
247
228
  for (const rl of this.rateLimiters) promises.push(rl.consume(ctx, opts))
248
- return Promise.all(promises).then(getTightestLimit)
229
+ const results = await Promise.all(promises)
230
+
231
+ // Compute the tightest rate limit status (the one with the least remaining points)
232
+ let lowest: RateLimiterStatus | null = null
233
+ for (const resp of results) {
234
+ if (resp === null) continue
235
+ if (lowest === null || resp.remainingPoints < lowest.remainingPoints) {
236
+ lowest = resp
237
+ }
238
+ }
239
+
240
+ return lowest
249
241
  }
250
242
 
251
243
  async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
@@ -254,7 +246,7 @@ export class CombinedRateLimiter<
254
246
  await Promise.all(promises)
255
247
  }
256
248
 
257
- static from<C extends RateLimiterContext = RateLimiterContext>(
249
+ static from<C = unknown>(
258
250
  rateLimiters: readonly RateLimiterI<C>[],
259
251
  ): RateLimiterI<C> | undefined {
260
252
  if (rateLimiters.length === 0) return undefined
@@ -263,86 +255,6 @@ export class CombinedRateLimiter<
263
255
  }
264
256
  }
265
257
 
266
- const getTightestLimit = (
267
- resps: (RateLimiterStatus | RateLimitExceededError | null)[],
268
- ): RateLimiterStatus | RateLimitExceededError | null => {
269
- let lowest: RateLimiterStatus | null = null
270
- for (const resp of resps) {
271
- if (resp === null) continue
272
- if (resp instanceof RateLimitExceededError) return resp
273
- if (lowest === null || resp.remainingPoints < lowest.remainingPoints) {
274
- lowest = resp
275
- }
276
- }
277
- return lowest
278
- }
279
-
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
258
  export class RateLimitExceededError extends XRPCError {
347
259
  constructor(
348
260
  public status: RateLimiterStatus,
package/src/server.ts CHANGED
@@ -27,12 +27,13 @@ import {
27
27
  excludeErrorResult,
28
28
  } from './errors.js'
29
29
  import log, { LOGGER_NAME } from './logger.js'
30
+ import { HttpRateLimiter } from './rate-limiter-http.js'
30
31
  import {
31
32
  CalcKeyFn,
32
33
  CalcPointsFn,
34
+ RateLimiterErrorHandlerDetails,
33
35
  RateLimiterI,
34
36
  RateLimiterOptions,
35
- RouteRateLimiter,
36
37
  WrappedRateLimiter,
37
38
  } from './rate-limiter.js'
38
39
  import {
@@ -99,7 +100,7 @@ export class Server {
99
100
  subscriptions = new Map<string, XrpcStreamServer>()
100
101
  lex = new Lexicons()
101
102
  options: Options
102
- globalRateLimiter?: RouteRateLimiter<HandlerContext>
103
+ globalRateLimiter?: HttpRateLimiter<HandlerContext>
103
104
  sharedRateLimiters?: Map<string, RateLimiterI<HandlerContext>>
104
105
 
105
106
  constructor(lexicons?: LexiconDoc[], opts: Options = {}) {
@@ -118,7 +119,7 @@ export class Server {
118
119
  const { global, shared, creator, bypass } = opts.rateLimits
119
120
 
120
121
  if (global) {
121
- this.globalRateLimiter = RouteRateLimiter.from(
122
+ this.globalRateLimiter = HttpRateLimiter.from(
122
123
  global.map((options) => creator(buildRateLimiterOptions(options))),
123
124
  { bypass },
124
125
  )
@@ -426,7 +427,7 @@ export class Server {
426
427
  authVerifier: AuthVerifierInternal<MethodAuthContext<P>, A> | null,
427
428
  paramsVerifier: ParamsVerifierInternal<P>,
428
429
  inputVerifier: InputVerifierInternal<I>,
429
- routeLimiter: RouteRateLimiter<HandlerContext<A, P, I>> | undefined,
430
+ routeLimiter: HttpRateLimiter<HandlerContext<A, P, I>> | undefined,
430
431
  handler: MethodHandler<A, P, I, O>,
431
432
  validateResOutput: null | OutputVerifierInternal<O>,
432
433
  ): RequestHandler {
@@ -664,14 +665,15 @@ export class Server {
664
665
  >(
665
666
  nsid: string,
666
667
  config: MethodConfig<A, P, I, O>,
667
- ): RouteRateLimiter<HandlerContext<A, P, I>> | undefined {
668
+ ): HttpRateLimiter<HandlerContext<A, P, I>> | undefined {
668
669
  // @NOTE global & shared rate limiters are instantiated with a context of
669
670
  // HandlerContext which is compatible (more generic) with the context of
670
- // this route specific rate limiters (C). For this reason, it's safe to
671
- // cast these with an `any` context
671
+ // this route specific rate limiters (C). For this reason, it's safe to cast
672
+ // the context of the global rate limiter to the context of the route
673
+ // specific rate limiter (HandlerContext<A, P, I>).
672
674
 
673
675
  const globalRateLimiter = this.globalRateLimiter as
674
- | RouteRateLimiter<any>
676
+ | HttpRateLimiter<HandlerContext<A, P, I>>
675
677
  | undefined
676
678
 
677
679
  // No route specific rate limiting configured, use the global rate limiter.
@@ -687,13 +689,18 @@ export class Server {
687
689
 
688
690
  const rateLimiters = asArray(config.rateLimit).map((options, i) => {
689
691
  if (isSharedRateLimitOpts(options)) {
690
- const rateLimiter = this.sharedRateLimiters?.get(options.name)
692
+ const rateLimiter = this.sharedRateLimiters?.get(options.name) as
693
+ | RateLimiterI<HandlerContext<A, P, I>>
694
+ | undefined
691
695
 
692
696
  // The route config references a shared rate limiter that does not
693
697
  // exist. This is a configuration error.
694
698
  assert(rateLimiter, `Shared rate limiter "${options.name}" not defined`)
695
699
 
696
- return WrappedRateLimiter.from<any>(rateLimiter, options)
700
+ return WrappedRateLimiter.from<HandlerContext<A, P, I>>(
701
+ rateLimiter,
702
+ options,
703
+ )
697
704
  } else {
698
705
  return creator({
699
706
  ...options,
@@ -711,7 +718,9 @@ export class Server {
711
718
  // the route specific rate limiters.
712
719
  if (globalRateLimiter) rateLimiters.push(globalRateLimiter)
713
720
 
714
- return RouteRateLimiter.from<any>(rateLimiters, { bypass })
721
+ return HttpRateLimiter.from<HandlerContext<A, P, I>>(rateLimiters, {
722
+ bypass,
723
+ })
715
724
  }
716
725
  }
717
726
 
@@ -792,9 +801,20 @@ function buildRateLimiterOptions<C extends HandlerContext = HandlerContext>({
792
801
  name,
793
802
  calcKey = defaultKey,
794
803
  calcPoints = defaultPoints,
795
- ...desc
804
+ failClosed = false,
805
+ durationMs,
806
+ points,
796
807
  }: ServerRateLimitDescription<C>): RateLimiterOptions<C> {
797
- return { ...desc, calcKey, calcPoints, keyPrefix: `rl-${name}` }
808
+ return {
809
+ durationMs,
810
+ points,
811
+ calcKey,
812
+ calcPoints,
813
+ keyPrefix: `rl-${name}`,
814
+ onError: failClosed
815
+ ? undefined // Let the error propagate
816
+ : rateLimiterLoggerErrorHandler,
817
+ }
798
818
  }
799
819
 
800
820
  const defaultPoints: CalcPointsFn = () => 1
@@ -806,3 +826,19 @@ const defaultPoints: CalcPointsFn = () => 1
806
826
  * @see {@link https://expressjs.com/en/guide/behind-proxies.html}
807
827
  */
808
828
  const defaultKey: CalcKeyFn<HandlerContext> = ({ req }) => req.ip
829
+
830
+ async function rateLimiterLoggerErrorHandler(
831
+ err: unknown,
832
+ ctx: HandlerContext,
833
+ { limiter: { keyPrefix, points, duration } }: RateLimiterErrorHandlerDetails,
834
+ ): Promise<null> {
835
+ const { req } = ctx
836
+ const logger = isPinoHttpRequest(req) ? req.log : log
837
+
838
+ logger.error(
839
+ { err, keyPrefix, points, duration },
840
+ 'rate limiter failed to consume points',
841
+ )
842
+
843
+ return null
844
+ }
@@ -2,7 +2,7 @@
2
2
  "extends": "../../tsconfig/node.json",
3
3
  "compilerOptions": {
4
4
  "rootDir": "./src",
5
- "outDir": "./dist"
5
+ "outDir": "./dist",
6
6
  },
7
- "include": ["./src"]
7
+ "include": ["./src"],
8
8
  }
@@ -1 +1 @@
1
- {"root":["./src/auth.ts","./src/errors.ts","./src/index.ts","./src/logger.ts","./src/rate-limiter.ts","./src/server.ts","./src/types.ts","./src/util.ts","./src/stream/frames.ts","./src/stream/index.ts","./src/stream/logger.ts","./src/stream/server.ts","./src/stream/stream.ts","./src/stream/subscription.ts","./src/stream/types.ts"],"version":"6.0.3"}
1
+ {"version":"7.0.0-dev.20260614.1","root":["./src/auth.ts","./src/errors.ts","./src/index.ts","./src/logger.ts","./src/rate-limiter-http.ts","./src/rate-limiter.ts","./src/server.ts","./src/types.ts","./src/util.ts","./src/stream/frames.ts","./src/stream/index.ts","./src/stream/logger.ts","./src/stream/server.ts","./src/stream/stream.ts","./src/stream/subscription.ts","./src/stream/types.ts"]}
package/tsconfig.json CHANGED
@@ -2,6 +2,6 @@
2
2
  "include": [],
3
3
  "references": [
4
4
  { "path": "./tsconfig.build.json" },
5
- { "path": "./tsconfig.tests.json" }
6
- ]
5
+ { "path": "./tsconfig.tests.json" },
6
+ ],
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "../../tsconfig/tests.json",
3
3
  "compilerOptions": {
4
- "rootDir": "."
4
+ "rootDir": ".",
5
5
  },
6
- "include": ["./tests"]
6
+ "include": ["./tests"],
7
7
  }