@atproto/xrpc-server 0.7.18 → 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.
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, {
@@ -21,20 +22,18 @@ import {
21
22
  Lexicons,
22
23
  lexToJson,
23
24
  } from '@atproto/lexicon'
24
- import log from './logger'
25
- import { consumeMany, resetMany } from './rate-limiter'
25
+ import log, { LOGGER_NAME } from './logger'
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
 
@@ -542,24 +487,69 @@ function createErrorMiddleware({
542
487
  return (err, req, res, next) => {
543
488
  const locals: RequestLocals | undefined = req[kRequestLocals]
544
489
  const methodSuffix = locals ? ` method ${locals.nsid}` : ''
490
+
545
491
  const xrpcError = errorParser(err)
546
- if (xrpcError instanceof InternalServerError) {
547
- // log trace for unhandled exceptions
548
- log.error({ err }, `unhandled exception in xrpc${methodSuffix}`)
549
- } else {
550
- // do not log trace for known xrpc errors
551
- log.error(
552
- {
553
- status: xrpcError.type,
554
- message: xrpcError.message,
555
- name: xrpcError.customErrorName,
556
- },
557
- `error in xrpc${methodSuffix}`,
558
- )
559
- }
492
+
493
+ // Use the request's logger (if available) to benefit from request context
494
+ // (id, timing) and logging configuration (serialization, etc.).
495
+ const logger = isPinoHttpRequest(req) ? req.log : log
496
+
497
+ const isInternalError = xrpcError instanceof InternalServerError
498
+
499
+ logger.error(
500
+ {
501
+ // @NOTE Computation of error stack is an expensive operation, so
502
+ // we strip it for expected errors.
503
+ err:
504
+ isInternalError || process.env.NODE_ENV === 'development'
505
+ ? err
506
+ : toSimplifiedErrorLike(err),
507
+
508
+ // XRPC specific properties, for easier browsing of logs
509
+ nsid: locals?.nsid,
510
+ type: xrpcError.type,
511
+ status: xrpcError.statusCode,
512
+ payload: xrpcError.payload,
513
+
514
+ // Ensure that the logged item's name is set to LOGGER_NAME, instead of
515
+ // the name of the pino-http logger, to ensure consistency across logs.
516
+ name: LOGGER_NAME,
517
+ },
518
+ isInternalError
519
+ ? `unhandled exception in xrpc${methodSuffix}`
520
+ : `error in xrpc${methodSuffix}`,
521
+ )
522
+
560
523
  if (res.headersSent) {
561
524
  return next(err)
562
525
  }
526
+
563
527
  return res.status(xrpcError.statusCode).json(xrpcError.payload)
564
528
  }
565
529
  }
530
+
531
+ function isPinoHttpRequest(req: Request): req is Request & {
532
+ log: { error: (obj: unknown, msg: string) => void }
533
+ } {
534
+ return typeof (req as { log?: any }).log?.error === 'function'
535
+ }
536
+
537
+ function toSimplifiedErrorLike(err: unknown): unknown {
538
+ if (err instanceof Error) {
539
+ // Transform into an "ErrorLike" for pino's std "err" serializer
540
+ return {
541
+ ...err,
542
+ // Carry over non-enumerable properties
543
+ message: err.message,
544
+ name:
545
+ !Object.hasOwn(err, 'name') &&
546
+ Object.prototype.toString.call(err.constructor) === '[object Function]'
547
+ ? err.constructor.name // extract the class name for sub-classes of Error
548
+ : err.name,
549
+ // @NOTE Error.stack, Error.cause and AggregateError.error are non
550
+ // enumerable properties so they won't be spread to the ErrorLike
551
+ }
552
+ }
553
+
554
+ return err
555
+ }
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',
@@ -1 +1 @@
1
- {"root":["./tests/_util.ts","./tests/auth.test.ts","./tests/bodies.test.ts","./tests/errors.test.ts","./tests/frames.test.ts","./tests/ipld.test.ts","./tests/parameters.test.ts","./tests/parsing.test.ts","./tests/procedures.test.ts","./tests/queries.test.ts","./tests/rate-limiter.test.ts","./tests/responses.test.ts","./tests/stream.test.ts","./tests/subscriptions.test.ts"],"version":"5.8.2"}
1
+ {"root":["./tests/_util.ts","./tests/auth.test.ts","./tests/bodies.test.ts","./tests/errors.test.ts","./tests/frames.test.ts","./tests/ipld.test.ts","./tests/parameters.test.ts","./tests/parsing.test.ts","./tests/procedures.test.ts","./tests/queries.test.ts","./tests/rate-limiter.test.ts","./tests/responses.test.ts","./tests/stream.test.ts","./tests/subscriptions.test.ts"],"version":"5.8.3"}