@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
package/src/server.ts CHANGED
@@ -1,16 +1,14 @@
1
+ import assert from 'node:assert'
2
+ import { IncomingMessage } from 'node:http'
1
3
  import { Readable } from 'node:stream'
2
4
  import { pipeline } from 'node:stream/promises'
3
5
  import express, {
4
6
  Application,
5
7
  ErrorRequestHandler,
6
8
  Express,
7
- NextFunction,
8
9
  Request,
9
10
  RequestHandler,
10
- Response,
11
11
  Router,
12
- json as jsonParser,
13
- text as textParser,
14
12
  } from 'express'
15
13
  import { check, schema } from '@atproto/common'
16
14
  import {
@@ -21,36 +19,51 @@ import {
21
19
  Lexicons,
22
20
  lexToJson,
23
21
  } from '@atproto/lexicon'
22
+ import {
23
+ InternalServerError,
24
+ InvalidRequestError,
25
+ MethodNotImplementedError,
26
+ XRPCError,
27
+ excludeErrorResult,
28
+ isErrorResult,
29
+ } from './errors'
24
30
  import log, { LOGGER_NAME } from './logger'
25
- import { consumeMany, resetMany } from './rate-limiter'
31
+ import {
32
+ CalcKeyFn,
33
+ CalcPointsFn,
34
+ RateLimiterI,
35
+ RateLimiterOptions,
36
+ RouteRateLimiter,
37
+ WrappedRateLimiter,
38
+ } from './rate-limiter'
26
39
  import { ErrorFrame, Frame, MessageFrame, XrpcStreamServer } from './stream'
27
40
  import {
41
+ Auth,
42
+ AuthResult,
28
43
  AuthVerifier,
29
- HandlerAuth,
30
- HandlerPipeThrough,
44
+ CatchallHandler,
45
+ HandlerContext,
31
46
  HandlerSuccess,
32
- InternalServerError,
33
- InvalidRequestError,
34
- MethodNotImplementedError,
47
+ Input,
48
+ MethodConfig,
49
+ MethodConfigOrHandler,
35
50
  Options,
36
51
  Params,
37
- RateLimitExceededError,
38
- RateLimiterI,
39
- XRPCError,
40
- XRPCHandler,
41
- XRPCHandlerConfig,
42
- XRPCReqContext,
43
- XRPCStreamHandler,
44
- XRPCStreamHandlerConfig,
45
- isHandlerError,
52
+ RouteOptions,
53
+ ServerRateLimitDescription,
54
+ StreamConfig,
55
+ StreamConfigOrHandler,
46
56
  isHandlerPipeThroughBuffer,
47
57
  isHandlerPipeThroughStream,
48
- isShared,
58
+ isSharedRateLimitOpts,
49
59
  } from './types'
50
60
  import {
61
+ asArray,
62
+ createInputVerifier,
51
63
  decodeQueryParams,
64
+ extractUrlNsid,
52
65
  getQueryParams,
53
- validateInput,
66
+ setHeaders,
54
67
  validateOutput,
55
68
  } from './util'
56
69
 
@@ -64,45 +77,38 @@ export class Server {
64
77
  subscriptions = new Map<string, XrpcStreamServer>()
65
78
  lex = new Lexicons()
66
79
  options: Options
67
- middleware: Record<'json' | 'text', RequestHandler>
68
- globalRateLimiters: RateLimiterI[]
69
- sharedRateLimiters: Record<string, RateLimiterI>
70
- routeRateLimiters: Record<string, RateLimiterI[]>
80
+ globalRateLimiter?: RouteRateLimiter<HandlerContext>
81
+ sharedRateLimiters?: Map<string, RateLimiterI<HandlerContext>>
71
82
 
72
83
  constructor(lexicons?: LexiconDoc[], opts: Options = {}) {
73
84
  if (lexicons) {
74
85
  this.addLexicons(lexicons)
75
86
  }
76
87
  this.router.use(this.routes)
77
- this.router.use('/xrpc/:methodId', this.catchall.bind(this))
88
+ this.router.use(this.catchall)
78
89
  this.router.use(createErrorMiddleware(opts))
79
90
  this.router.once('mount', (app: Application) => {
80
91
  this.enableStreamingOnListen(app)
81
92
  })
82
93
  this.options = opts
83
- this.middleware = {
84
- json: jsonParser({ limit: opts?.payload?.jsonLimit }),
85
- text: textParser({ limit: opts?.payload?.textLimit }),
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)
94
+
95
+ if (opts.rateLimits) {
96
+ const { global, shared, creator, bypass } = opts.rateLimits
97
+
98
+ if (global) {
99
+ this.globalRateLimiter = RouteRateLimiter.from(
100
+ global.map((options) => creator(buildRateLimiterOptions(options))),
101
+ { bypass },
102
+ )
97
103
  }
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
104
+
105
+ if (shared) {
106
+ this.sharedRateLimiters = new Map(
107
+ shared.map((options) => [
108
+ options.name,
109
+ creator(buildRateLimiterOptions(options)),
110
+ ]),
111
+ )
106
112
  }
107
113
  }
108
114
  }
@@ -110,11 +116,17 @@ export class Server {
110
116
  // handlers
111
117
  // =
112
118
 
113
- method(nsid: string, configOrFn: XRPCHandlerConfig | XRPCHandler) {
119
+ method<A extends Auth = Auth>(
120
+ nsid: string,
121
+ configOrFn: MethodConfigOrHandler<A>,
122
+ ) {
114
123
  this.addMethod(nsid, configOrFn)
115
124
  }
116
125
 
117
- addMethod(nsid: string, configOrFn: XRPCHandlerConfig | XRPCHandler) {
126
+ addMethod<A extends Auth = Auth>(
127
+ nsid: string,
128
+ configOrFn: MethodConfigOrHandler<A>,
129
+ ) {
118
130
  const config =
119
131
  typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
120
132
  const def = this.lex.getDef(nsid)
@@ -125,16 +137,16 @@ export class Server {
125
137
  }
126
138
  }
127
139
 
128
- streamMethod(
140
+ streamMethod<A extends Auth = Auth>(
129
141
  nsid: string,
130
- configOrFn: XRPCStreamHandlerConfig | XRPCStreamHandler,
142
+ configOrFn: StreamConfigOrHandler<A>,
131
143
  ) {
132
144
  this.addStreamMethod(nsid, configOrFn)
133
145
  }
134
146
 
135
- addStreamMethod(
147
+ addStreamMethod<A extends Auth = Auth>(
136
148
  nsid: string,
137
- configOrFn: XRPCStreamHandlerConfig | XRPCStreamHandler,
149
+ configOrFn: StreamConfigOrHandler<A>,
138
150
  ) {
139
151
  const config =
140
152
  typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
@@ -162,160 +174,175 @@ export class Server {
162
174
  // http
163
175
  // =
164
176
 
165
- protected async addRoute(
177
+ protected async addRoute<A extends Auth = Auth>(
166
178
  nsid: string,
167
179
  def: LexXrpcQuery | LexXrpcProcedure,
168
- config: XRPCHandlerConfig,
180
+ config: MethodConfig<A>,
169
181
  ) {
170
- const verb: 'post' | 'get' = def.type === 'procedure' ? 'post' : 'get'
171
- const middleware: RequestHandler[] = []
172
- middleware.push(createLocalsMiddleware(nsid))
173
- if (config.auth) {
174
- middleware.push(createAuthMiddleware(config.auth))
175
- }
176
- if (verb === 'post') {
177
- middleware.push(this.middleware.json)
178
- middleware.push(this.middleware.text)
182
+ const path = `/xrpc/${nsid}`
183
+ const handler = this.createHandler(nsid, def, config)
184
+
185
+ if (def.type === 'procedure') {
186
+ this.routes.post(path, handler)
187
+ } else {
188
+ this.routes.get(path, handler)
179
189
  }
180
- this.setupRouteRateLimits(nsid, config)
181
- this.routes[verb](
182
- `/xrpc/${nsid}`,
183
- ...middleware,
184
- this.createHandler(nsid, def, config),
185
- )
186
190
  }
187
191
 
188
- async catchall(req: Request, res: Response, next: NextFunction) {
189
- if (this.globalRateLimiters) {
192
+ catchall: CatchallHandler = async (req, res, next) => {
193
+ // catchall handler only applies to XRPC routes
194
+ if (!req.url.startsWith('/xrpc/')) return next()
195
+
196
+ // Validate the NSID
197
+ const nsid = extractUrlNsid(req.url)
198
+ if (!nsid) {
199
+ return next(new InvalidRequestError('invalid xrpc path'))
200
+ }
201
+
202
+ if (this.globalRateLimiter) {
190
203
  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
- }
204
+ await this.globalRateLimiter.handle({
205
+ req,
206
+ res,
207
+ auth: undefined,
208
+ params: {},
209
+ input: undefined,
210
+ async resetRouteRateLimits() {},
211
+ })
207
212
  } catch (err) {
208
213
  return next(err)
209
214
  }
210
215
  }
211
216
 
217
+ // Ensure that known XRPC methods are only called with the correct HTTP
218
+ // method.
219
+ const def = this.lex.getDef(nsid)
220
+ if (def) {
221
+ const expectedMethod =
222
+ def.type === 'procedure' ? 'POST' : def.type === 'query' ? 'GET' : null
223
+ if (expectedMethod != null && expectedMethod !== req.method) {
224
+ return next(
225
+ new InvalidRequestError(
226
+ `Incorrect HTTP method (${req.method}) expected ${expectedMethod}`,
227
+ ),
228
+ )
229
+ }
230
+ }
231
+
212
232
  if (this.options.catchall) {
213
- return this.options.catchall(req, res, next)
233
+ this.options.catchall.call(null, req, res, next)
234
+ } else if (!def) {
235
+ next(new MethodNotImplementedError())
236
+ } else {
237
+ next()
214
238
  }
239
+ }
215
240
 
216
- const def = this.lex.getDef(req.params.methodId)
217
- if (!def) {
218
- return next(new MethodNotImplementedError())
241
+ protected createParamsVerifier(
242
+ nsid: string,
243
+ def: LexXrpcQuery | LexXrpcProcedure | LexXrpcSubscription,
244
+ ) {
245
+ return (req: Request | IncomingMessage): Params => {
246
+ const queryParams = 'query' in req ? req.query : getQueryParams(req.url)
247
+ const params: Params = decodeQueryParams(def, queryParams)
248
+ try {
249
+ return this.lex.assertValidXrpcParams(nsid, params) as Params
250
+ } catch (e) {
251
+ throw new InvalidRequestError(String(e))
252
+ }
219
253
  }
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
- )
254
+ }
255
+
256
+ protected createInputVerifier(
257
+ nsid: string,
258
+ def: LexXrpcQuery | LexXrpcProcedure,
259
+ routeOpts: RouteOptions,
260
+ ) {
261
+ return createInputVerifier(nsid, def, routeOpts, this.lex)
262
+ }
263
+
264
+ protected createAuthVerifier<C, A extends Auth>(cfg: {
265
+ auth?: AuthVerifier<C, A & AuthResult>
266
+ }): null | ((ctx: C) => Promise<A>) {
267
+ const { auth } = cfg
268
+ if (!auth) return null
269
+
270
+ return async (ctx: C) => {
271
+ const result = await auth(ctx)
272
+ return excludeErrorResult(result)
233
273
  }
234
- return next()
235
274
  }
236
275
 
237
- createHandler(
276
+ createHandler<A extends Auth = Auth>(
238
277
  nsid: string,
239
278
  def: LexXrpcQuery | LexXrpcProcedure,
240
- routeCfg: XRPCHandlerConfig,
279
+ cfg: MethodConfig<A>,
241
280
  ): RequestHandler {
242
- const routeOpts = {
243
- blobLimit: routeCfg.opts?.blobLimit ?? this.options.payload?.blobLimit,
244
- }
245
- const validateReqInput = (req: Request) =>
246
- validateInput(nsid, def, req, routeOpts, this.lex)
281
+ const authVerifier = this.createAuthVerifier(cfg)
282
+ const paramsVerifier = this.createParamsVerifier(nsid, def)
283
+ const inputVerifier = this.createInputVerifier(nsid, def, {
284
+ blobLimit: cfg.opts?.blobLimit ?? this.options.payload?.blobLimit,
285
+ jsonLimit: cfg.opts?.jsonLimit ?? this.options.payload?.jsonLimit,
286
+ textLimit: cfg.opts?.textLimit ?? this.options.payload?.textLimit,
287
+ })
288
+
247
289
  const validateResOutput =
248
290
  this.options.validateResponse === false
249
291
  ? null
250
- : (output: undefined | HandlerSuccess) =>
292
+ : (output: void | HandlerSuccess) =>
251
293
  validateOutput(nsid, def, output, this.lex)
252
- const assertValidXrpcParams = (params: unknown) =>
253
- 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
- )
294
+
295
+ const routeLimiter = this.createRouteRateLimiter(nsid, cfg)
266
296
 
267
297
  return async function (req, res, next) {
268
298
  try {
269
- // validate request
270
- let params = decodeQueryParams(def, req.query)
271
- try {
272
- params = assertValidXrpcParams(params) as Params
273
- } catch (e) {
274
- throw new InvalidRequestError(String(e))
275
- }
276
- const input = validateReqInput(req)
299
+ // parse & validate params
300
+ const params: Params = paramsVerifier(req)
301
+
302
+ // authenticate request
303
+ const auth: A = authVerifier
304
+ ? await authVerifier({ req, res, params })
305
+ : (undefined as A)
277
306
 
278
- const locals: RequestLocals = req[kRequestLocals]
307
+ // parse & validate input
308
+ const input: Input = await inputVerifier(req, res)
279
309
 
280
- const reqCtx: XRPCReqContext = {
310
+ const ctx: HandlerContext<A> = {
281
311
  params,
282
312
  input,
283
- auth: locals.auth,
313
+ auth,
284
314
  req,
285
315
  res,
286
- resetRouteRateLimits: async () => resetRateLimit(reqCtx),
316
+ resetRouteRateLimits: async () => routeLimiter?.reset(ctx),
287
317
  }
288
318
 
289
319
  // handle rate limits
290
- const result = await consumeRateLimit(reqCtx)
291
- if (result instanceof RateLimitExceededError) {
292
- return next(result)
293
- }
320
+ if (routeLimiter) await routeLimiter.handle(ctx)
294
321
 
295
322
  // run the handler
296
- const output = await routeCfg.handler(reqCtx)
323
+ const output = await cfg.handler(ctx)
297
324
 
298
325
  if (!output) {
299
326
  validateResOutput?.(output)
300
327
  res.status(200)
301
328
  res.end()
302
329
  } else if (isHandlerPipeThroughStream(output)) {
303
- setHeaders(res, output)
330
+ setHeaders(res, output.headers)
304
331
  res.status(200)
305
332
  res.header('Content-Type', output.encoding)
306
333
  await pipeline(output.stream, res)
307
334
  } else if (isHandlerPipeThroughBuffer(output)) {
308
- setHeaders(res, output)
335
+ setHeaders(res, output.headers)
309
336
  res.status(200)
310
337
  res.header('Content-Type', output.encoding)
311
338
  res.end(output.buffer)
312
- } else if (isHandlerError(output)) {
339
+ } else if (isErrorResult(output)) {
313
340
  next(XRPCError.fromError(output))
314
341
  } else {
315
342
  validateResOutput?.(output)
316
343
 
317
344
  res.status(200)
318
- setHeaders(res, output)
345
+ setHeaders(res, output.headers)
319
346
 
320
347
  if (
321
348
  output.encoding === 'application/json' ||
@@ -350,34 +377,29 @@ export class Server {
350
377
  }
351
378
  }
352
379
 
353
- protected async addSubscription(
380
+ protected async addSubscription<A extends Auth = Auth>(
354
381
  nsid: string,
355
382
  def: LexXrpcSubscription,
356
- config: XRPCStreamHandlerConfig,
383
+ cfg: StreamConfig<A>,
357
384
  ) {
358
- const assertValidXrpcParams = (params: unknown) =>
359
- this.lex.assertValidXrpcParams(nsid, params)
385
+ const paramsVerifier = this.createParamsVerifier(nsid, def)
386
+ const authVerifier = this.createAuthVerifier(cfg)
387
+
388
+ const { handler } = cfg
360
389
  this.subscriptions.set(
361
390
  nsid,
362
391
  new XrpcStreamServer({
363
392
  noServer: true,
364
393
  handler: async function* (req, signal) {
365
394
  try {
366
- // authenticate request
367
- const auth = await config.auth?.({ req })
368
- if (isHandlerError(auth)) {
369
- throw XRPCError.fromHandlerError(auth)
370
- }
371
395
  // validate request
372
- let params = decodeQueryParams(def, getQueryParams(req.url))
373
- try {
374
- params = assertValidXrpcParams(params) as Params
375
- } catch (e) {
376
- throw new InvalidRequestError(String(e))
377
- }
396
+ const params = paramsVerifier(req)
397
+ // authenticate request
398
+ const auth = authVerifier
399
+ ? await authVerifier({ req, params })
400
+ : (undefined as A)
378
401
  // stream
379
- const items = config.handler({ req, params, auth, signal })
380
- for await (const item of items) {
402
+ for await (const item of handler({ req, params, auth, signal })) {
381
403
  if (item instanceof Frame) {
382
404
  yield item
383
405
  continue
@@ -419,10 +441,8 @@ export class Server {
419
441
  // @ts-ignore the args spread
420
442
  const httpServer = _listen.call(app, ...args)
421
443
  httpServer.on('upgrade', (req, socket, head) => {
422
- const url = new URL(req.url || '', 'http://x')
423
- const sub = url.pathname.startsWith('/xrpc/')
424
- ? this.subscriptions.get(url.pathname.replace('/xrpc/', ''))
425
- : undefined
444
+ const nsid = req.url ? extractUrlNsid(req.url) : undefined
445
+ const sub = nsid ? this.subscriptions.get(nsid) : undefined
426
446
  if (!sub) return socket.destroy()
427
447
  sub.wss.handleUpgrade(req, socket, head, (ws) =>
428
448
  sub.wss.emit('connection', ws, req),
@@ -432,107 +452,57 @@ export class Server {
432
452
  }
433
453
  }
434
454
 
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
- }
443
-
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
- }
455
+ private createRouteRateLimiter<A extends Auth, C extends HandlerContext>(
456
+ nsid: string,
457
+ config: MethodConfig<A>,
458
+ ): RouteRateLimiter<C> | undefined {
459
+ // @NOTE global & shared rate limiters are instantiated with a context of
460
+ // HandlerContext which is compatible (more generic) with the context of
461
+ // this route specific rate limiters (C). For this reason, it's safe to
462
+ // cast these with an `any` context
463
+
464
+ const globalRateLimiter = this.globalRateLimiter as
465
+ | RouteRateLimiter<any>
466
+ | undefined
467
+
468
+ // No route specific rate limiting configured, use the global rate limiter.
469
+ if (!config.rateLimit) return globalRateLimiter
470
+
471
+ const { rateLimits } = this.options
472
+
473
+ // @NOTE Silently ignore creation of route specific rate limiter if the
474
+ // `rateLimits` options was not provided to the constructor.
475
+ if (!rateLimits) return globalRateLimiter
476
+
477
+ const { creator, bypass } = rateLimits
478
+
479
+ const rateLimiters = asArray(config.rateLimit).map((options, i) => {
480
+ if (isSharedRateLimitOpts(options)) {
481
+ const rateLimiter = this.sharedRateLimiters?.get(options.name)
482
+
483
+ // The route config references a shared rate limiter that does not
484
+ // exist. This is a configuration error.
485
+ assert(rateLimiter, `Shared rate limiter "${options.name}" not defined`)
486
+
487
+ return WrappedRateLimiter.from<any>(rateLimiter, options)
488
+ } else {
489
+ return creator({
490
+ ...options,
491
+ calcKey: options.calcKey ?? defaultKey,
492
+ calcPoints: options.calcPoints ?? defaultPoints,
493
+ keyPrefix: `${nsid}-${i}`,
494
+ })
491
495
  }
492
- }
493
- }
494
- }
496
+ })
495
497
 
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
- }
505
- }
506
- }
498
+ // If the route config contains an empty array, use global rate limiter.
499
+ if (!rateLimiters.length) return globalRateLimiter
507
500
 
508
- const kRequestLocals = Symbol('requestLocals')
501
+ // The global rate limiter (if present) should be applied in addition to
502
+ // the route specific rate limiters.
503
+ if (globalRateLimiter) rateLimiters.push(globalRateLimiter)
509
504
 
510
- function createLocalsMiddleware(nsid: string): RequestHandler {
511
- return function (req, _res, next) {
512
- const locals: RequestLocals = { auth: undefined, nsid }
513
- req[kRequestLocals] = locals
514
- return next()
515
- }
516
- }
517
-
518
- type RequestLocals = {
519
- auth: HandlerAuth | undefined
520
- nsid: string
521
- }
522
-
523
- function createAuthMiddleware(verifier: AuthVerifier): RequestHandler {
524
- return async function (req, res, next) {
525
- try {
526
- const result = await verifier({ req, res })
527
- if (isHandlerError(result)) {
528
- throw XRPCError.fromHandlerError(result)
529
- }
530
- const locals: RequestLocals = req[kRequestLocals]
531
- locals.auth = result
532
- next()
533
- } catch (err: unknown) {
534
- next(err)
535
- }
505
+ return RouteRateLimiter.from<any>(rateLimiters, { bypass })
536
506
  }
537
507
  }
538
508
 
@@ -540,9 +510,7 @@ function createErrorMiddleware({
540
510
  errorParser = (err) => XRPCError.fromError(err),
541
511
  }: Options): ErrorRequestHandler {
542
512
  return (err, req, res, next) => {
543
- const locals: RequestLocals | undefined = req[kRequestLocals]
544
- const methodSuffix = locals ? ` method ${locals.nsid}` : ''
545
-
513
+ const nsid = extractUrlNsid(req.originalUrl)
546
514
  const xrpcError = errorParser(err)
547
515
 
548
516
  // Use the request's logger (if available) to benefit from request context
@@ -551,6 +519,10 @@ function createErrorMiddleware({
551
519
 
552
520
  const isInternalError = xrpcError instanceof InternalServerError
553
521
 
522
+ const msgPrefix = isInternalError ? 'unhandled exception' : 'error'
523
+ const msgSuffix = nsid ? `xrpc method ${nsid}` : `${req.method} ${req.url}`
524
+ const msg = `${msgPrefix} in ${msgSuffix}`
525
+
554
526
  logger.error(
555
527
  {
556
528
  // @NOTE Computation of error stack is an expensive operation, so
@@ -561,7 +533,7 @@ function createErrorMiddleware({
561
533
  : toSimplifiedErrorLike(err),
562
534
 
563
535
  // XRPC specific properties, for easier browsing of logs
564
- nsid: locals?.nsid,
536
+ nsid,
565
537
  type: xrpcError.type,
566
538
  status: xrpcError.statusCode,
567
539
  payload: xrpcError.payload,
@@ -570,9 +542,7 @@ function createErrorMiddleware({
570
542
  // the name of the pino-http logger, to ensure consistency across logs.
571
543
  name: LOGGER_NAME,
572
544
  },
573
- isInternalError
574
- ? `unhandled exception in xrpc${methodSuffix}`
575
- : `error in xrpc${methodSuffix}`,
545
+ msg,
576
546
  )
577
547
 
578
548
  if (res.headersSent) {
@@ -583,7 +553,7 @@ function createErrorMiddleware({
583
553
  }
584
554
  }
585
555
 
586
- function isPinoHttpRequest(req: Request): req is Request & {
556
+ function isPinoHttpRequest(req: IncomingMessage): req is IncomingMessage & {
587
557
  log: { error: (obj: unknown, msg: string) => void }
588
558
  } {
589
559
  return typeof (req as { log?: any }).log?.error === 'function'
@@ -608,3 +578,22 @@ function toSimplifiedErrorLike(err: unknown): unknown {
608
578
 
609
579
  return err
610
580
  }
581
+
582
+ function buildRateLimiterOptions<C extends HandlerContext = HandlerContext>({
583
+ name,
584
+ calcKey = defaultKey,
585
+ calcPoints = defaultPoints,
586
+ ...desc
587
+ }: ServerRateLimitDescription<C>): RateLimiterOptions<C> {
588
+ return { ...desc, calcKey, calcPoints, keyPrefix: `rl-${name}` }
589
+ }
590
+
591
+ const defaultPoints: CalcPointsFn = () => 1
592
+
593
+ /**
594
+ * @note when using a proxy, ensure headers are getting forwarded correctly:
595
+ * `app.set('trust proxy', true)`
596
+ *
597
+ * @see {@link https://expressjs.com/en/guide/behind-proxies.html}
598
+ */
599
+ const defaultKey: CalcKeyFn<HandlerContext> = ({ req }) => req.ip