@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.
@@ -1,82 +0,0 @@
1
- import { IncomingMessage, ServerResponse } from 'node:http'
2
- import {
3
- CombinedRateLimiter,
4
- RateLimitExceededError,
5
- RateLimiterConsume,
6
- RateLimiterI,
7
- RateLimiterReset,
8
- RateLimiterStatus,
9
- } from './rate-limiter.js'
10
-
11
- export interface HttpRateLimiterContext {
12
- req: IncomingMessage
13
- res?: ServerResponse
14
- }
15
-
16
- export type HttpRateLimiterOptions<
17
- C extends HttpRateLimiterContext = HttpRateLimiterContext,
18
- > = {
19
- bypass?: (ctx: C) => boolean
20
- }
21
-
22
- /**
23
- * Wraps a {@link RateLimiterI} class with an {@link RateLimiterI}
24
- * implementation that will apply the appropriate headers to the response if a
25
- * limit is exceeded.
26
- */
27
- export class HttpRateLimiter<
28
- C extends HttpRateLimiterContext = HttpRateLimiterContext,
29
- > implements RateLimiterI<C>
30
- {
31
- constructor(
32
- private readonly rateLimiter: RateLimiterI<C>,
33
- private readonly options: Readonly<HttpRateLimiterOptions<C>> = {},
34
- ) {}
35
-
36
- async handle(ctx: C): Promise<void> {
37
- const { bypass } = this.options
38
- if (bypass && bypass(ctx)) return
39
-
40
- try {
41
- const result = await this.consume(ctx)
42
- if (result != null) {
43
- setStatusHeaders(ctx, result)
44
- }
45
- } catch (err) {
46
- if (err instanceof RateLimitExceededError) {
47
- setStatusHeaders(ctx, err.status)
48
- }
49
-
50
- throw err
51
- }
52
- }
53
-
54
- async consume(...args: Parameters<RateLimiterConsume<C>>) {
55
- return this.rateLimiter.consume(...args)
56
- }
57
-
58
- async reset(...args: Parameters<RateLimiterReset<C>>) {
59
- return this.rateLimiter.reset(...args)
60
- }
61
-
62
- static from<C extends HttpRateLimiterContext = HttpRateLimiterContext>(
63
- rateLimiters: readonly RateLimiterI<C>[],
64
- { bypass }: HttpRateLimiterOptions<C> = {},
65
- ): HttpRateLimiter<C> | undefined {
66
- const rateLimiter = CombinedRateLimiter.from(rateLimiters)
67
- if (!rateLimiter) return undefined
68
-
69
- return new HttpRateLimiter<C>(rateLimiter, { bypass })
70
- }
71
- }
72
-
73
- function setStatusHeaders<
74
- C extends HttpRateLimiterContext = HttpRateLimiterContext,
75
- >(ctx: C, status: RateLimiterStatus) {
76
- const resetAt = Math.floor((Date.now() + status.msBeforeNext) / 1e3)
77
-
78
- ctx.res?.setHeader('RateLimit-Limit', status.limit)
79
- ctx.res?.setHeader('RateLimit-Reset', resetAt)
80
- ctx.res?.setHeader('RateLimit-Remaining', status.remainingPoints)
81
- ctx.res?.setHeader('RateLimit-Policy', `${status.limit};w=${status.duration}`)
82
- }
@@ -1,279 +0,0 @@
1
- import {
2
- RateLimiterAbstract,
3
- RateLimiterMemory,
4
- RateLimiterRedis,
5
- RateLimiterRes,
6
- } from 'rate-limiter-flexible'
7
- import { ResponseType, XRPCError } from './errors.js'
8
-
9
- // @NOTE Do not depend (directly or indirectly) on "./types" here, as it would
10
- // create a circular dependency.
11
-
12
- export type { RateLimiterAbstract }
13
-
14
- export type CalcKeyFn<C = unknown> = (ctx: C) => string | null
15
- export type CalcPointsFn<C = unknown> = (ctx: C) => number
16
-
17
- export interface RateLimiterI<C = unknown> {
18
- consume: RateLimiterConsume<C>
19
- reset: RateLimiterReset<C>
20
- }
21
-
22
- export type RateLimiterConsumeOptions<C = unknown> = {
23
- calcKey?: CalcKeyFn<C>
24
- calcPoints?: CalcPointsFn<C>
25
- }
26
-
27
- export type RateLimiterConsume<C = unknown> = (
28
- ctx: C,
29
- opts?: RateLimiterConsumeOptions<C>,
30
- ) => Promise<RateLimiterStatus | null>
31
-
32
- export type RateLimiterStatus = {
33
- limit: number
34
- duration: number
35
- remainingPoints: number
36
- msBeforeNext: number
37
- consumedPoints: number
38
- isFirstInDuration: boolean
39
- }
40
-
41
- export type RateLimiterResetOptions<C = unknown> = {
42
- calcKey?: CalcKeyFn<C>
43
- }
44
-
45
- export type RateLimiterReset<C = unknown> = (
46
- ctx: C,
47
- opts?: RateLimiterResetOptions<C>,
48
- ) => Promise<void>
49
-
50
- export type RateLimiterErrorHandlerDetails = {
51
- key: string
52
- points: number
53
- limiter: {
54
- keyPrefix: string
55
- points: number
56
- duration: number
57
- }
58
- }
59
- export type RateLimiterErrorHandler<C = unknown> = (
60
- err: unknown,
61
- ctx: C,
62
- details: RateLimiterErrorHandlerDetails,
63
- ) => Promise<RateLimiterStatus | null>
64
-
65
- export type RateLimiterOptions<C = unknown> = {
66
- keyPrefix: string
67
- durationMs: number
68
- points: number
69
- calcKey: CalcKeyFn<C>
70
- calcPoints: CalcPointsFn<C>
71
- onError?: RateLimiterErrorHandler<C>
72
- }
73
-
74
- export class RateLimiter<C = unknown> implements RateLimiterI<C> {
75
- private readonly onError?: RateLimiterErrorHandler<C>
76
- private readonly calcKey: CalcKeyFn<C>
77
- private readonly calcPoints: CalcPointsFn<C>
78
-
79
- constructor(
80
- public limiter: RateLimiterAbstract,
81
- options: RateLimiterOptions<C>,
82
- ) {
83
- this.limiter = limiter
84
- this.onError = options.onError
85
- this.calcKey = options.calcKey
86
- this.calcPoints = options.calcPoints
87
- }
88
-
89
- async consume(
90
- ctx: C,
91
- opts?: RateLimiterConsumeOptions<C>,
92
- ): Promise<RateLimiterStatus | null> {
93
- const calcKey = opts?.calcKey ?? this.calcKey
94
- const key = calcKey(ctx)
95
- if (key === null) {
96
- return null
97
- }
98
- const calcPoints = opts?.calcPoints ?? this.calcPoints
99
- const points = calcPoints(ctx)
100
- if (points < 1) {
101
- return null
102
- }
103
- const { limiter } = this
104
- try {
105
- const res = await limiter.consume(key, points)
106
- return formatLimiterStatus(limiter, res)
107
- } catch (err) {
108
- if (err instanceof RateLimiterRes) {
109
- // Wrap rate-limiter-flexible error into our own error type
110
- const status = formatLimiterStatus(limiter, err)
111
- throw new RateLimitExceededError(status)
112
- } else if (err instanceof RateLimitExceededError) {
113
- // Propagate RateLimitExceededError errors
114
- throw err
115
- } else {
116
- // Most likely a system error (failed to connect to Redis, etc). Allow
117
- // the caller to decide how to handle it (fail open by allowing the
118
- // request, fail closed by rejecting the request, etc).
119
- const { onError } = this
120
- if (onError) return onError(err, ctx, { key, points, limiter })
121
-
122
- throw err
123
- }
124
- }
125
- }
126
-
127
- async reset(ctx: C, opts?: RateLimiterResetOptions<C>): Promise<void> {
128
- const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
129
- if (key === null) {
130
- return
131
- }
132
-
133
- try {
134
- await this.limiter.delete(key)
135
- } catch (cause) {
136
- throw new Error(`rate limiter failed to reset key: ${key}`, { cause })
137
- }
138
- }
139
- }
140
-
141
- export class MemoryRateLimiter<C = unknown> extends RateLimiter<C> {
142
- constructor(options: RateLimiterOptions<C>) {
143
- const limiter = new RateLimiterMemory({
144
- keyPrefix: options.keyPrefix,
145
- duration: Math.floor(options.durationMs / 1000),
146
- points: options.points,
147
- })
148
- super(limiter, options)
149
- }
150
- }
151
-
152
- export class RedisRateLimiter<C = unknown> extends RateLimiter<C> {
153
- constructor(storeClient: unknown, options: RateLimiterOptions<C>) {
154
- const limiter = new RateLimiterRedis({
155
- storeClient,
156
- keyPrefix: options.keyPrefix,
157
- duration: Math.floor(options.durationMs / 1000),
158
- points: options.points,
159
- })
160
- super(limiter, options)
161
- }
162
- }
163
-
164
- export const formatLimiterStatus = (
165
- limiter: RateLimiterAbstract,
166
- res: RateLimiterRes,
167
- ): RateLimiterStatus => {
168
- return {
169
- limit: limiter.points,
170
- duration: limiter.duration,
171
- remainingPoints: res.remainingPoints,
172
- msBeforeNext: res.msBeforeNext,
173
- consumedPoints: res.consumedPoints,
174
- isFirstInDuration: res.isFirstInDuration,
175
- }
176
- }
177
-
178
- export type WrappedRateLimiterOptions<C = unknown> = {
179
- calcKey?: CalcKeyFn<C>
180
- calcPoints?: CalcPointsFn<C>
181
- }
182
-
183
- /**
184
- * Wraps a {@link RateLimiterI} instance with custom key and points calculation
185
- * functions.
186
- */
187
- export class WrappedRateLimiter<C = unknown> implements RateLimiterI<C> {
188
- private constructor(
189
- private readonly rateLimiter: RateLimiterI<C>,
190
- private readonly options: Readonly<WrappedRateLimiterOptions<C>>,
191
- ) {}
192
-
193
- async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
194
- return this.rateLimiter.consume(ctx, {
195
- calcKey: opts?.calcKey ?? this.options.calcKey,
196
- calcPoints: opts?.calcPoints ?? this.options.calcPoints,
197
- })
198
- }
199
-
200
- async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
201
- return this.rateLimiter.reset(ctx, {
202
- calcKey: opts?.calcKey ?? this.options.calcKey,
203
- })
204
- }
205
-
206
- static from<C = unknown>(
207
- rateLimiter: RateLimiterI<C>,
208
- { calcKey, calcPoints }: WrappedRateLimiterOptions<C> = {},
209
- ): RateLimiterI<C> {
210
- if (!calcKey && !calcPoints) return rateLimiter
211
- return new WrappedRateLimiter<C>(rateLimiter, { calcKey, calcPoints })
212
- }
213
- }
214
-
215
- /**
216
- * Combines multiple rate limiters into one.
217
- *
218
- * The combined rate limiter will return the tightest (most restrictive) of all
219
- * the provided rate limiters.
220
- */
221
- export class CombinedRateLimiter<C = unknown> implements RateLimiterI<C> {
222
- private constructor(
223
- private readonly rateLimiters: readonly RateLimiterI<C>[],
224
- ) {}
225
-
226
- async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
227
- const promises: ReturnType<RateLimiterConsume>[] = []
228
- for (const rl of this.rateLimiters) promises.push(rl.consume(ctx, opts))
229
- const results = await Promise.all(promises)
230
-
231
- // Compute the tightest rate limit status (the one with the least remaining points)
232
- let lowest: RateLimiterStatus | null = null
233
- for (const resp of results) {
234
- if (resp === null) continue
235
- if (lowest === null || resp.remainingPoints < lowest.remainingPoints) {
236
- lowest = resp
237
- }
238
- }
239
-
240
- return lowest
241
- }
242
-
243
- async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
244
- const promises: ReturnType<RateLimiterReset>[] = []
245
- for (const rl of this.rateLimiters) promises.push(rl.reset(ctx, opts))
246
- await Promise.all(promises)
247
- }
248
-
249
- static from<C = unknown>(
250
- rateLimiters: readonly RateLimiterI<C>[],
251
- ): RateLimiterI<C> | undefined {
252
- if (rateLimiters.length === 0) return undefined
253
- if (rateLimiters.length === 1) return rateLimiters[0]
254
- return new CombinedRateLimiter(rateLimiters)
255
- }
256
- }
257
-
258
- export class RateLimitExceededError extends XRPCError {
259
- constructor(
260
- public status: RateLimiterStatus,
261
- errorMessage?: string,
262
- customErrorName?: string,
263
- options?: ErrorOptions,
264
- ) {
265
- super(
266
- ResponseType.RateLimitExceeded,
267
- errorMessage,
268
- customErrorName,
269
- options,
270
- )
271
- }
272
-
273
- [Symbol.hasInstance](instance: unknown): boolean {
274
- return (
275
- instance instanceof XRPCError &&
276
- instance.type === ResponseType.RateLimitExceeded
277
- )
278
- }
279
- }