@atproto/xrpc-server 0.7.19 → 0.9.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 +42 -0
- package/dist/auth.js +11 -11
- package/dist/auth.js.map +1 -1
- package/dist/errors.d.ts +67 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +202 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/rate-limiter.d.ts +95 -26
- package/dist/rate-limiter.d.ts.map +1 -1
- package/dist/rate-limiter.js +179 -85
- package/dist/rate-limiter.js.map +1 -1
- package/dist/server.d.ts +20 -15
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +185 -220
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +80 -175
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -226
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +12 -9
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +114 -78
- package/dist/util.js.map +1 -1
- package/package.json +4 -3
- package/src/auth.ts +1 -1
- package/src/errors.ts +293 -0
- package/src/index.ts +4 -3
- package/src/rate-limiter.ts +270 -104
- package/src/server.ts +265 -276
- package/src/types.ts +144 -429
- package/src/util.ts +131 -85
- package/tests/auth.test.ts +2 -2
- package/tests/bodies.test.ts +18 -27
- package/tests/errors.test.ts +1 -1
- package/tests/ipld.test.ts +15 -14
- package/tests/parameters.test.ts +4 -7
- package/tests/procedures.test.ts +22 -34
- package/tests/queries.test.ts +9 -12
- package/tests/rate-limiter.test.ts +8 -11
- package/tests/responses.test.ts +12 -15
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/rate-limiter.ts
CHANGED
|
@@ -1,87 +1,107 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'node:http'
|
|
1
2
|
import {
|
|
2
3
|
RateLimiterAbstract,
|
|
3
4
|
RateLimiterMemory,
|
|
4
5
|
RateLimiterRedis,
|
|
5
6
|
RateLimiterRes,
|
|
6
7
|
} from 'rate-limiter-flexible'
|
|
8
|
+
import { ResponseType, XRPCError } from './errors'
|
|
7
9
|
import { logger } from './logger'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
|
|
11
|
+
// @NOTE Do not depend (directly or indirectly) on "./types" here, as it would
|
|
12
|
+
// create a circular dependency.
|
|
13
|
+
|
|
14
|
+
export interface RateLimiterContext {
|
|
15
|
+
req: IncomingMessage
|
|
16
|
+
res?: ServerResponse
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CalcKeyFn<C extends RateLimiterContext = RateLimiterContext> = (
|
|
20
|
+
ctx: C,
|
|
21
|
+
) => string | null
|
|
22
|
+
export type CalcPointsFn<C extends RateLimiterContext = RateLimiterContext> = (
|
|
23
|
+
ctx: C,
|
|
24
|
+
) => number
|
|
25
|
+
|
|
26
|
+
export interface RateLimiterI<
|
|
27
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
28
|
+
> {
|
|
29
|
+
consume: RateLimiterConsume<C>
|
|
30
|
+
reset: RateLimiterReset<C>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RateLimiterConsumeOptions<
|
|
34
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
35
|
+
> = {
|
|
36
|
+
calcKey?: CalcKeyFn<C>
|
|
37
|
+
calcPoints?: CalcPointsFn<C>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type RateLimiterConsume<
|
|
41
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
42
|
+
> = (
|
|
43
|
+
ctx: C,
|
|
44
|
+
opts?: RateLimiterConsumeOptions<C>,
|
|
45
|
+
) => Promise<RateLimiterStatus | RateLimitExceededError | null>
|
|
46
|
+
|
|
47
|
+
export type RateLimiterStatus = {
|
|
48
|
+
limit: number
|
|
49
|
+
duration: number
|
|
50
|
+
remainingPoints: number
|
|
51
|
+
msBeforeNext: number
|
|
52
|
+
consumedPoints: number
|
|
53
|
+
isFirstInDuration: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type RateLimiterResetOptions<
|
|
57
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
58
|
+
> = {
|
|
59
|
+
calcKey?: CalcKeyFn<C>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RateLimiterReset<
|
|
63
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
64
|
+
> = (ctx: C, opts?: RateLimiterResetOptions<C>) => Promise<void>
|
|
65
|
+
|
|
66
|
+
export type RateLimiterOptions<
|
|
67
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
68
|
+
> = {
|
|
20
69
|
keyPrefix: string
|
|
21
70
|
durationMs: number
|
|
22
71
|
points: number
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
calcKey?: CalcKeyFn
|
|
26
|
-
calcPoints?: CalcPointsFn
|
|
72
|
+
calcKey: CalcKeyFn<C>
|
|
73
|
+
calcPoints: CalcPointsFn<C>
|
|
27
74
|
failClosed?: boolean
|
|
28
75
|
}
|
|
29
76
|
|
|
30
|
-
export class RateLimiter
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
public calcPoints: CalcPointsFn
|
|
77
|
+
export class RateLimiter<C extends RateLimiterContext = RateLimiterContext>
|
|
78
|
+
implements RateLimiterI<C>
|
|
79
|
+
{
|
|
80
|
+
private readonly failClosed?: boolean
|
|
81
|
+
private readonly calcKey: CalcKeyFn<C>
|
|
82
|
+
private readonly calcPoints: CalcPointsFn<C>
|
|
37
83
|
|
|
38
|
-
constructor(
|
|
84
|
+
constructor(
|
|
85
|
+
public limiter: RateLimiterAbstract,
|
|
86
|
+
options: RateLimiterOptions<C>,
|
|
87
|
+
) {
|
|
39
88
|
this.limiter = limiter
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
-
this.calcPoints = opts.calcPoints ?? defaultPoints
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
static memory(opts: RateLimiterOpts): RateLimiter {
|
|
47
|
-
const limiter = new RateLimiterMemory({
|
|
48
|
-
keyPrefix: opts.keyPrefix,
|
|
49
|
-
duration: Math.floor(opts.durationMs / 1000),
|
|
50
|
-
points: opts.points,
|
|
51
|
-
})
|
|
52
|
-
return new RateLimiter(limiter, opts)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
static redis(storeClient: unknown, opts: RateLimiterOpts): RateLimiter {
|
|
56
|
-
const limiter = new RateLimiterRedis({
|
|
57
|
-
storeClient,
|
|
58
|
-
keyPrefix: opts.keyPrefix,
|
|
59
|
-
duration: Math.floor(opts.durationMs / 1000),
|
|
60
|
-
points: opts.points,
|
|
61
|
-
})
|
|
62
|
-
return new RateLimiter(limiter, opts)
|
|
89
|
+
this.failClosed = options.failClosed ?? false
|
|
90
|
+
this.calcKey = options.calcKey
|
|
91
|
+
this.calcPoints = options.calcPoints
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
async consume(
|
|
66
|
-
ctx:
|
|
67
|
-
opts?:
|
|
95
|
+
ctx: C,
|
|
96
|
+
opts?: RateLimiterConsumeOptions<C>,
|
|
68
97
|
): Promise<RateLimiterStatus | RateLimitExceededError | null> {
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
|
|
98
|
+
const calcKey = opts?.calcKey ?? this.calcKey
|
|
99
|
+
const key = calcKey(ctx)
|
|
79
100
|
if (key === null) {
|
|
80
101
|
return null
|
|
81
102
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
: this.calcPoints(ctx)
|
|
103
|
+
const calcPoints = opts?.calcPoints ?? this.calcPoints
|
|
104
|
+
const points = calcPoints(ctx)
|
|
85
105
|
if (points < 1) {
|
|
86
106
|
return null
|
|
87
107
|
}
|
|
@@ -111,10 +131,7 @@ export class RateLimiter implements RateLimiterI {
|
|
|
111
131
|
}
|
|
112
132
|
}
|
|
113
133
|
|
|
114
|
-
async reset(
|
|
115
|
-
ctx: XRPCReqContext,
|
|
116
|
-
opts?: { calcKey?: CalcKeyFn },
|
|
117
|
-
): Promise<void> {
|
|
134
|
+
async reset(ctx: C, opts?: RateLimiterResetOptions<C>): Promise<void> {
|
|
118
135
|
const key = opts?.calcKey ? opts.calcKey(ctx) : this.calcKey(ctx)
|
|
119
136
|
if (key === null) {
|
|
120
137
|
return
|
|
@@ -128,6 +145,33 @@ export class RateLimiter implements RateLimiterI {
|
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
export class MemoryRateLimiter<
|
|
149
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
150
|
+
> extends RateLimiter<C> {
|
|
151
|
+
constructor(options: RateLimiterOptions<C>) {
|
|
152
|
+
const limiter = new RateLimiterMemory({
|
|
153
|
+
keyPrefix: options.keyPrefix,
|
|
154
|
+
duration: Math.floor(options.durationMs / 1000),
|
|
155
|
+
points: options.points,
|
|
156
|
+
})
|
|
157
|
+
super(limiter, options)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class RedisRateLimiter<
|
|
162
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
163
|
+
> extends RateLimiter<C> {
|
|
164
|
+
constructor(storeClient: unknown, options: RateLimiterOptions<C>) {
|
|
165
|
+
const limiter = new RateLimiterRedis({
|
|
166
|
+
storeClient,
|
|
167
|
+
keyPrefix: options.keyPrefix,
|
|
168
|
+
duration: Math.floor(options.durationMs / 1000),
|
|
169
|
+
points: options.points,
|
|
170
|
+
})
|
|
171
|
+
super(limiter, options)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
export const formatLimiterStatus = (
|
|
132
176
|
limiter: RateLimiterAbstract,
|
|
133
177
|
res: RateLimiterRes,
|
|
@@ -142,46 +186,84 @@ export const formatLimiterStatus = (
|
|
|
142
186
|
}
|
|
143
187
|
}
|
|
144
188
|
|
|
145
|
-
export
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
}
|
|
189
|
+
export type WrappedRateLimiterOptions<
|
|
190
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
191
|
+
> = {
|
|
192
|
+
calcKey?: CalcKeyFn<C>
|
|
193
|
+
calcPoints?: CalcPointsFn<C>
|
|
161
194
|
}
|
|
162
195
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Wraps a {@link RateLimiterI} instance with custom key and points calculation
|
|
198
|
+
* functions.
|
|
199
|
+
*/
|
|
200
|
+
export class WrappedRateLimiter<
|
|
201
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
202
|
+
> implements RateLimiterI<C>
|
|
203
|
+
{
|
|
204
|
+
private constructor(
|
|
205
|
+
private readonly rateLimiter: RateLimiterI<C>,
|
|
206
|
+
private readonly options: Readonly<WrappedRateLimiterOptions<C>>,
|
|
207
|
+
) {}
|
|
208
|
+
|
|
209
|
+
async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
|
|
210
|
+
return this.rateLimiter.consume(ctx, {
|
|
211
|
+
calcKey: opts?.calcKey ?? this.options.calcKey,
|
|
212
|
+
calcPoints: opts?.calcPoints ?? this.options.calcPoints,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
|
|
217
|
+
return this.rateLimiter.reset(ctx, {
|
|
218
|
+
calcKey: opts?.calcKey ?? this.options.calcKey,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static from<C extends RateLimiterContext = RateLimiterContext>(
|
|
223
|
+
rateLimiter: RateLimiterI<C>,
|
|
224
|
+
{ calcKey, calcPoints }: WrappedRateLimiterOptions<C> = {},
|
|
225
|
+
): RateLimiterI<C> {
|
|
226
|
+
if (!calcKey && !calcPoints) return rateLimiter
|
|
227
|
+
return new WrappedRateLimiter<C>(rateLimiter, { calcKey, calcPoints })
|
|
228
|
+
}
|
|
169
229
|
}
|
|
170
230
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Combines multiple rate limiters into one.
|
|
233
|
+
*
|
|
234
|
+
* The combined rate limiter will return the tightest (most restrictive) of all
|
|
235
|
+
* the provided rate limiters.
|
|
236
|
+
*/
|
|
237
|
+
export class CombinedRateLimiter<
|
|
238
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
239
|
+
> implements RateLimiterI<C>
|
|
240
|
+
{
|
|
241
|
+
private constructor(
|
|
242
|
+
private readonly rateLimiters: readonly RateLimiterI<C>[],
|
|
243
|
+
) {}
|
|
244
|
+
|
|
245
|
+
async consume(ctx: C, opts?: RateLimiterConsumeOptions<C>) {
|
|
246
|
+
const promises: ReturnType<RateLimiterConsume>[] = []
|
|
247
|
+
for (const rl of this.rateLimiters) promises.push(rl.consume(ctx, opts))
|
|
248
|
+
return Promise.all(promises).then(getTightestLimit)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async reset(ctx: C, opts?: RateLimiterResetOptions<C>) {
|
|
252
|
+
const promises: ReturnType<RateLimiterReset>[] = []
|
|
253
|
+
for (const rl of this.rateLimiters) promises.push(rl.reset(ctx, opts))
|
|
254
|
+
await Promise.all(promises)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static from<C extends RateLimiterContext = RateLimiterContext>(
|
|
258
|
+
rateLimiters: readonly RateLimiterI<C>[],
|
|
259
|
+
): RateLimiterI<C> | undefined {
|
|
260
|
+
if (rateLimiters.length === 0) return undefined
|
|
261
|
+
if (rateLimiters.length === 1) return rateLimiters[0]
|
|
262
|
+
return new CombinedRateLimiter(rateLimiters)
|
|
263
|
+
}
|
|
182
264
|
}
|
|
183
265
|
|
|
184
|
-
|
|
266
|
+
const getTightestLimit = (
|
|
185
267
|
resps: (RateLimiterStatus | RateLimitExceededError | null)[],
|
|
186
268
|
): RateLimiterStatus | RateLimitExceededError | null => {
|
|
187
269
|
let lowest: RateLimiterStatus | null = null
|
|
@@ -195,7 +277,91 @@ export const getTightestLimit = (
|
|
|
195
277
|
return lowest
|
|
196
278
|
}
|
|
197
279
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
280
|
+
export type RouteRateLimiterOptions<
|
|
281
|
+
C extends RateLimiterContext = RateLimiterContext,
|
|
282
|
+
> = {
|
|
283
|
+
bypass?: (ctx: C) => boolean
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Wraps a {@link RateLimiterI} interface into a class that will apply the
|
|
288
|
+
* appropriate headers to the response if a limit is exceeded.
|
|
289
|
+
*/
|
|
290
|
+
export class RouteRateLimiter<C extends RateLimiterContext = RateLimiterContext>
|
|
291
|
+
implements RateLimiterI<C>
|
|
292
|
+
{
|
|
293
|
+
constructor(
|
|
294
|
+
private readonly rateLimiter: RateLimiterI<C>,
|
|
295
|
+
private readonly options: Readonly<RouteRateLimiterOptions<C>> = {},
|
|
296
|
+
) {}
|
|
297
|
+
|
|
298
|
+
async handle(ctx: C): Promise<RateLimiterStatus | null> {
|
|
299
|
+
const { bypass } = this.options
|
|
300
|
+
if (bypass && bypass(ctx)) {
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const result = await this.consume(ctx)
|
|
305
|
+
if (result instanceof RateLimitExceededError) {
|
|
306
|
+
setStatusHeaders(ctx, result.status)
|
|
307
|
+
throw result
|
|
308
|
+
} else if (result != null) {
|
|
309
|
+
setStatusHeaders(ctx, result)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async consume(...args: Parameters<RateLimiterConsume<C>>) {
|
|
316
|
+
return this.rateLimiter.consume(...args)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async reset(...args: Parameters<RateLimiterReset<C>>) {
|
|
320
|
+
return this.rateLimiter.reset(...args)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
static from<C extends RateLimiterContext = RateLimiterContext>(
|
|
324
|
+
rateLimiters: readonly RateLimiterI<C>[],
|
|
325
|
+
{ bypass }: RouteRateLimiterOptions<C> = {},
|
|
326
|
+
): RouteRateLimiter<C> | undefined {
|
|
327
|
+
const rateLimiter = CombinedRateLimiter.from(rateLimiters)
|
|
328
|
+
if (!rateLimiter) return undefined
|
|
329
|
+
|
|
330
|
+
return new RouteRateLimiter(rateLimiter, { bypass })
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function setStatusHeaders<C extends RateLimiterContext = RateLimiterContext>(
|
|
335
|
+
ctx: C,
|
|
336
|
+
status: RateLimiterStatus,
|
|
337
|
+
) {
|
|
338
|
+
const resetAt = Math.floor((Date.now() + status.msBeforeNext) / 1e3)
|
|
339
|
+
|
|
340
|
+
ctx.res?.setHeader('RateLimit-Limit', status.limit)
|
|
341
|
+
ctx.res?.setHeader('RateLimit-Reset', resetAt)
|
|
342
|
+
ctx.res?.setHeader('RateLimit-Remaining', status.remainingPoints)
|
|
343
|
+
ctx.res?.setHeader('RateLimit-Policy', `${status.limit};w=${status.duration}`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export class RateLimitExceededError extends XRPCError {
|
|
347
|
+
constructor(
|
|
348
|
+
public status: RateLimiterStatus,
|
|
349
|
+
errorMessage?: string,
|
|
350
|
+
customErrorName?: string,
|
|
351
|
+
options?: ErrorOptions,
|
|
352
|
+
) {
|
|
353
|
+
super(
|
|
354
|
+
ResponseType.RateLimitExceeded,
|
|
355
|
+
errorMessage,
|
|
356
|
+
customErrorName,
|
|
357
|
+
options,
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
[Symbol.hasInstance](instance: unknown): boolean {
|
|
362
|
+
return (
|
|
363
|
+
instance instanceof XRPCError &&
|
|
364
|
+
instance.type === ResponseType.RateLimitExceeded
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|