@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/CHANGELOG.md +24 -0
- package/LICENSE.txt +1 -1
- package/dist/logger.d.ts +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +3 -2
- package/dist/logger.js.map +1 -1
- package/dist/rate-limiter.d.ts +48 -16
- package/dist/rate-limiter.d.ts.map +1 -1
- package/dist/rate-limiter.js +130 -53
- package/dist/rate-limiter.js.map +1 -1
- package/dist/server.d.ts +5 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +106 -129
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +7 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +3 -1
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +12 -1
- package/dist/util.js.map +1 -1
- package/package.json +1 -1
- package/src/logger.ts +3 -1
- package/src/rate-limiter.ts +125 -51
- package/src/server.ts +154 -164
- package/src/types.ts +12 -2
- package/src/util.ts +15 -1
- package/tests/rate-limiter.test.ts +2 -5
- package/tsconfig.tests.tsbuildinfo +1 -1
package/package.json
CHANGED
package/src/logger.ts
CHANGED
package/src/rate-limiter.ts
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
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?:
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
181
|
-
|
|
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
|
-
|
|
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
|