@atproto/xrpc-server 0.11.5 → 0.11.6

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 DELETED
@@ -1,858 +0,0 @@
1
- import assert from 'node:assert'
2
- import { IncomingMessage } from 'node:http'
3
- import { Readable } from 'node:stream'
4
- import { pipeline } from 'node:stream/promises'
5
- import express, {
6
- Application,
7
- ErrorRequestHandler,
8
- Express,
9
- RequestHandler,
10
- Router,
11
- } from 'express'
12
- import { LexValue } from '@atproto/lex-data'
13
- import { l } from '@atproto/lex-schema'
14
- import {
15
- LexXrpcProcedure,
16
- LexXrpcQuery,
17
- LexXrpcSubscription,
18
- LexiconDoc,
19
- Lexicons,
20
- lexToJson,
21
- } from '@atproto/lexicon'
22
- import {
23
- InternalServerError,
24
- InvalidRequestError,
25
- MethodNotImplementedError,
26
- XRPCError,
27
- excludeErrorResult,
28
- } from './errors.js'
29
- import log, { LOGGER_NAME } from './logger.js'
30
- import { HttpRateLimiter } from './rate-limiter-http.js'
31
- import {
32
- CalcKeyFn,
33
- CalcPointsFn,
34
- RateLimiterErrorHandlerDetails,
35
- RateLimiterI,
36
- RateLimiterOptions,
37
- WrappedRateLimiter,
38
- } from './rate-limiter.js'
39
- import {
40
- ErrorFrame,
41
- Frame,
42
- MessageFrame,
43
- XrpcStreamServer,
44
- } from './stream/index.js'
45
- import {
46
- Auth,
47
- AuthResult,
48
- AuthVerifier,
49
- CatchallHandler,
50
- HandlerContext,
51
- Input,
52
- LexMethodConfig,
53
- LexMethodHandler,
54
- LexMethodInput,
55
- LexMethodOutput,
56
- LexMethodParams,
57
- LexSubscriptionConfig,
58
- LexSubscriptionHandler,
59
- MethodAuthContext,
60
- MethodConfig,
61
- MethodConfigOrHandler,
62
- MethodHandler,
63
- Options,
64
- Output,
65
- Params,
66
- RouteOptions,
67
- ServerRateLimitDescription,
68
- StreamAuthContext,
69
- StreamConfig,
70
- StreamConfigOrHandler,
71
- StreamContext,
72
- isHandlerPipeThroughBuffer,
73
- isHandlerPipeThroughStream,
74
- isHandlerSuccess,
75
- isSharedRateLimitOpts,
76
- } from './types.js'
77
- import {
78
- AuthVerifierInternal,
79
- InputVerifierInternal,
80
- OutputVerifierInternal,
81
- ParamsVerifierInternal,
82
- asArray,
83
- createLexiconInputVerifier,
84
- createLexiconOutputVerifier,
85
- createLexiconParamsVerifier,
86
- createSchemaInputVerifier,
87
- createSchemaOutputVerifier,
88
- createSchemaParamsVerifier,
89
- extractUrlNsid,
90
- setHeaders,
91
- } from './util.js'
92
-
93
- export function createServer(lexicons?: LexiconDoc[], options?: Options) {
94
- return new Server(lexicons, options)
95
- }
96
-
97
- export class Server {
98
- router: Express = express()
99
- routes: Router = Router()
100
- subscriptions = new Map<string, XrpcStreamServer>()
101
- lex = new Lexicons()
102
- options: Options
103
- globalRateLimiter?: HttpRateLimiter<HandlerContext>
104
- sharedRateLimiters?: Map<string, RateLimiterI<HandlerContext>>
105
-
106
- constructor(lexicons?: LexiconDoc[], opts: Options = {}) {
107
- if (lexicons) {
108
- this.addLexicons(lexicons)
109
- }
110
- this.router.use(this.routes)
111
- this.router.use(this.catchall)
112
- this.router.use(createErrorMiddleware(opts))
113
- this.router.once('mount', (app: Application) => {
114
- this.enableStreamingOnListen(app)
115
- })
116
- this.options = opts
117
-
118
- if (opts.rateLimits) {
119
- const { global, shared, creator, bypass } = opts.rateLimits
120
-
121
- if (global) {
122
- this.globalRateLimiter = HttpRateLimiter.from(
123
- global.map((options) => creator(buildRateLimiterOptions(options))),
124
- { bypass },
125
- )
126
- }
127
-
128
- if (shared) {
129
- this.sharedRateLimiters = new Map(
130
- shared.map((options) => [
131
- options.name,
132
- creator(buildRateLimiterOptions(options)),
133
- ]),
134
- )
135
- }
136
- }
137
- }
138
-
139
- listen(port: number, callback?: () => void) {
140
- return this.router.listen(port, callback)
141
- }
142
-
143
- // handlers
144
- // =
145
-
146
- // Routes with auth
147
- add<M extends l.Procedure | l.Query | l.Subscription, A extends AuthResult>(
148
- ns: l.Main<M>,
149
- config: M extends l.Procedure | l.Query
150
- ? LexMethodConfig<M, A> & { auth: Exclude<unknown, void> }
151
- : M extends l.Subscription
152
- ? LexSubscriptionConfig<M, A> & { auth: Exclude<unknown, void> }
153
- : never,
154
- ): void
155
- // Routes without auth
156
- add<M extends l.Procedure | l.Query | l.Subscription>(
157
- ns: l.Main<M>,
158
- config: M extends l.Procedure | l.Query
159
- ? LexMethodConfig<M, void> | LexMethodHandler<M, void>
160
- : M extends l.Subscription
161
- ? LexSubscriptionConfig<M, void> | LexSubscriptionHandler<M, void>
162
- : never,
163
- ): void
164
- add<M extends l.Procedure | l.Query | l.Subscription, A extends Auth>(
165
- ns: l.Main<M>,
166
- configOfHandler: M extends l.Procedure | l.Query
167
- ? LexMethodConfig<M, A> | LexMethodHandler<M, A>
168
- : M extends l.Subscription
169
- ? LexSubscriptionConfig<M, A> | LexSubscriptionHandler<M, A>
170
- : never,
171
- ): void {
172
- const schema = l.getMain(ns)
173
- const config =
174
- typeof configOfHandler === 'function'
175
- ? { handler: configOfHandler }
176
- : configOfHandler
177
- switch (schema.type) {
178
- case 'procedure':
179
- return this.addProcedureSchema(
180
- schema,
181
- config as LexMethodConfig<l.Procedure, A>,
182
- )
183
- case 'query':
184
- return this.addQuerySchema(
185
- schema,
186
- config as LexMethodConfig<l.Query, A>,
187
- )
188
- case 'subscription':
189
- return this.addSubscriptionSchema(
190
- schema,
191
- config as LexSubscriptionConfig<l.Subscription, A>,
192
- )
193
- default:
194
- throw new TypeError(
195
- // @ts-expect-error should never happen
196
- `Unsupported schema ${schema.nsid} of type ${schema.type}`,
197
- )
198
- }
199
- }
200
-
201
- protected addProcedureSchema<M extends l.Procedure, A extends Auth>(
202
- schema: M,
203
- config: LexMethodConfig<M, A>,
204
- ): void {
205
- this.routes.get(`/xrpc/${schema.nsid}`, (req, res, next) => {
206
- next(
207
- new InvalidRequestError(
208
- `Incorrect HTTP method (${req.method}) expected POST`,
209
- ),
210
- )
211
- })
212
- this.routes.post(
213
- `/xrpc/${schema.nsid}`,
214
- this.createHandlerInternal<
215
- A,
216
- LexMethodParams<M>,
217
- LexMethodInput<M>,
218
- LexMethodOutput<M>
219
- >(
220
- this.createAuthVerifier(config),
221
- this.createSchemaParamsVerifier(schema, config.opts),
222
- this.createSchemaInputVerifier(schema, config.opts),
223
- this.createRouteRateLimiter(schema.nsid, config),
224
- config.handler,
225
- this.createSchemaOutputVerifier(schema),
226
- ),
227
- )
228
- }
229
-
230
- protected addQuerySchema<M extends l.Query, A extends Auth>(
231
- schema: M,
232
- config: LexMethodConfig<M, A>,
233
- ): void {
234
- this.routes.post(`/xrpc/${schema.nsid}`, (req, res, next) => {
235
- next(
236
- new InvalidRequestError(
237
- `Incorrect HTTP method (${req.method}) expected GET`,
238
- ),
239
- )
240
- })
241
- this.routes.get(
242
- `/xrpc/${schema.nsid}`,
243
- this.createHandlerInternal<
244
- A,
245
- LexMethodParams<M>,
246
- LexMethodInput<M>,
247
- LexMethodOutput<M>
248
- >(
249
- this.createAuthVerifier(config),
250
- this.createSchemaParamsVerifier(schema, config.opts),
251
- this.createSchemaInputVerifier(schema, config.opts),
252
- this.createRouteRateLimiter(schema.nsid, config),
253
- config.handler,
254
- this.createSchemaOutputVerifier(schema),
255
- ),
256
- )
257
- }
258
-
259
- protected addSubscriptionSchema<
260
- M extends l.Subscription,
261
- A extends Auth = void,
262
- >(schema: M, config: LexSubscriptionConfig<M, A>): void {
263
- const { handler } = config
264
- const messageSchema =
265
- this.options.validateResponse === false ? undefined : schema.message
266
-
267
- return this.addSubscriptionInternal(
268
- schema.nsid,
269
- this.createSchemaParamsVerifier(schema),
270
- this.createAuthVerifier(config),
271
- // Wrap the handler to validate outgoing messages if a message schema
272
- // is available
273
- messageSchema
274
- ? async function* (ctx) {
275
- for await (const item of handler(ctx)) {
276
- if (item instanceof Frame) {
277
- messageSchema.validate(item.body)
278
- yield item
279
- } else {
280
- yield messageSchema.validate(item)
281
- }
282
- }
283
- }
284
- : handler,
285
- )
286
- }
287
-
288
- method<A extends Auth = Auth>(
289
- nsid: string,
290
- configOrFn: MethodConfigOrHandler<A>,
291
- ): void {
292
- this.addMethod(nsid, configOrFn)
293
- }
294
-
295
- addMethod<A extends Auth = Auth>(
296
- nsid: string,
297
- configOrFn: MethodConfigOrHandler<A>,
298
- ) {
299
- const config =
300
- typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
301
- if (config.opts && 'paramsParseLoose' in config.opts) {
302
- throw new Error(
303
- `paramsParseLoose is not supported with method(), use add() instead`,
304
- )
305
- }
306
- const def = this.lex.getDef(nsid)
307
- if (def?.type === 'query' || def?.type === 'procedure') {
308
- this.addRoute(nsid, def, config)
309
- } else {
310
- throw new Error(`Lex def for ${nsid} is not a query or a procedure`)
311
- }
312
- }
313
-
314
- streamMethod<A extends Auth = Auth>(
315
- nsid: string,
316
- configOrFn: StreamConfigOrHandler<A, Params>,
317
- ) {
318
- this.addStreamMethod(nsid, configOrFn)
319
- }
320
-
321
- addStreamMethod<A extends Auth = Auth>(
322
- nsid: string,
323
- configOrFn: StreamConfigOrHandler<A, Params>,
324
- ) {
325
- const config =
326
- typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
327
- const def = this.lex.getDef(nsid)
328
- if (def?.type === 'subscription') {
329
- this.addSubscription(nsid, def, config)
330
- } else {
331
- throw new Error(`Lex def for ${nsid} is not a subscription`)
332
- }
333
- }
334
-
335
- // schemas
336
- // =
337
-
338
- addLexicon(doc: LexiconDoc) {
339
- this.lex.add(doc)
340
- }
341
-
342
- addLexicons(docs: LexiconDoc[]) {
343
- for (const doc of docs) {
344
- this.addLexicon(doc)
345
- }
346
- }
347
-
348
- // http
349
- // =
350
-
351
- protected async addRoute<A extends Auth = Auth>(
352
- nsid: string,
353
- def: LexXrpcQuery | LexXrpcProcedure,
354
- config: MethodConfig<A>,
355
- ) {
356
- const path = `/xrpc/${nsid}`
357
- const handler = this.createHandler(nsid, def, config)
358
-
359
- if (def.type === 'procedure') {
360
- this.routes.post(path, handler)
361
- } else {
362
- this.routes.get(path, handler)
363
- }
364
- }
365
-
366
- catchall: CatchallHandler = async (req, res, next) => {
367
- // catchall handler only applies to XRPC routes
368
- if (!req.url.startsWith('/xrpc/')) return next()
369
-
370
- // Validate the NSID
371
- const nsid = extractUrlNsid(req.url)
372
- if (!nsid) {
373
- return next(new InvalidRequestError('invalid xrpc path'))
374
- }
375
-
376
- if (this.globalRateLimiter) {
377
- try {
378
- await this.globalRateLimiter.handle({
379
- req,
380
- res,
381
- auth: undefined,
382
- params: {},
383
- input: undefined,
384
- async resetRouteRateLimits() {},
385
- })
386
- } catch (err) {
387
- return next(err)
388
- }
389
- }
390
-
391
- // Ensure that known XRPC methods are only called with the correct HTTP
392
- // method.
393
- const def = this.lex.getDef(nsid)
394
- if (def) {
395
- const expectedMethod =
396
- def.type === 'procedure' ? 'POST' : def.type === 'query' ? 'GET' : null
397
- if (expectedMethod != null && expectedMethod !== req.method) {
398
- return next(
399
- new InvalidRequestError(
400
- `Incorrect HTTP method (${req.method}) expected ${expectedMethod}`,
401
- ),
402
- )
403
- }
404
- }
405
-
406
- if (this.options.catchall) {
407
- this.options.catchall.call(null, req, res, next)
408
- } else if (!def) {
409
- next(new MethodNotImplementedError())
410
- } else {
411
- next()
412
- }
413
- }
414
-
415
- createHandler<
416
- A extends Auth = Auth,
417
- P extends Params = Params,
418
- I extends Input = Input,
419
- O extends Output = Output,
420
- >(
421
- nsid: string,
422
- def: LexXrpcQuery | LexXrpcProcedure,
423
- cfg: MethodConfig<A, P, I, O>,
424
- ): RequestHandler {
425
- return this.createHandlerInternal<A, P, I, O>(
426
- this.createAuthVerifier(cfg),
427
- this.createLexiconParamsVerifier<P>(nsid, def),
428
- this.createLexiconInputVerifier<I>(nsid, def, cfg.opts),
429
- this.createRouteRateLimiter(nsid, cfg),
430
- cfg.handler,
431
- this.createLexiconOutputVerifier<O>(nsid, def),
432
- )
433
- }
434
-
435
- protected createHandlerInternal<
436
- A extends Auth,
437
- P extends Params,
438
- I extends Input,
439
- O extends Output,
440
- >(
441
- authVerifier: AuthVerifierInternal<MethodAuthContext<P>, A> | null,
442
- paramsVerifier: ParamsVerifierInternal<P>,
443
- inputVerifier: InputVerifierInternal<I>,
444
- routeLimiter: HttpRateLimiter<HandlerContext<A, P, I>> | undefined,
445
- handler: MethodHandler<A, P, I, O>,
446
- validateResOutput: null | OutputVerifierInternal<O>,
447
- ): RequestHandler {
448
- return async function (req, res, next) {
449
- try {
450
- // parse & validate params
451
- const params = paramsVerifier(req)
452
-
453
- // authenticate request
454
- const auth: A = authVerifier
455
- ? await authVerifier({ req, res, params })
456
- : (undefined as A)
457
-
458
- // parse & validate input
459
- const input: I = await inputVerifier(req, res)
460
-
461
- const ctx: HandlerContext<A, P, I> = {
462
- params,
463
- input,
464
- auth,
465
- req,
466
- res,
467
- resetRouteRateLimits: async () => routeLimiter?.reset(ctx),
468
- }
469
-
470
- // handle rate limits
471
- if (routeLimiter) await routeLimiter.handle(ctx)
472
-
473
- // run the handler
474
- const output = (await handler(ctx)) as O
475
-
476
- if (!output) {
477
- validateResOutput?.(output)
478
- res.status(200)
479
- res.end()
480
- } else if (isHandlerPipeThroughStream(output)) {
481
- setHeaders(res, output.headers)
482
- res.status(200)
483
- res.header('Content-Type', output.encoding)
484
- await pipeline(output.stream, res)
485
- } else if (isHandlerPipeThroughBuffer(output)) {
486
- setHeaders(res, output.headers)
487
- res.status(200)
488
- res.header('Content-Type', output.encoding)
489
- res.end(output.buffer)
490
- } else if (isHandlerSuccess(output)) {
491
- validateResOutput?.(output)
492
-
493
- res.status(200)
494
- setHeaders(res, output.headers)
495
-
496
- const encoding =
497
- output.encoding === 'json' ? 'application/json' : output.encoding
498
-
499
- res.header('Content-Type', encoding)
500
-
501
- if (output.body instanceof Readable) {
502
- // The "Readable" check comes first to avoid calling "lexToJson" on
503
- // a stream, which would be a bug.
504
- await pipeline(output.body, res)
505
- } else if (encoding === 'application/json') {
506
- const json = lexToJson(output.body)
507
- res.json(json)
508
- } else {
509
- res.send(
510
- Buffer.isBuffer(output.body)
511
- ? output.body
512
- : output.body instanceof Uint8Array
513
- ? Buffer.from(output.body)
514
- : output.body,
515
- )
516
- }
517
- } else {
518
- next(XRPCError.fromError(output))
519
- }
520
- } catch (err: unknown) {
521
- // Express will not call the next middleware (errorMiddleware in this case)
522
- // if the value passed to next is false-y (e.g. null, undefined, 0).
523
- // Hence we replace it with an InternalServerError.
524
- if (!err) {
525
- next(new InternalServerError())
526
- } else {
527
- next(err)
528
- }
529
- }
530
- }
531
- }
532
-
533
- protected async addSubscription<A extends Auth = Auth>(
534
- nsid: string,
535
- def: LexXrpcSubscription,
536
- cfg: StreamConfig<A, Params>,
537
- ) {
538
- this.addSubscriptionInternal(
539
- nsid,
540
- this.createLexiconParamsVerifier(nsid, def),
541
- this.createAuthVerifier(cfg),
542
- // @NOTE outgoing messages are not validated against the lexicon schema
543
- // (unlike the handlers for @atproto/lex based subscriptions)
544
- cfg.handler,
545
- )
546
- }
547
-
548
- protected addSubscriptionInternal<A extends Auth, P extends Params>(
549
- nsid: string,
550
- paramsVerifier: ParamsVerifierInternal<P>,
551
- authVerifier: AuthVerifierInternal<StreamAuthContext<P>, A> | null,
552
- handler: (ctx: StreamContext<A, P>) => AsyncIterable<unknown>,
553
- ) {
554
- this.subscriptions.set(
555
- nsid,
556
- new XrpcStreamServer({
557
- noServer: true,
558
- handler: async function* (req, signal) {
559
- try {
560
- // validate request
561
- const params = paramsVerifier(req)
562
-
563
- // authenticate request
564
- const auth = authVerifier
565
- ? await authVerifier({ req, params })
566
- : (undefined as A)
567
-
568
- // stream
569
- for await (const item of handler({ req, params, auth, signal })) {
570
- yield item instanceof Frame
571
- ? item
572
- : MessageFrame.fromLexValue(item as LexValue, nsid)
573
- }
574
- } catch (err) {
575
- yield ErrorFrame.fromError(err)
576
- }
577
- },
578
- }),
579
- )
580
- }
581
-
582
- private createAuthVerifier<C, A extends AuthResult>(cfg: {
583
- auth?: AuthVerifier<C, A>
584
- }): null | AuthVerifierInternal<C, A> {
585
- const { auth } = cfg
586
- if (!auth) return null
587
-
588
- return async (ctx) => {
589
- const result = await auth(ctx)
590
- return excludeErrorResult(result)
591
- }
592
- }
593
-
594
- private createLexiconParamsVerifier<P extends Params = Params>(
595
- nsid: string,
596
- def: LexXrpcQuery | LexXrpcProcedure | LexXrpcSubscription,
597
- ) {
598
- return createLexiconParamsVerifier<P>(nsid, def, this.lex)
599
- }
600
-
601
- private createLexiconInputVerifier<I extends Input = Input>(
602
- nsid: string,
603
- def: LexXrpcQuery | LexXrpcProcedure,
604
- opts?: RouteOptions,
605
- ): InputVerifierInternal<I> {
606
- return createLexiconInputVerifier(
607
- nsid,
608
- def,
609
- {
610
- blobLimit: opts?.blobLimit ?? this.options.payload?.blobLimit,
611
- jsonLimit: opts?.jsonLimit ?? this.options.payload?.jsonLimit,
612
- textLimit: opts?.textLimit ?? this.options.payload?.textLimit,
613
- },
614
- this.lex,
615
- )
616
- }
617
-
618
- private createLexiconOutputVerifier<O extends Output = Output>(
619
- nsid: string,
620
- def: LexXrpcQuery | LexXrpcProcedure,
621
- ): null | OutputVerifierInternal<O> {
622
- if (this.options.validateResponse === false) {
623
- return null
624
- }
625
- return createLexiconOutputVerifier(nsid, def, this.lex)
626
- }
627
-
628
- private createSchemaParamsVerifier<
629
- M extends l.Procedure | l.Query | l.Subscription,
630
- >(
631
- ns: l.Main<M>,
632
- opts?: RouteOptions,
633
- ): ParamsVerifierInternal<LexMethodParams<M>> {
634
- return createSchemaParamsVerifier<M>(ns, opts)
635
- }
636
-
637
- private createSchemaInputVerifier<M extends l.Procedure | l.Query>(
638
- ns: l.Main<M>,
639
- opts?: RouteOptions,
640
- ): InputVerifierInternal<LexMethodInput<M>> {
641
- return createSchemaInputVerifier<M>(ns, {
642
- blobLimit: opts?.blobLimit ?? this.options.payload?.blobLimit,
643
- jsonLimit: opts?.jsonLimit ?? this.options.payload?.jsonLimit,
644
- textLimit: opts?.textLimit ?? this.options.payload?.textLimit,
645
- })
646
- }
647
-
648
- private createSchemaOutputVerifier<M extends l.Procedure | l.Query>(
649
- ns: l.Main<M>,
650
- ): null | OutputVerifierInternal<LexMethodOutput<M>> {
651
- if (this.options.validateResponse === false) {
652
- return null
653
- }
654
- return createSchemaOutputVerifier<M>(ns)
655
- }
656
-
657
- private enableStreamingOnListen(app: Application) {
658
- const _listen = app.listen
659
- app.listen = (...args) => {
660
- // @ts-ignore the args spread
661
- const httpServer = _listen.call(app, ...args)
662
- httpServer.on('upgrade', (req, socket, head) => {
663
- const nsid = req.url ? extractUrlNsid(req.url) : undefined
664
- const sub = nsid ? this.subscriptions.get(nsid) : undefined
665
- if (!sub) return socket.destroy()
666
- sub.wss.handleUpgrade(req, socket, head, (ws) =>
667
- sub.wss.emit('connection', ws, req),
668
- )
669
- })
670
- return httpServer
671
- }
672
- }
673
-
674
- private createRouteRateLimiter<
675
- A extends Auth,
676
- P extends Params,
677
- I extends Input,
678
- O extends Output,
679
- >(
680
- nsid: string,
681
- config: MethodConfig<A, P, I, O>,
682
- ): HttpRateLimiter<HandlerContext<A, P, I>> | undefined {
683
- // @NOTE global & shared rate limiters are instantiated with a context of
684
- // HandlerContext which is compatible (more generic) with the context of
685
- // this route specific rate limiters (C). For this reason, it's safe to cast
686
- // the context of the global rate limiter to the context of the route
687
- // specific rate limiter (HandlerContext<A, P, I>).
688
-
689
- const globalRateLimiter = this.globalRateLimiter as
690
- | HttpRateLimiter<HandlerContext<A, P, I>>
691
- | undefined
692
-
693
- // No route specific rate limiting configured, use the global rate limiter.
694
- if (!config.rateLimit) return globalRateLimiter
695
-
696
- const { rateLimits } = this.options
697
-
698
- // @NOTE Silently ignore creation of route specific rate limiter if the
699
- // `rateLimits` options was not provided to the constructor.
700
- if (!rateLimits) return globalRateLimiter
701
-
702
- const { creator, bypass } = rateLimits
703
-
704
- const rateLimiters = asArray(config.rateLimit).map((options, i) => {
705
- if (isSharedRateLimitOpts(options)) {
706
- const rateLimiter = this.sharedRateLimiters?.get(options.name) as
707
- | RateLimiterI<HandlerContext<A, P, I>>
708
- | undefined
709
-
710
- // The route config references a shared rate limiter that does not
711
- // exist. This is a configuration error.
712
- assert(rateLimiter, `Shared rate limiter "${options.name}" not defined`)
713
-
714
- return WrappedRateLimiter.from<HandlerContext<A, P, I>>(
715
- rateLimiter,
716
- options,
717
- )
718
- } else {
719
- return creator({
720
- ...options,
721
- calcKey: options.calcKey ?? defaultKey,
722
- calcPoints: options.calcPoints ?? defaultPoints,
723
- keyPrefix: `${nsid}-${i}`,
724
- })
725
- }
726
- })
727
-
728
- // If the route config contains an empty array, use global rate limiter.
729
- if (!rateLimiters.length) return globalRateLimiter
730
-
731
- // The global rate limiter (if present) should be applied in addition to
732
- // the route specific rate limiters.
733
- if (globalRateLimiter) rateLimiters.push(globalRateLimiter)
734
-
735
- return HttpRateLimiter.from<HandlerContext<A, P, I>>(rateLimiters, {
736
- bypass,
737
- })
738
- }
739
- }
740
-
741
- function createErrorMiddleware({
742
- errorParser = (err) => XRPCError.fromError(err),
743
- }: Options): ErrorRequestHandler {
744
- return (err, req, res, next) => {
745
- const nsid = extractUrlNsid(req.originalUrl)
746
- const xrpcError = errorParser(err)
747
-
748
- // Use the request's logger (if available) to benefit from request context
749
- // (id, timing) and logging configuration (serialization, etc.).
750
- const logger = isPinoHttpRequest(req) ? req.log : log
751
-
752
- const msgError = xrpcError.error || 'Unknown'
753
- const msgLoc = nsid ? `xrpc method ${nsid}` : `${req.method} ${req.url}`
754
- const msgDetail = xrpcError.message ? ` (${xrpcError.message})` : ''
755
- const msg = `${msgError} error in ${msgLoc}${msgDetail}`
756
-
757
- logger.error(
758
- {
759
- // @NOTE Computation of error stack is an expensive operation, so
760
- // we strip it for expected (non-server) errors.
761
- err:
762
- xrpcError instanceof InternalServerError ||
763
- process.env.NODE_ENV === 'development'
764
- ? err
765
- : toSimplifiedErrorLike(err),
766
-
767
- // XRPC specific properties, for easier browsing of logs
768
- nsid,
769
- type: xrpcError.type,
770
- status: xrpcError.statusCode,
771
- payload: xrpcError.payload,
772
-
773
- // Ensure that the logged item's name is set to LOGGER_NAME, instead of
774
- // the name of the pino-http logger, to ensure consistency across logs.
775
- name: LOGGER_NAME,
776
- },
777
- msg,
778
- )
779
-
780
- if (res.headersSent) {
781
- return next(err)
782
- }
783
-
784
- return res.status(xrpcError.statusCode).json(xrpcError.payload)
785
- }
786
- }
787
-
788
- function isPinoHttpRequest(req: IncomingMessage): req is IncomingMessage & {
789
- log: { error: (obj: unknown, msg: string) => void }
790
- } {
791
- return typeof (req as { log?: any }).log?.error === 'function'
792
- }
793
-
794
- function toSimplifiedErrorLike(err: unknown): unknown {
795
- if (err instanceof Error) {
796
- // Transform into an "ErrorLike" for pino's std "err" serializer
797
- return {
798
- ...err,
799
- // Carry over non-enumerable properties
800
- message: err.message,
801
- name:
802
- !Object.hasOwn(err, 'name') &&
803
- Object.prototype.toString.call(err.constructor) === '[object Function]'
804
- ? err.constructor.name // extract the class name for sub-classes of Error
805
- : err.name,
806
- // @NOTE Error.stack, Error.cause and AggregateError.error are non
807
- // enumerable properties so they won't be spread to the ErrorLike
808
- }
809
- }
810
-
811
- return err
812
- }
813
-
814
- function buildRateLimiterOptions<C extends HandlerContext = HandlerContext>({
815
- name,
816
- calcKey = defaultKey,
817
- calcPoints = defaultPoints,
818
- failClosed = false,
819
- durationMs,
820
- points,
821
- }: ServerRateLimitDescription<C>): RateLimiterOptions<C> {
822
- return {
823
- durationMs,
824
- points,
825
- calcKey,
826
- calcPoints,
827
- keyPrefix: `rl-${name}`,
828
- onError: failClosed
829
- ? undefined // Let the error propagate
830
- : rateLimiterLoggerErrorHandler,
831
- }
832
- }
833
-
834
- const defaultPoints: CalcPointsFn = () => 1
835
-
836
- /**
837
- * @note when using a proxy, ensure headers are getting forwarded correctly:
838
- * `app.set('trust proxy', true)`
839
- *
840
- * @see {@link https://expressjs.com/en/guide/behind-proxies.html}
841
- */
842
- const defaultKey: CalcKeyFn<HandlerContext> = ({ req }) => req.ip
843
-
844
- async function rateLimiterLoggerErrorHandler(
845
- err: unknown,
846
- ctx: HandlerContext,
847
- { limiter: { keyPrefix, points, duration } }: RateLimiterErrorHandlerDetails,
848
- ): Promise<null> {
849
- const { req } = ctx
850
- const logger = isPinoHttpRequest(req) ? req.log : log
851
-
852
- logger.error(
853
- { err, keyPrefix, points, duration },
854
- 'rate limiter failed to consume points',
855
- )
856
-
857
- return null
858
- }