@atproto/xrpc-server 0.7.19 → 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 +18 -0
- 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 +67 -116
- 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/rate-limiter.ts +125 -51
- package/src/server.ts +94 -149
- package/src/types.ts +12 -2
- package/src/util.ts +15 -1
- package/tests/rate-limiter.test.ts +2 -5
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
|
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
1
2
|
import { Readable } from 'node:stream'
|
|
2
3
|
import { pipeline } from 'node:stream/promises'
|
|
3
4
|
import express, {
|
|
@@ -22,19 +23,17 @@ import {
|
|
|
22
23
|
lexToJson,
|
|
23
24
|
} from '@atproto/lexicon'
|
|
24
25
|
import log, { LOGGER_NAME } from './logger'
|
|
25
|
-
import {
|
|
26
|
+
import { RouteRateLimiter, WrappedRateLimiter } from './rate-limiter'
|
|
26
27
|
import { ErrorFrame, Frame, MessageFrame, XrpcStreamServer } from './stream'
|
|
27
28
|
import {
|
|
28
29
|
AuthVerifier,
|
|
29
30
|
HandlerAuth,
|
|
30
|
-
HandlerPipeThrough,
|
|
31
31
|
HandlerSuccess,
|
|
32
32
|
InternalServerError,
|
|
33
33
|
InvalidRequestError,
|
|
34
34
|
MethodNotImplementedError,
|
|
35
35
|
Options,
|
|
36
36
|
Params,
|
|
37
|
-
RateLimitExceededError,
|
|
38
37
|
RateLimiterI,
|
|
39
38
|
XRPCError,
|
|
40
39
|
XRPCHandler,
|
|
@@ -48,8 +47,10 @@ import {
|
|
|
48
47
|
isShared,
|
|
49
48
|
} from './types'
|
|
50
49
|
import {
|
|
50
|
+
asArray,
|
|
51
51
|
decodeQueryParams,
|
|
52
52
|
getQueryParams,
|
|
53
|
+
setHeaders,
|
|
53
54
|
validateInput,
|
|
54
55
|
validateOutput,
|
|
55
56
|
} from './util'
|
|
@@ -65,9 +66,8 @@ export class Server {
|
|
|
65
66
|
lex = new Lexicons()
|
|
66
67
|
options: Options
|
|
67
68
|
middleware: Record<'json' | 'text', RequestHandler>
|
|
68
|
-
|
|
69
|
-
sharedRateLimiters
|
|
70
|
-
routeRateLimiters: Record<string, RateLimiterI[]>
|
|
69
|
+
globalRateLimiter?: RouteRateLimiter
|
|
70
|
+
sharedRateLimiters?: Map<string, RateLimiterI>
|
|
71
71
|
|
|
72
72
|
constructor(lexicons?: LexiconDoc[], opts: Options = {}) {
|
|
73
73
|
if (lexicons) {
|
|
@@ -84,25 +84,26 @@ export class Server {
|
|
|
84
84
|
json: jsonParser({ limit: opts?.payload?.jsonLimit }),
|
|
85
85
|
text: textParser({ limit: opts?.payload?.textLimit }),
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
...limit
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
|
|
88
|
+
if (opts.rateLimits) {
|
|
89
|
+
const { global, shared, creator, bypass } = opts.rateLimits
|
|
90
|
+
|
|
91
|
+
if (global) {
|
|
92
|
+
this.globalRateLimiter = RouteRateLimiter.from(
|
|
93
|
+
global.map(({ name, ...limit }) =>
|
|
94
|
+
creator({ ...limit, keyPrefix: `rl-${name}` }),
|
|
95
|
+
),
|
|
96
|
+
{ bypass },
|
|
97
|
+
)
|
|
97
98
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
|
|
100
|
+
if (shared) {
|
|
101
|
+
this.sharedRateLimiters = new Map(
|
|
102
|
+
shared.map(({ name, ...limit }) => [
|
|
103
|
+
name,
|
|
104
|
+
creator({ ...limit, keyPrefix: `rl-${name}` }),
|
|
105
|
+
]),
|
|
106
|
+
)
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
}
|
|
@@ -177,7 +178,6 @@ export class Server {
|
|
|
177
178
|
middleware.push(this.middleware.json)
|
|
178
179
|
middleware.push(this.middleware.text)
|
|
179
180
|
}
|
|
180
|
-
this.setupRouteRateLimits(nsid, config)
|
|
181
181
|
this.routes[verb](
|
|
182
182
|
`/xrpc/${nsid}`,
|
|
183
183
|
...middleware,
|
|
@@ -186,52 +186,43 @@ export class Server {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
async catchall(req: Request, res: Response, next: NextFunction) {
|
|
189
|
-
if (this.
|
|
189
|
+
if (this.globalRateLimiter) {
|
|
190
190
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
},
|
|
200
|
-
this.globalRateLimiters.map(
|
|
201
|
-
(rl) => (ctx: XRPCReqContext) => rl.consume(ctx),
|
|
202
|
-
),
|
|
203
|
-
)
|
|
204
|
-
if (rlRes instanceof RateLimitExceededError) {
|
|
205
|
-
return next(rlRes)
|
|
206
|
-
}
|
|
191
|
+
await this.globalRateLimiter.handle({
|
|
192
|
+
req,
|
|
193
|
+
res,
|
|
194
|
+
auth: undefined,
|
|
195
|
+
params: {},
|
|
196
|
+
input: undefined,
|
|
197
|
+
async resetRouteRateLimits() {},
|
|
198
|
+
})
|
|
207
199
|
} catch (err) {
|
|
208
200
|
return next(err)
|
|
209
201
|
}
|
|
210
202
|
}
|
|
211
203
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
204
|
+
// Ensure that known XRPC methods are only called with the correct HTTP
|
|
205
|
+
// method.
|
|
216
206
|
const def = this.lex.getDef(req.params.methodId)
|
|
217
|
-
if (
|
|
218
|
-
|
|
207
|
+
if (def) {
|
|
208
|
+
const expectedMethod =
|
|
209
|
+
def.type === 'procedure' ? 'POST' : def.type === 'query' ? 'GET' : null
|
|
210
|
+
if (expectedMethod != null && expectedMethod !== req.method) {
|
|
211
|
+
return next(
|
|
212
|
+
new InvalidRequestError(
|
|
213
|
+
`Incorrect HTTP method (${req.method}) expected ${expectedMethod}`,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
219
217
|
}
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
} else if (def.type === 'procedure' && req.method !== 'POST') {
|
|
228
|
-
return next(
|
|
229
|
-
new InvalidRequestError(
|
|
230
|
-
`Incorrect HTTP method (${req.method}) expected POST`,
|
|
231
|
-
),
|
|
232
|
-
)
|
|
218
|
+
|
|
219
|
+
if (this.options.catchall) {
|
|
220
|
+
this.options.catchall.call(null, req, res, next)
|
|
221
|
+
} else if (!def) {
|
|
222
|
+
next(new MethodNotImplementedError())
|
|
223
|
+
} else {
|
|
224
|
+
next()
|
|
233
225
|
}
|
|
234
|
-
return next()
|
|
235
226
|
}
|
|
236
227
|
|
|
237
228
|
createHandler(
|
|
@@ -251,18 +242,8 @@ export class Server {
|
|
|
251
242
|
validateOutput(nsid, def, output, this.lex)
|
|
252
243
|
const assertValidXrpcParams = (params: unknown) =>
|
|
253
244
|
this.lex.assertValidXrpcParams(nsid, params)
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
consumeMany(
|
|
257
|
-
reqCtx,
|
|
258
|
-
rls.map((rl) => (ctx: XRPCReqContext) => rl.consume(ctx)),
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
const resetRateLimit = (reqCtx: XRPCReqContext) =>
|
|
262
|
-
resetMany(
|
|
263
|
-
reqCtx,
|
|
264
|
-
rls.map((rl) => (ctx: XRPCReqContext) => rl.reset(ctx)),
|
|
265
|
-
)
|
|
245
|
+
|
|
246
|
+
const routeLimiter = this.createRouteRateLimiter(nsid, routeCfg)
|
|
266
247
|
|
|
267
248
|
return async function (req, res, next) {
|
|
268
249
|
try {
|
|
@@ -283,14 +264,11 @@ export class Server {
|
|
|
283
264
|
auth: locals.auth,
|
|
284
265
|
req,
|
|
285
266
|
res,
|
|
286
|
-
resetRouteRateLimits: async () =>
|
|
267
|
+
resetRouteRateLimits: async () => routeLimiter?.reset(reqCtx),
|
|
287
268
|
}
|
|
288
269
|
|
|
289
270
|
// handle rate limits
|
|
290
|
-
|
|
291
|
-
if (result instanceof RateLimitExceededError) {
|
|
292
|
-
return next(result)
|
|
293
|
-
}
|
|
271
|
+
if (routeLimiter) await routeLimiter.handle(reqCtx)
|
|
294
272
|
|
|
295
273
|
// run the handler
|
|
296
274
|
const output = await routeCfg.handler(reqCtx)
|
|
@@ -300,12 +278,12 @@ export class Server {
|
|
|
300
278
|
res.status(200)
|
|
301
279
|
res.end()
|
|
302
280
|
} else if (isHandlerPipeThroughStream(output)) {
|
|
303
|
-
setHeaders(res, output)
|
|
281
|
+
setHeaders(res, output.headers)
|
|
304
282
|
res.status(200)
|
|
305
283
|
res.header('Content-Type', output.encoding)
|
|
306
284
|
await pipeline(output.stream, res)
|
|
307
285
|
} else if (isHandlerPipeThroughBuffer(output)) {
|
|
308
|
-
setHeaders(res, output)
|
|
286
|
+
setHeaders(res, output.headers)
|
|
309
287
|
res.status(200)
|
|
310
288
|
res.header('Content-Type', output.encoding)
|
|
311
289
|
res.end(output.buffer)
|
|
@@ -315,7 +293,7 @@ export class Server {
|
|
|
315
293
|
validateResOutput?.(output)
|
|
316
294
|
|
|
317
295
|
res.status(200)
|
|
318
|
-
setHeaders(res, output)
|
|
296
|
+
setHeaders(res, output.headers)
|
|
319
297
|
|
|
320
298
|
if (
|
|
321
299
|
output.encoding === 'application/json' ||
|
|
@@ -432,76 +410,43 @@ export class Server {
|
|
|
432
410
|
}
|
|
433
411
|
}
|
|
434
412
|
|
|
435
|
-
private
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
})
|
|
442
|
-
}
|
|
413
|
+
private createRouteRateLimiter(
|
|
414
|
+
nsid: string,
|
|
415
|
+
config: XRPCHandlerConfig,
|
|
416
|
+
): RouteRateLimiter | undefined {
|
|
417
|
+
// No route specific rate limiting configured, use the global rate limiter.
|
|
418
|
+
if (!config.rateLimit) return this.globalRateLimiter
|
|
443
419
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
calcKey,
|
|
464
|
-
}),
|
|
465
|
-
})
|
|
466
|
-
}
|
|
467
|
-
} else {
|
|
468
|
-
const { durationMs, points } = limit
|
|
469
|
-
const rateLimiter = this.options.rateLimits?.creator({
|
|
470
|
-
keyPrefix: `nsid-${i}`,
|
|
471
|
-
durationMs,
|
|
472
|
-
points,
|
|
473
|
-
calcKey,
|
|
474
|
-
calcPoints,
|
|
475
|
-
})
|
|
476
|
-
if (rateLimiter) {
|
|
477
|
-
this.sharedRateLimiters[nsid] = rateLimiter
|
|
478
|
-
this.routeRateLimiters[nsid].push({
|
|
479
|
-
consume: (ctx: XRPCReqContext) =>
|
|
480
|
-
rateLimiter.consume(ctx, {
|
|
481
|
-
calcKey,
|
|
482
|
-
calcPoints,
|
|
483
|
-
}),
|
|
484
|
-
reset: (ctx: XRPCReqContext) =>
|
|
485
|
-
rateLimiter.reset(ctx, {
|
|
486
|
-
calcKey,
|
|
487
|
-
}),
|
|
488
|
-
})
|
|
489
|
-
}
|
|
490
|
-
}
|
|
420
|
+
const { rateLimits } = this.options
|
|
421
|
+
|
|
422
|
+
// @NOTE Silently ignore creation of route specific rate limiter if the
|
|
423
|
+
// `rateLimits` options was not provided to the constructor.
|
|
424
|
+
if (!rateLimits) return this.globalRateLimiter
|
|
425
|
+
|
|
426
|
+
const { creator, bypass } = rateLimits
|
|
427
|
+
|
|
428
|
+
const rateLimiters = asArray(config.rateLimit).map((options, i) => {
|
|
429
|
+
if (isShared(options)) {
|
|
430
|
+
const rateLimiter = this.sharedRateLimiters?.get(options.name)
|
|
431
|
+
|
|
432
|
+
// The route config references a shared rate limiter that does not
|
|
433
|
+
// exist. This is a configuration error.
|
|
434
|
+
assert(rateLimiter, `Shared rate limiter "${options.name}" not defined`)
|
|
435
|
+
|
|
436
|
+
return WrappedRateLimiter.from(rateLimiter, options)
|
|
437
|
+
} else {
|
|
438
|
+
return creator({ ...options, keyPrefix: `${nsid}-${i}` })
|
|
491
439
|
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
440
|
+
})
|
|
495
441
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
442
|
+
// If the route config contains an empty array, use global rate limiter.
|
|
443
|
+
if (!rateLimiters.length) return this.globalRateLimiter
|
|
444
|
+
|
|
445
|
+
// The global rate limiter (if present) should be applied in addition to
|
|
446
|
+
// the route specific rate limiters.
|
|
447
|
+
if (this.globalRateLimiter) rateLimiters.push(this.globalRateLimiter)
|
|
448
|
+
|
|
449
|
+
return RouteRateLimiter.from(rateLimiters, { bypass })
|
|
505
450
|
}
|
|
506
451
|
}
|
|
507
452
|
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type Options = {
|
|
|
29
29
|
creator: RateLimiterCreator
|
|
30
30
|
global?: ServerRateLimitDescription[]
|
|
31
31
|
shared?: ServerRateLimitDescription[]
|
|
32
|
+
bypass?: (ctx: XRPCReqContext) => boolean
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* By default, errors are converted to {@link XRPCError} using
|
|
@@ -148,14 +149,23 @@ export interface RateLimiterI {
|
|
|
148
149
|
reset: RateLimiterReset
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
export type RateLimiterConsumeOptions = {
|
|
153
|
+
calcKey?: CalcKeyFn
|
|
154
|
+
calcPoints?: CalcPointsFn
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
export type RateLimiterConsume = (
|
|
152
158
|
ctx: XRPCReqContext,
|
|
153
|
-
opts?:
|
|
159
|
+
opts?: RateLimiterConsumeOptions,
|
|
154
160
|
) => Promise<RateLimiterStatus | RateLimitExceededError | null>
|
|
155
161
|
|
|
162
|
+
export type RateLimiterResetOptions = {
|
|
163
|
+
calcKey?: CalcKeyFn
|
|
164
|
+
}
|
|
165
|
+
|
|
156
166
|
export type RateLimiterReset = (
|
|
157
167
|
ctx: XRPCReqContext,
|
|
158
|
-
opts?:
|
|
168
|
+
opts?: RateLimiterResetOptions,
|
|
159
169
|
) => Promise<void>
|
|
160
170
|
|
|
161
171
|
export type RateLimiterCreator = (opts: {
|
package/src/util.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
|
-
import { IncomingMessage } from 'node:http'
|
|
2
|
+
import { IncomingMessage, OutgoingMessage } from 'node:http'
|
|
3
3
|
import { Duplex, Readable, pipeline } from 'node:stream'
|
|
4
4
|
import express from 'express'
|
|
5
5
|
import mime from 'mime-types'
|
|
@@ -24,6 +24,20 @@ import {
|
|
|
24
24
|
handlerSuccess,
|
|
25
25
|
} from './types'
|
|
26
26
|
|
|
27
|
+
export const asArray = <T>(arr: T | T[]): T[] =>
|
|
28
|
+
Array.isArray(arr) ? arr : [arr]
|
|
29
|
+
|
|
30
|
+
export function setHeaders(
|
|
31
|
+
res: OutgoingMessage,
|
|
32
|
+
headers?: Record<string, string | number>,
|
|
33
|
+
) {
|
|
34
|
+
if (headers) {
|
|
35
|
+
for (const [name, val] of Object.entries(headers)) {
|
|
36
|
+
if (val != null) res.setHeader(name, val)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
export function decodeQueryParams(
|
|
28
42
|
def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription,
|
|
29
43
|
params: UndecodedParams,
|
|
@@ -132,11 +132,8 @@ describe('Parameters', () => {
|
|
|
132
132
|
let s: http.Server
|
|
133
133
|
const server = xrpcServer.createServer(LEXICONS, {
|
|
134
134
|
rateLimits: {
|
|
135
|
-
creator: (opts
|
|
136
|
-
|
|
137
|
-
bypassSecret: 'bypass',
|
|
138
|
-
...opts,
|
|
139
|
-
}),
|
|
135
|
+
creator: (opts) => RateLimiter.memory(opts),
|
|
136
|
+
bypass: ({ req }) => req.headers['x-ratelimit-bypass'] === 'bypass',
|
|
140
137
|
shared: [
|
|
141
138
|
{
|
|
142
139
|
name: 'shared-limit',
|