@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/xrpc-server",
3
- "version": "0.7.18",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "description": "atproto HTTP API (XRPC) server library",
6
6
  "keywords": [
package/src/logger.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { subsystemLogger } from '@atproto/common'
2
2
 
3
+ export const LOGGER_NAME = 'xrpc-server'
4
+
3
5
  export const logger: ReturnType<typeof subsystemLogger> =
4
- subsystemLogger('xrpc-server')
6
+ subsystemLogger(LOGGER_NAME)
5
7
 
6
8
  export default logger
@@ -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