@atproto/xrpc-server 0.7.19 → 0.8.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.
@@ -10,18 +10,19 @@ import {
10
10
  CalcPointsFn,
11
11
  RateLimitExceededError,
12
12
  RateLimiterConsume,
13
+ RateLimiterConsumeOptions,
13
14
  RateLimiterI,
14
15
  RateLimiterReset,
16
+ RateLimiterResetOptions,
15
17
  RateLimiterStatus,
16
18
  XRPCReqContext,
17
19
  } from './types'
20
+ import { setHeaders } from './util'
18
21
 
19
22
  export type RateLimiterOpts = {
20
23
  keyPrefix: string
21
24
  durationMs: number
22
25
  points: number
23
- bypassSecret?: string
24
- bypassIps?: string[]
25
26
  calcKey?: CalcKeyFn
26
27
  calcPoints?: CalcPointsFn
27
28
  failClosed?: boolean
@@ -29,16 +30,13 @@ export type RateLimiterOpts = {
29
30
 
30
31
  export class RateLimiter implements RateLimiterI {
31
32
  public limiter: RateLimiterAbstract
32
- private bypassSecret?: string
33
- private bypassIps?: string[]
33
+
34
34
  private failClosed?: boolean
35
35
  public calcKey: CalcKeyFn
36
36
  public calcPoints: CalcPointsFn
37
37
 
38
38
  constructor(limiter: RateLimiterAbstract, opts: RateLimiterOpts) {
39
39
  this.limiter = limiter
40
- this.bypassSecret = opts.bypassSecret
41
- this.bypassIps = opts.bypassIps
42
40
  this.calcKey = opts.calcKey ?? defaultKey
43
41
  this.calcPoints = opts.calcPoints ?? defaultPoints
44
42
  }
@@ -64,17 +62,8 @@ export class RateLimiter implements RateLimiterI {
64
62
 
65
63
  async consume(
66
64
  ctx: XRPCReqContext,
67
- opts?: { calcKey?: CalcKeyFn; calcPoints?: CalcPointsFn },
65
+ opts?: RateLimiterConsumeOptions,
68
66
  ): 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
67
  const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
79
68
  if (key === null) {
80
69
  return null
@@ -113,7 +102,7 @@ export class RateLimiter implements RateLimiterI {
113
102
 
114
103
  async reset(
115
104
  ctx: XRPCReqContext,
116
- opts?: { calcKey?: CalcKeyFn },
105
+ opts?: RateLimiterResetOptions,
117
106
  ): Promise<void> {
118
107
  const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
119
108
  if (key === null) {
@@ -142,46 +131,72 @@ export const formatLimiterStatus = (
142
131
  }
143
132
  }
144
133
 
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
- }
134
+ export type WrappedRateLimiterOptions = {
135
+ calcKey?: CalcKeyFn
136
+ calcPoints?: CalcPointsFn
161
137
  }
162
138
 
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)))
139
+ /**
140
+ * Wraps a {@link RateLimiterI} instance with custom key and points calculation
141
+ * functions.
142
+ */
143
+ export class WrappedRateLimiter implements RateLimiterI {
144
+ private constructor(
145
+ private readonly rateLimiter: RateLimiterI,
146
+ private readonly options: Readonly<WrappedRateLimiterOptions>,
147
+ ) {}
148
+
149
+ async consume(ctx: XRPCReqContext, opts?: RateLimiterConsumeOptions) {
150
+ return this.rateLimiter.consume(ctx, {
151
+ calcKey: opts?.calcKey ?? this.options.calcKey,
152
+ calcPoints: opts?.calcPoints ?? this.options.calcPoints,
153
+ })
154
+ }
155
+
156
+ async reset(ctx: XRPCReqContext, opts?: RateLimiterResetOptions) {
157
+ return this.rateLimiter.reset(ctx, {
158
+ calcKey: opts?.calcKey ?? this.options.calcKey,
159
+ })
160
+ }
161
+
162
+ static from(
163
+ rateLimiter: RateLimiterI,
164
+ { calcKey, calcPoints }: WrappedRateLimiterOptions = {},
165
+ ): RateLimiterI {
166
+ if (!calcKey && !calcPoints) return rateLimiter
167
+ return new WrappedRateLimiter(rateLimiter, { calcKey, calcPoints })
168
+ }
169
169
  }
170
170
 
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}`)
171
+ /**
172
+ * Combines multiple rate limiters into one.
173
+ *
174
+ * The combined rate limiter will return the tightest (most restrictive) of all
175
+ * the provided rate limiters.
176
+ */
177
+ export class CombinedRateLimiter implements RateLimiterI {
178
+ private constructor(private readonly rateLimiters: readonly RateLimiterI[]) {}
179
+
180
+ async consume(ctx: XRPCReqContext, opts?: RateLimiterConsumeOptions) {
181
+ const promises: ReturnType<RateLimiterConsume>[] = []
182
+ for (const rl of this.rateLimiters) promises.push(rl.consume(ctx, opts))
183
+ return Promise.all(promises).then(getTightestLimit)
184
+ }
185
+
186
+ async reset(ctx: XRPCReqContext, opts?: RateLimiterResetOptions) {
187
+ const promises: ReturnType<RateLimiterReset>[] = []
188
+ for (const rl of this.rateLimiters) promises.push(rl.reset(ctx, opts))
189
+ await Promise.all(promises)
190
+ }
191
+
192
+ static from(rateLimiters: readonly RateLimiterI[]): RateLimiterI | undefined {
193
+ if (rateLimiters.length === 0) return undefined
194
+ if (rateLimiters.length === 1) return rateLimiters[0]
195
+ return new CombinedRateLimiter(rateLimiters)
196
+ }
182
197
  }
183
198
 
184
- export const getTightestLimit = (
199
+ const getTightestLimit = (
185
200
  resps: (RateLimiterStatus | RateLimitExceededError | null)[],
186
201
  ): RateLimiterStatus | RateLimitExceededError | null => {
187
202
  let lowest: RateLimiterStatus | null = null
@@ -195,6 +210,65 @@ export const getTightestLimit = (
195
210
  return lowest
196
211
  }
197
212
 
213
+ export type RouteRateLimiterOptions = {
214
+ bypass?: (ctx: XRPCReqContext) => boolean
215
+ }
216
+
217
+ /**
218
+ * Wraps a {@link RateLimiterI} interface into a class that will apply the
219
+ * appropriate headers to the response if a limit is exceeded.
220
+ */
221
+ export class RouteRateLimiter implements RateLimiterI {
222
+ constructor(
223
+ private readonly rateLimiter: RateLimiterI,
224
+ private readonly options: Readonly<RouteRateLimiterOptions> = {},
225
+ ) {}
226
+
227
+ async handle(ctx: XRPCReqContext): Promise<RateLimiterStatus | null> {
228
+ const { bypass } = this.options
229
+ if (bypass && bypass(ctx)) {
230
+ return null
231
+ }
232
+
233
+ const result = await this.consume(ctx)
234
+ if (result instanceof RateLimitExceededError) {
235
+ setStatusHeaders(ctx, result.status)
236
+ throw result
237
+ } else if (result != null) {
238
+ setStatusHeaders(ctx, result)
239
+ }
240
+
241
+ return result
242
+ }
243
+
244
+ async consume(...args: Parameters<RateLimiterConsume>) {
245
+ return this.rateLimiter.consume(...args)
246
+ }
247
+
248
+ async reset(...args: Parameters<RateLimiterReset>) {
249
+ return this.rateLimiter.reset(...args)
250
+ }
251
+
252
+ static from(
253
+ rateLimiters: readonly RateLimiterI[],
254
+ { bypass }: RouteRateLimiterOptions = {},
255
+ ): RouteRateLimiter | undefined {
256
+ const rateLimiter = CombinedRateLimiter.from(rateLimiters)
257
+ if (!rateLimiter) return undefined
258
+
259
+ return new RouteRateLimiter(rateLimiter, { bypass })
260
+ }
261
+ }
262
+
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
+ })
270
+ }
271
+
198
272
  // when using a proxy, ensure headers are getting forwarded correctly: `app.set('trust proxy', true)`
199
273
  // https://expressjs.com/en/guide/behind-proxies.html
200
274
  const defaultKey: CalcKeyFn = (ctx: XRPCReqContext) => ctx.req.ip
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import assert from 'node:assert'
1
2
  import { Readable } from 'node:stream'
2
3
  import { pipeline } from 'node:stream/promises'
3
4
  import express, {
@@ -22,19 +23,17 @@ import {
22
23
  lexToJson,
23
24
  } from '@atproto/lexicon'
24
25
  import log, { LOGGER_NAME } from './logger'
25
- import { consumeMany, resetMany } from './rate-limiter'
26
+ import { RouteRateLimiter, WrappedRateLimiter } from './rate-limiter'
26
27
  import { ErrorFrame, Frame, MessageFrame, XrpcStreamServer } from './stream'
27
28
  import {
28
29
  AuthVerifier,
29
30
  HandlerAuth,
30
- HandlerPipeThrough,
31
31
  HandlerSuccess,
32
32
  InternalServerError,
33
33
  InvalidRequestError,
34
34
  MethodNotImplementedError,
35
35
  Options,
36
36
  Params,
37
- RateLimitExceededError,
38
37
  RateLimiterI,
39
38
  XRPCError,
40
39
  XRPCHandler,
@@ -48,8 +47,10 @@ import {
48
47
  isShared,
49
48
  } from './types'
50
49
  import {
50
+ asArray,
51
51
  decodeQueryParams,
52
52
  getQueryParams,
53
+ setHeaders,
53
54
  validateInput,
54
55
  validateOutput,
55
56
  } from './util'
@@ -65,9 +66,8 @@ export class Server {
65
66
  lex = new Lexicons()
66
67
  options: Options
67
68
  middleware: Record<'json' | 'text', RequestHandler>
68
- globalRateLimiters: RateLimiterI[]
69
- sharedRateLimiters: Record<string, RateLimiterI>
70
- routeRateLimiters: Record<string, RateLimiterI[]>
69
+ globalRateLimiter?: RouteRateLimiter
70
+ sharedRateLimiters?: Map<string, RateLimiterI>
71
71
 
72
72
  constructor(lexicons?: LexiconDoc[], opts: Options = {}) {
73
73
  if (lexicons) {
@@ -84,25 +84,26 @@ export class Server {
84
84
  json: jsonParser({ limit: opts?.payload?.jsonLimit }),
85
85
  text: textParser({ limit: opts?.payload?.textLimit }),
86
86
  }
87
- this.globalRateLimiters = []
88
- this.sharedRateLimiters = {}
89
- this.routeRateLimiters = {}
90
- if (opts?.rateLimits?.global) {
91
- for (const limit of opts.rateLimits.global) {
92
- const rateLimiter = opts.rateLimits.creator({
93
- ...limit,
94
- keyPrefix: `rl-${limit.name}`,
95
- })
96
- this.globalRateLimiters.push(rateLimiter)
87
+
88
+ if (opts.rateLimits) {
89
+ const { global, shared, creator, bypass } = opts.rateLimits
90
+
91
+ if (global) {
92
+ this.globalRateLimiter = RouteRateLimiter.from(
93
+ global.map(({ name, ...limit }) =>
94
+ creator({ ...limit, keyPrefix: `rl-${name}` }),
95
+ ),
96
+ { bypass },
97
+ )
97
98
  }
98
- }
99
- if (opts?.rateLimits?.shared) {
100
- for (const limit of opts.rateLimits.shared) {
101
- const rateLimiter = opts.rateLimits.creator({
102
- ...limit,
103
- keyPrefix: `rl-${limit.name}`,
104
- })
105
- this.sharedRateLimiters[limit.name] = rateLimiter
99
+
100
+ if (shared) {
101
+ this.sharedRateLimiters = new Map(
102
+ shared.map(({ name, ...limit }) => [
103
+ name,
104
+ creator({ ...limit, keyPrefix: `rl-${name}` }),
105
+ ]),
106
+ )
106
107
  }
107
108
  }
108
109
  }
@@ -177,7 +178,6 @@ export class Server {
177
178
  middleware.push(this.middleware.json)
178
179
  middleware.push(this.middleware.text)
179
180
  }
180
- this.setupRouteRateLimits(nsid, config)
181
181
  this.routes[verb](
182
182
  `/xrpc/${nsid}`,
183
183
  ...middleware,
@@ -186,52 +186,43 @@ export class Server {
186
186
  }
187
187
 
188
188
  async catchall(req: Request, res: Response, next: NextFunction) {
189
- if (this.globalRateLimiters) {
189
+ if (this.globalRateLimiter) {
190
190
  try {
191
- const rlRes = await consumeMany(
192
- {
193
- req,
194
- res,
195
- auth: undefined,
196
- params: {},
197
- input: undefined,
198
- async resetRouteRateLimits() {},
199
- },
200
- this.globalRateLimiters.map(
201
- (rl) => (ctx: XRPCReqContext) => rl.consume(ctx),
202
- ),
203
- )
204
- if (rlRes instanceof RateLimitExceededError) {
205
- return next(rlRes)
206
- }
191
+ await this.globalRateLimiter.handle({
192
+ req,
193
+ res,
194
+ auth: undefined,
195
+ params: {},
196
+ input: undefined,
197
+ async resetRouteRateLimits() {},
198
+ })
207
199
  } catch (err) {
208
200
  return next(err)
209
201
  }
210
202
  }
211
203
 
212
- if (this.options.catchall) {
213
- return this.options.catchall(req, res, next)
214
- }
215
-
204
+ // Ensure that known XRPC methods are only called with the correct HTTP
205
+ // method.
216
206
  const def = this.lex.getDef(req.params.methodId)
217
- if (!def) {
218
- return next(new MethodNotImplementedError())
207
+ if (def) {
208
+ const expectedMethod =
209
+ def.type === 'procedure' ? 'POST' : def.type === 'query' ? 'GET' : null
210
+ if (expectedMethod != null && expectedMethod !== req.method) {
211
+ return next(
212
+ new InvalidRequestError(
213
+ `Incorrect HTTP method (${req.method}) expected ${expectedMethod}`,
214
+ ),
215
+ )
216
+ }
219
217
  }
220
- // validate method
221
- if (def.type === 'query' && req.method !== 'GET') {
222
- return next(
223
- new InvalidRequestError(
224
- `Incorrect HTTP method (${req.method}) expected GET`,
225
- ),
226
- )
227
- } else if (def.type === 'procedure' && req.method !== 'POST') {
228
- return next(
229
- new InvalidRequestError(
230
- `Incorrect HTTP method (${req.method}) expected POST`,
231
- ),
232
- )
218
+
219
+ if (this.options.catchall) {
220
+ this.options.catchall.call(null, req, res, next)
221
+ } else if (!def) {
222
+ next(new MethodNotImplementedError())
223
+ } else {
224
+ next()
233
225
  }
234
- return next()
235
226
  }
236
227
 
237
228
  createHandler(
@@ -251,18 +242,8 @@ export class Server {
251
242
  validateOutput(nsid, def, output, this.lex)
252
243
  const assertValidXrpcParams = (params: unknown) =>
253
244
  this.lex.assertValidXrpcParams(nsid, params)
254
- const rls = this.routeRateLimiters[nsid] ?? []
255
- const consumeRateLimit = (reqCtx: XRPCReqContext) =>
256
- consumeMany(
257
- reqCtx,
258
- rls.map((rl) => (ctx: XRPCReqContext) => rl.consume(ctx)),
259
- )
260
-
261
- const resetRateLimit = (reqCtx: XRPCReqContext) =>
262
- resetMany(
263
- reqCtx,
264
- rls.map((rl) => (ctx: XRPCReqContext) => rl.reset(ctx)),
265
- )
245
+
246
+ const routeLimiter = this.createRouteRateLimiter(nsid, routeCfg)
266
247
 
267
248
  return async function (req, res, next) {
268
249
  try {
@@ -283,14 +264,11 @@ export class Server {
283
264
  auth: locals.auth,
284
265
  req,
285
266
  res,
286
- resetRouteRateLimits: async () => resetRateLimit(reqCtx),
267
+ resetRouteRateLimits: async () => routeLimiter?.reset(reqCtx),
287
268
  }
288
269
 
289
270
  // handle rate limits
290
- const result = await consumeRateLimit(reqCtx)
291
- if (result instanceof RateLimitExceededError) {
292
- return next(result)
293
- }
271
+ if (routeLimiter) await routeLimiter.handle(reqCtx)
294
272
 
295
273
  // run the handler
296
274
  const output = await routeCfg.handler(reqCtx)
@@ -300,12 +278,12 @@ export class Server {
300
278
  res.status(200)
301
279
  res.end()
302
280
  } else if (isHandlerPipeThroughStream(output)) {
303
- setHeaders(res, output)
281
+ setHeaders(res, output.headers)
304
282
  res.status(200)
305
283
  res.header('Content-Type', output.encoding)
306
284
  await pipeline(output.stream, res)
307
285
  } else if (isHandlerPipeThroughBuffer(output)) {
308
- setHeaders(res, output)
286
+ setHeaders(res, output.headers)
309
287
  res.status(200)
310
288
  res.header('Content-Type', output.encoding)
311
289
  res.end(output.buffer)
@@ -315,7 +293,7 @@ export class Server {
315
293
  validateResOutput?.(output)
316
294
 
317
295
  res.status(200)
318
- setHeaders(res, output)
296
+ setHeaders(res, output.headers)
319
297
 
320
298
  if (
321
299
  output.encoding === 'application/json' ||
@@ -432,76 +410,43 @@ export class Server {
432
410
  }
433
411
  }
434
412
 
435
- private setupRouteRateLimits(nsid: string, config: XRPCHandlerConfig) {
436
- this.routeRateLimiters[nsid] = []
437
- for (const limit of this.globalRateLimiters) {
438
- this.routeRateLimiters[nsid].push({
439
- consume: (ctx: XRPCReqContext) => limit.consume(ctx),
440
- reset: (ctx: XRPCReqContext) => limit.reset(ctx),
441
- })
442
- }
413
+ private createRouteRateLimiter(
414
+ nsid: string,
415
+ config: XRPCHandlerConfig,
416
+ ): RouteRateLimiter | undefined {
417
+ // No route specific rate limiting configured, use the global rate limiter.
418
+ if (!config.rateLimit) return this.globalRateLimiter
443
419
 
444
- if (config.rateLimit) {
445
- const limits = Array.isArray(config.rateLimit)
446
- ? config.rateLimit
447
- : [config.rateLimit]
448
- this.routeRateLimiters[nsid] = []
449
- for (let i = 0; i < limits.length; i++) {
450
- const limit = limits[i]
451
- const { calcKey, calcPoints } = limit
452
- if (isShared(limit)) {
453
- const rateLimiter = this.sharedRateLimiters[limit.name]
454
- if (rateLimiter) {
455
- this.routeRateLimiters[nsid].push({
456
- consume: (ctx: XRPCReqContext) =>
457
- rateLimiter.consume(ctx, {
458
- calcKey,
459
- calcPoints,
460
- }),
461
- reset: (ctx: XRPCReqContext) =>
462
- rateLimiter.reset(ctx, {
463
- calcKey,
464
- }),
465
- })
466
- }
467
- } else {
468
- const { durationMs, points } = limit
469
- const rateLimiter = this.options.rateLimits?.creator({
470
- keyPrefix: `nsid-${i}`,
471
- durationMs,
472
- points,
473
- calcKey,
474
- calcPoints,
475
- })
476
- if (rateLimiter) {
477
- this.sharedRateLimiters[nsid] = rateLimiter
478
- this.routeRateLimiters[nsid].push({
479
- consume: (ctx: XRPCReqContext) =>
480
- rateLimiter.consume(ctx, {
481
- calcKey,
482
- calcPoints,
483
- }),
484
- reset: (ctx: XRPCReqContext) =>
485
- rateLimiter.reset(ctx, {
486
- calcKey,
487
- }),
488
- })
489
- }
490
- }
420
+ const { rateLimits } = this.options
421
+
422
+ // @NOTE Silently ignore creation of route specific rate limiter if the
423
+ // `rateLimits` options was not provided to the constructor.
424
+ if (!rateLimits) return this.globalRateLimiter
425
+
426
+ const { creator, bypass } = rateLimits
427
+
428
+ const rateLimiters = asArray(config.rateLimit).map((options, i) => {
429
+ if (isShared(options)) {
430
+ const rateLimiter = this.sharedRateLimiters?.get(options.name)
431
+
432
+ // The route config references a shared rate limiter that does not
433
+ // exist. This is a configuration error.
434
+ assert(rateLimiter, `Shared rate limiter "${options.name}" not defined`)
435
+
436
+ return WrappedRateLimiter.from(rateLimiter, options)
437
+ } else {
438
+ return creator({ ...options, keyPrefix: `${nsid}-${i}` })
491
439
  }
492
- }
493
- }
494
- }
440
+ })
495
441
 
496
- function setHeaders(
497
- res: Response,
498
- result: HandlerSuccess | HandlerPipeThrough,
499
- ) {
500
- const { headers } = result
501
- if (headers) {
502
- for (const [name, val] of Object.entries(headers)) {
503
- if (val != null) res.header(name, val)
504
- }
442
+ // If the route config contains an empty array, use global rate limiter.
443
+ if (!rateLimiters.length) return this.globalRateLimiter
444
+
445
+ // The global rate limiter (if present) should be applied in addition to
446
+ // the route specific rate limiters.
447
+ if (this.globalRateLimiter) rateLimiters.push(this.globalRateLimiter)
448
+
449
+ return RouteRateLimiter.from(rateLimiters, { bypass })
505
450
  }
506
451
  }
507
452
 
package/src/types.ts CHANGED
@@ -29,6 +29,7 @@ export type Options = {
29
29
  creator: RateLimiterCreator
30
30
  global?: ServerRateLimitDescription[]
31
31
  shared?: ServerRateLimitDescription[]
32
+ bypass?: (ctx: XRPCReqContext) => boolean
32
33
  }
33
34
  /**
34
35
  * By default, errors are converted to {@link XRPCError} using
@@ -148,14 +149,23 @@ export interface RateLimiterI {
148
149
  reset: RateLimiterReset
149
150
  }
150
151
 
152
+ export type RateLimiterConsumeOptions = {
153
+ calcKey?: CalcKeyFn
154
+ calcPoints?: CalcPointsFn
155
+ }
156
+
151
157
  export type RateLimiterConsume = (
152
158
  ctx: XRPCReqContext,
153
- opts?: { calcKey?: CalcKeyFn; calcPoints?: CalcPointsFn },
159
+ opts?: RateLimiterConsumeOptions,
154
160
  ) => Promise<RateLimiterStatus | RateLimitExceededError | null>
155
161
 
162
+ export type RateLimiterResetOptions = {
163
+ calcKey?: CalcKeyFn
164
+ }
165
+
156
166
  export type RateLimiterReset = (
157
167
  ctx: XRPCReqContext,
158
- opts?: { calcKey?: CalcKeyFn },
168
+ opts?: RateLimiterResetOptions,
159
169
  ) => Promise<void>
160
170
 
161
171
  export type RateLimiterCreator = (opts: {
package/src/util.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert'
2
- import { IncomingMessage } from 'node:http'
2
+ import { IncomingMessage, OutgoingMessage } from 'node:http'
3
3
  import { Duplex, Readable, pipeline } from 'node:stream'
4
4
  import express from 'express'
5
5
  import mime from 'mime-types'
@@ -24,6 +24,20 @@ import {
24
24
  handlerSuccess,
25
25
  } from './types'
26
26
 
27
+ export const asArray = <T>(arr: T | T[]): T[] =>
28
+ Array.isArray(arr) ? arr : [arr]
29
+
30
+ export function setHeaders(
31
+ res: OutgoingMessage,
32
+ headers?: Record<string, string | number>,
33
+ ) {
34
+ if (headers) {
35
+ for (const [name, val] of Object.entries(headers)) {
36
+ if (val != null) res.setHeader(name, val)
37
+ }
38
+ }
39
+ }
40
+
27
41
  export function decodeQueryParams(
28
42
  def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription,
29
43
  params: UndecodedParams,
@@ -132,11 +132,8 @@ describe('Parameters', () => {
132
132
  let s: http.Server
133
133
  const server = xrpcServer.createServer(LEXICONS, {
134
134
  rateLimits: {
135
- creator: (opts: xrpcServer.RateLimiterOpts) =>
136
- RateLimiter.memory({
137
- bypassSecret: 'bypass',
138
- ...opts,
139
- }),
135
+ creator: (opts) => RateLimiter.memory(opts),
136
+ bypass: ({ req }) => req.headers['x-ratelimit-bypass'] === 'bypass',
140
137
  shared: [
141
138
  {
142
139
  name: 'shared-limit',