@atproto/xrpc-server 0.2.0 → 0.3.1
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 +8 -0
- package/LICENSE +21 -0
- package/README.md +11 -4
- package/build.js +0 -8
- package/dist/auth.d.ts +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +17210 -8937
- package/dist/index.js.map +4 -4
- package/dist/logger.d.ts +2 -1
- package/dist/rate-limiter.d.ts +29 -0
- package/dist/server.d.ts +5 -1
- package/dist/stream/logger.d.ts +2 -1
- package/dist/stream/stream.d.ts +5 -2
- package/dist/stream/subscription.d.ts +2 -1
- package/dist/stream/types.d.ts +6 -6
- package/dist/stream/websocket-keepalive.d.ts +23 -0
- package/dist/types.d.ts +67 -9
- package/dist/util.d.ts +15 -0
- package/package.json +19 -25
- package/src/auth.ts +2 -2
- package/src/index.ts +4 -0
- package/src/logger.ts +2 -1
- package/src/rate-limiter.ts +167 -0
- package/src/server.ts +117 -7
- package/src/stream/logger.ts +2 -1
- package/src/stream/stream.ts +24 -11
- package/src/stream/subscription.ts +21 -107
- package/src/stream/websocket-keepalive.ts +151 -0
- package/src/types.ts +83 -4
- package/src/util.ts +33 -0
- package/tests/bodies.test.ts +3 -3
- package/tests/procedures.test.ts +12 -12
- package/tests/queries.test.ts +19 -14
- package/tests/rate-limiter.test.ts +249 -0
- package/tests/responses.test.ts +77 -0
- package/tests/subscriptions.test.ts +71 -15
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +3 -3
- package/dist/src/index.d.ts +0 -2
- package/dist/src/logger.d.ts +0 -2
- package/dist/src/server.d.ts +0 -19
- package/dist/src/types.d.ts +0 -115
- package/dist/src/util.d.ts +0 -10
- package/dist/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.build.tsbuildinfo +0 -1
- package/update-pkg.js +0 -14
package/src/types.ts
CHANGED
|
@@ -15,6 +15,11 @@ export type Options = {
|
|
|
15
15
|
blobLimit?: number
|
|
16
16
|
textLimit?: number
|
|
17
17
|
}
|
|
18
|
+
rateLimits?: {
|
|
19
|
+
creator: RateLimiterCreator
|
|
20
|
+
global?: ServerRateLimitDescription[]
|
|
21
|
+
shared?: ServerRateLimitDescription[]
|
|
22
|
+
}
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export type UndecodedParams = typeof express.request['query']
|
|
@@ -37,6 +42,7 @@ export type HandlerAuth = zod.infer<typeof handlerAuth>
|
|
|
37
42
|
export const handlerSuccess = zod.object({
|
|
38
43
|
encoding: zod.string(),
|
|
39
44
|
body: zod.any(),
|
|
45
|
+
headers: zod.record(zod.string()).optional(),
|
|
40
46
|
})
|
|
41
47
|
export type HandlerSuccess = zod.infer<typeof handlerSuccess>
|
|
42
48
|
|
|
@@ -49,13 +55,17 @@ export type HandlerError = zod.infer<typeof handlerError>
|
|
|
49
55
|
|
|
50
56
|
export type HandlerOutput = HandlerSuccess | HandlerError
|
|
51
57
|
|
|
52
|
-
export type
|
|
58
|
+
export type XRPCReqContext = {
|
|
53
59
|
auth: HandlerAuth | undefined
|
|
54
60
|
params: Params
|
|
55
61
|
input: HandlerInput | undefined
|
|
56
62
|
req: express.Request
|
|
57
63
|
res: express.Response
|
|
58
|
-
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type XRPCHandler = (
|
|
67
|
+
ctx: XRPCReqContext,
|
|
68
|
+
) => Promise<HandlerOutput> | HandlerOutput | undefined
|
|
59
69
|
|
|
60
70
|
export type XRPCStreamHandler = (ctx: {
|
|
61
71
|
auth: HandlerAuth | undefined
|
|
@@ -75,7 +85,66 @@ export type StreamAuthVerifier = (ctx: {
|
|
|
75
85
|
req: IncomingMessage
|
|
76
86
|
}) => Promise<AuthOutput> | AuthOutput
|
|
77
87
|
|
|
88
|
+
export type CalcKeyFn = (ctx: XRPCReqContext) => string
|
|
89
|
+
export type CalcPointsFn = (ctx: XRPCReqContext) => number
|
|
90
|
+
|
|
91
|
+
export interface RateLimiterI {
|
|
92
|
+
consume: RateLimiterConsume
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type RateLimiterConsume = (
|
|
96
|
+
ctx: XRPCReqContext,
|
|
97
|
+
opts?: { calcKey?: CalcKeyFn; calcPoints?: CalcPointsFn },
|
|
98
|
+
) => Promise<RateLimiterStatus | RateLimitExceededError | null>
|
|
99
|
+
|
|
100
|
+
export type RateLimiterCreator = (opts: {
|
|
101
|
+
keyPrefix: string
|
|
102
|
+
durationMs: number
|
|
103
|
+
points: number
|
|
104
|
+
calcKey?: (ctx: XRPCReqContext) => string
|
|
105
|
+
calcPoints?: (ctx: XRPCReqContext) => number
|
|
106
|
+
}) => RateLimiterI
|
|
107
|
+
|
|
108
|
+
export type ServerRateLimitDescription = {
|
|
109
|
+
name: string
|
|
110
|
+
durationMs: number
|
|
111
|
+
points: number
|
|
112
|
+
calcKey?: (ctx: XRPCReqContext) => string
|
|
113
|
+
calcPoints?: (ctx: XRPCReqContext) => number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type SharedRateLimitOpts = {
|
|
117
|
+
name: string
|
|
118
|
+
calcKey?: (ctx: XRPCReqContext) => string
|
|
119
|
+
calcPoints?: (ctx: XRPCReqContext) => number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type RouteRateLimitOpts = {
|
|
123
|
+
durationMs: number
|
|
124
|
+
points: number
|
|
125
|
+
calcKey?: (ctx: XRPCReqContext) => string
|
|
126
|
+
calcPoints?: (ctx: XRPCReqContext) => number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts
|
|
130
|
+
|
|
131
|
+
export const isShared = (
|
|
132
|
+
opts: HandlerRateLimitOpts,
|
|
133
|
+
): opts is SharedRateLimitOpts => {
|
|
134
|
+
return typeof opts['name'] === 'string'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type RateLimiterStatus = {
|
|
138
|
+
limit: number
|
|
139
|
+
duration: number
|
|
140
|
+
remainingPoints: number
|
|
141
|
+
msBeforeNext: number
|
|
142
|
+
consumedPoints: number
|
|
143
|
+
isFirstInDuration: boolean
|
|
144
|
+
}
|
|
145
|
+
|
|
78
146
|
export type XRPCHandlerConfig = {
|
|
147
|
+
rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[]
|
|
79
148
|
auth?: AuthVerifier
|
|
80
149
|
handler: XRPCHandler
|
|
81
150
|
}
|
|
@@ -153,6 +222,16 @@ export class ForbiddenError extends XRPCError {
|
|
|
153
222
|
}
|
|
154
223
|
}
|
|
155
224
|
|
|
225
|
+
export class RateLimitExceededError extends XRPCError {
|
|
226
|
+
constructor(
|
|
227
|
+
public status: RateLimiterStatus,
|
|
228
|
+
errorMessage?: string,
|
|
229
|
+
customErrorName?: string,
|
|
230
|
+
) {
|
|
231
|
+
super(ResponseType.RateLimitExceeded, errorMessage, customErrorName)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
export class InternalServerError extends XRPCError {
|
|
157
236
|
constructor(errorMessage?: string, customErrorName?: string) {
|
|
158
237
|
super(ResponseType.InternalServerError, errorMessage, customErrorName)
|
|
@@ -165,9 +244,9 @@ export class UpstreamFailureError extends XRPCError {
|
|
|
165
244
|
}
|
|
166
245
|
}
|
|
167
246
|
|
|
168
|
-
export class
|
|
247
|
+
export class NotEnoughResourcesError extends XRPCError {
|
|
169
248
|
constructor(errorMessage?: string, customErrorName?: string) {
|
|
170
|
-
super(ResponseType.
|
|
249
|
+
super(ResponseType.NotEnoughResources, errorMessage, customErrorName)
|
|
171
250
|
}
|
|
172
251
|
}
|
|
173
252
|
|
package/src/util.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
1
2
|
import { Readable, Transform } from 'stream'
|
|
2
3
|
import { createDeflate, createGunzip } from 'zlib'
|
|
3
4
|
import express from 'express'
|
|
@@ -266,3 +267,35 @@ function decodeBodyStream(
|
|
|
266
267
|
|
|
267
268
|
return stream
|
|
268
269
|
}
|
|
270
|
+
|
|
271
|
+
export function serverTimingHeader(timings: ServerTiming[]) {
|
|
272
|
+
return timings
|
|
273
|
+
.map((timing) => {
|
|
274
|
+
let header = timing.name
|
|
275
|
+
if (timing.duration) header += `;dur=${timing.duration}`
|
|
276
|
+
if (timing.description) header += `;desc="${timing.description}"`
|
|
277
|
+
return header
|
|
278
|
+
})
|
|
279
|
+
.join(', ')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export class ServerTimer implements ServerTiming {
|
|
283
|
+
public duration?: number
|
|
284
|
+
private startMs?: number
|
|
285
|
+
constructor(public name: string, public description?: string) {}
|
|
286
|
+
start() {
|
|
287
|
+
this.startMs = Date.now()
|
|
288
|
+
return this
|
|
289
|
+
}
|
|
290
|
+
stop() {
|
|
291
|
+
assert(this.startMs, "timer hasn't been started")
|
|
292
|
+
this.duration = Date.now() - this.startMs
|
|
293
|
+
return this
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface ServerTiming {
|
|
298
|
+
name: string
|
|
299
|
+
duration?: number
|
|
300
|
+
description?: string
|
|
301
|
+
}
|
package/tests/bodies.test.ts
CHANGED
|
@@ -43,7 +43,7 @@ const LEXICONS = [
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
lexicon: 1,
|
|
46
|
-
id: 'io.example.
|
|
46
|
+
id: 'io.example.validationTestTwo',
|
|
47
47
|
defs: {
|
|
48
48
|
main: {
|
|
49
49
|
type: 'query',
|
|
@@ -101,7 +101,7 @@ describe('Bodies', () => {
|
|
|
101
101
|
body: ctx.input?.body,
|
|
102
102
|
}),
|
|
103
103
|
)
|
|
104
|
-
server.method('io.example.
|
|
104
|
+
server.method('io.example.validationTestTwo', () => ({
|
|
105
105
|
encoding: 'json',
|
|
106
106
|
body: { wrong: 'data' },
|
|
107
107
|
}))
|
|
@@ -175,7 +175,7 @@ describe('Bodies', () => {
|
|
|
175
175
|
return logger.error(obj, ...args)
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
await expect(client.call('io.example.
|
|
178
|
+
await expect(client.call('io.example.validationTestTwo')).rejects.toThrow(
|
|
179
179
|
'Internal Server Error',
|
|
180
180
|
)
|
|
181
181
|
expect(error).toEqual(`Output must have the property "foo"`)
|
package/tests/procedures.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as xrpcServer from '../src'
|
|
|
8
8
|
const LEXICONS = [
|
|
9
9
|
{
|
|
10
10
|
lexicon: 1,
|
|
11
|
-
id: 'io.example.
|
|
11
|
+
id: 'io.example.pingOne',
|
|
12
12
|
defs: {
|
|
13
13
|
main: {
|
|
14
14
|
type: 'procedure',
|
|
@@ -26,7 +26,7 @@ const LEXICONS = [
|
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
lexicon: 1,
|
|
29
|
-
id: 'io.example.
|
|
29
|
+
id: 'io.example.pingTwo',
|
|
30
30
|
defs: {
|
|
31
31
|
main: {
|
|
32
32
|
type: 'procedure',
|
|
@@ -41,7 +41,7 @@ const LEXICONS = [
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
lexicon: 1,
|
|
44
|
-
id: 'io.example.
|
|
44
|
+
id: 'io.example.pingThree',
|
|
45
45
|
defs: {
|
|
46
46
|
main: {
|
|
47
47
|
type: 'procedure',
|
|
@@ -56,7 +56,7 @@ const LEXICONS = [
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
lexicon: 1,
|
|
59
|
-
id: 'io.example.
|
|
59
|
+
id: 'io.example.pingFour',
|
|
60
60
|
defs: {
|
|
61
61
|
main: {
|
|
62
62
|
type: 'procedure',
|
|
@@ -84,17 +84,17 @@ const LEXICONS = [
|
|
|
84
84
|
describe('Procedures', () => {
|
|
85
85
|
let s: http.Server
|
|
86
86
|
const server = xrpcServer.createServer(LEXICONS)
|
|
87
|
-
server.method('io.example.
|
|
87
|
+
server.method('io.example.pingOne', (ctx: { params: xrpcServer.Params }) => {
|
|
88
88
|
return { encoding: 'text/plain', body: ctx.params.message }
|
|
89
89
|
})
|
|
90
90
|
server.method(
|
|
91
|
-
'io.example.
|
|
91
|
+
'io.example.pingTwo',
|
|
92
92
|
(ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => {
|
|
93
93
|
return { encoding: 'text/plain', body: ctx.input?.body }
|
|
94
94
|
},
|
|
95
95
|
)
|
|
96
96
|
server.method(
|
|
97
|
-
'io.example.
|
|
97
|
+
'io.example.pingThree',
|
|
98
98
|
async (ctx: {
|
|
99
99
|
params: xrpcServer.Params
|
|
100
100
|
input?: xrpcServer.HandlerInput
|
|
@@ -112,7 +112,7 @@ describe('Procedures', () => {
|
|
|
112
112
|
},
|
|
113
113
|
)
|
|
114
114
|
server.method(
|
|
115
|
-
'io.example.
|
|
115
|
+
'io.example.pingFour',
|
|
116
116
|
(ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => {
|
|
117
117
|
return {
|
|
118
118
|
encoding: 'application/json',
|
|
@@ -133,14 +133,14 @@ describe('Procedures', () => {
|
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
it('serves requests', async () => {
|
|
136
|
-
const res1 = await client.call('io.example.
|
|
136
|
+
const res1 = await client.call('io.example.pingOne', {
|
|
137
137
|
message: 'hello world',
|
|
138
138
|
})
|
|
139
139
|
expect(res1.success).toBeTruthy()
|
|
140
140
|
expect(res1.headers['content-type']).toBe('text/plain; charset=utf-8')
|
|
141
141
|
expect(res1.data).toBe('hello world')
|
|
142
142
|
|
|
143
|
-
const res2 = await client.call('io.example.
|
|
143
|
+
const res2 = await client.call('io.example.pingTwo', {}, 'hello world', {
|
|
144
144
|
encoding: 'text/plain',
|
|
145
145
|
})
|
|
146
146
|
expect(res2.success).toBeTruthy()
|
|
@@ -148,7 +148,7 @@ describe('Procedures', () => {
|
|
|
148
148
|
expect(res2.data).toBe('hello world')
|
|
149
149
|
|
|
150
150
|
const res3 = await client.call(
|
|
151
|
-
'io.example.
|
|
151
|
+
'io.example.pingThree',
|
|
152
152
|
{},
|
|
153
153
|
new TextEncoder().encode('hello world'),
|
|
154
154
|
{ encoding: 'application/octet-stream' },
|
|
@@ -158,7 +158,7 @@ describe('Procedures', () => {
|
|
|
158
158
|
expect(new TextDecoder().decode(res3.data)).toBe('hello world')
|
|
159
159
|
|
|
160
160
|
const res4 = await client.call(
|
|
161
|
-
'io.example.
|
|
161
|
+
'io.example.pingFour',
|
|
162
162
|
{},
|
|
163
163
|
{ message: 'hello world' },
|
|
164
164
|
)
|
package/tests/queries.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import * as xrpcServer from '../src'
|
|
|
7
7
|
const LEXICONS = [
|
|
8
8
|
{
|
|
9
9
|
lexicon: 1,
|
|
10
|
-
id: 'io.example.
|
|
10
|
+
id: 'io.example.pingOne',
|
|
11
11
|
defs: {
|
|
12
12
|
main: {
|
|
13
13
|
type: 'query',
|
|
@@ -25,7 +25,7 @@ const LEXICONS = [
|
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
lexicon: 1,
|
|
28
|
-
id: 'io.example.
|
|
28
|
+
id: 'io.example.pingTwo',
|
|
29
29
|
defs: {
|
|
30
30
|
main: {
|
|
31
31
|
type: 'query',
|
|
@@ -43,7 +43,7 @@ const LEXICONS = [
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
lexicon: 1,
|
|
46
|
-
id: 'io.example.
|
|
46
|
+
id: 'io.example.pingThree',
|
|
47
47
|
defs: {
|
|
48
48
|
main: {
|
|
49
49
|
type: 'query',
|
|
@@ -69,21 +69,25 @@ const LEXICONS = [
|
|
|
69
69
|
describe('Queries', () => {
|
|
70
70
|
let s: http.Server
|
|
71
71
|
const server = xrpcServer.createServer(LEXICONS)
|
|
72
|
-
server.method('io.example.
|
|
72
|
+
server.method('io.example.pingOne', (ctx: { params: xrpcServer.Params }) => {
|
|
73
73
|
return { encoding: 'text/plain', body: ctx.params.message }
|
|
74
74
|
})
|
|
75
|
-
server.method('io.example.
|
|
75
|
+
server.method('io.example.pingTwo', (ctx: { params: xrpcServer.Params }) => {
|
|
76
76
|
return {
|
|
77
77
|
encoding: 'application/octet-stream',
|
|
78
78
|
body: new TextEncoder().encode(String(ctx.params.message)),
|
|
79
79
|
}
|
|
80
80
|
})
|
|
81
|
-
server.method(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
server.method(
|
|
82
|
+
'io.example.pingThree',
|
|
83
|
+
(ctx: { params: xrpcServer.Params }) => {
|
|
84
|
+
return {
|
|
85
|
+
encoding: 'application/json',
|
|
86
|
+
body: { message: ctx.params.message },
|
|
87
|
+
headers: { 'x-test-header-name': 'test-value' },
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
)
|
|
87
91
|
xrpc.addLexicons(LEXICONS)
|
|
88
92
|
|
|
89
93
|
let client: ServiceClient
|
|
@@ -97,25 +101,26 @@ describe('Queries', () => {
|
|
|
97
101
|
})
|
|
98
102
|
|
|
99
103
|
it('serves requests', async () => {
|
|
100
|
-
const res1 = await client.call('io.example.
|
|
104
|
+
const res1 = await client.call('io.example.pingOne', {
|
|
101
105
|
message: 'hello world',
|
|
102
106
|
})
|
|
103
107
|
expect(res1.success).toBeTruthy()
|
|
104
108
|
expect(res1.headers['content-type']).toBe('text/plain; charset=utf-8')
|
|
105
109
|
expect(res1.data).toBe('hello world')
|
|
106
110
|
|
|
107
|
-
const res2 = await client.call('io.example.
|
|
111
|
+
const res2 = await client.call('io.example.pingTwo', {
|
|
108
112
|
message: 'hello world',
|
|
109
113
|
})
|
|
110
114
|
expect(res2.success).toBeTruthy()
|
|
111
115
|
expect(res2.headers['content-type']).toBe('application/octet-stream')
|
|
112
116
|
expect(new TextDecoder().decode(res2.data)).toBe('hello world')
|
|
113
117
|
|
|
114
|
-
const res3 = await client.call('io.example.
|
|
118
|
+
const res3 = await client.call('io.example.pingThree', {
|
|
115
119
|
message: 'hello world',
|
|
116
120
|
})
|
|
117
121
|
expect(res3.success).toBeTruthy()
|
|
118
122
|
expect(res3.headers['content-type']).toBe('application/json; charset=utf-8')
|
|
119
123
|
expect(res3.data?.message).toBe('hello world')
|
|
124
|
+
expect(res3.headers['x-test-header-name']).toEqual('test-value')
|
|
120
125
|
})
|
|
121
126
|
})
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
2
|
+
import getPort from 'get-port'
|
|
3
|
+
import xrpc, { ServiceClient } from '@atproto/xrpc'
|
|
4
|
+
import { createServer, closeServer } from './_util'
|
|
5
|
+
import * as xrpcServer from '../src'
|
|
6
|
+
import { RateLimiter } from '../src'
|
|
7
|
+
import { MINUTE } from '@atproto/common'
|
|
8
|
+
|
|
9
|
+
const LEXICONS = [
|
|
10
|
+
{
|
|
11
|
+
lexicon: 1,
|
|
12
|
+
id: 'io.example.routeLimit',
|
|
13
|
+
defs: {
|
|
14
|
+
main: {
|
|
15
|
+
type: 'query',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'params',
|
|
18
|
+
required: ['str'],
|
|
19
|
+
properties: {
|
|
20
|
+
str: { type: 'string' },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
output: {
|
|
24
|
+
encoding: 'application/json',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
lexicon: 1,
|
|
31
|
+
id: 'io.example.sharedLimitOne',
|
|
32
|
+
defs: {
|
|
33
|
+
main: {
|
|
34
|
+
type: 'query',
|
|
35
|
+
parameters: {
|
|
36
|
+
type: 'params',
|
|
37
|
+
required: ['points'],
|
|
38
|
+
properties: {
|
|
39
|
+
points: { type: 'integer' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
output: {
|
|
43
|
+
encoding: 'application/json',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
lexicon: 1,
|
|
50
|
+
id: 'io.example.sharedLimitTwo',
|
|
51
|
+
defs: {
|
|
52
|
+
main: {
|
|
53
|
+
type: 'query',
|
|
54
|
+
parameters: {
|
|
55
|
+
type: 'params',
|
|
56
|
+
required: ['points'],
|
|
57
|
+
properties: {
|
|
58
|
+
points: { type: 'integer' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
output: {
|
|
62
|
+
encoding: 'application/json',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
lexicon: 1,
|
|
69
|
+
id: 'io.example.toggleLimit',
|
|
70
|
+
defs: {
|
|
71
|
+
main: {
|
|
72
|
+
type: 'query',
|
|
73
|
+
parameters: {
|
|
74
|
+
type: 'params',
|
|
75
|
+
properties: {
|
|
76
|
+
shouldCount: { type: 'boolean' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
output: {
|
|
80
|
+
encoding: 'application/json',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
lexicon: 1,
|
|
87
|
+
id: 'io.example.noLimit',
|
|
88
|
+
defs: {
|
|
89
|
+
main: {
|
|
90
|
+
type: 'query',
|
|
91
|
+
output: {
|
|
92
|
+
encoding: 'application/json',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
describe('Parameters', () => {
|
|
100
|
+
let s: http.Server
|
|
101
|
+
const server = xrpcServer.createServer(LEXICONS, {
|
|
102
|
+
rateLimits: {
|
|
103
|
+
creator: (opts: xrpcServer.RateLimiterOpts) =>
|
|
104
|
+
RateLimiter.memory({
|
|
105
|
+
bypassSecret: 'bypass',
|
|
106
|
+
...opts,
|
|
107
|
+
}),
|
|
108
|
+
shared: [
|
|
109
|
+
{
|
|
110
|
+
name: 'shared-limit',
|
|
111
|
+
durationMs: 5 * MINUTE,
|
|
112
|
+
points: 6,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
global: [
|
|
116
|
+
{
|
|
117
|
+
name: 'global-ip',
|
|
118
|
+
durationMs: 5 * MINUTE,
|
|
119
|
+
points: 100,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
server.method('io.example.routeLimit', {
|
|
125
|
+
rateLimit: {
|
|
126
|
+
durationMs: 5 * MINUTE,
|
|
127
|
+
points: 5,
|
|
128
|
+
calcKey: ({ params }) => params.str as string,
|
|
129
|
+
},
|
|
130
|
+
handler: (ctx: { params: xrpcServer.Params }) => ({
|
|
131
|
+
encoding: 'json',
|
|
132
|
+
body: ctx.params,
|
|
133
|
+
}),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
server.method('io.example.sharedLimitOne', {
|
|
137
|
+
rateLimit: {
|
|
138
|
+
name: 'shared-limit',
|
|
139
|
+
calcPoints: ({ params }) => params.points as number,
|
|
140
|
+
},
|
|
141
|
+
handler: (ctx: { params: xrpcServer.Params }) => ({
|
|
142
|
+
encoding: 'json',
|
|
143
|
+
body: ctx.params,
|
|
144
|
+
}),
|
|
145
|
+
})
|
|
146
|
+
server.method('io.example.sharedLimitTwo', {
|
|
147
|
+
rateLimit: {
|
|
148
|
+
name: 'shared-limit',
|
|
149
|
+
calcPoints: ({ params }) => params.points as number,
|
|
150
|
+
},
|
|
151
|
+
handler: (ctx: { params: xrpcServer.Params }) => ({
|
|
152
|
+
encoding: 'json',
|
|
153
|
+
body: ctx.params,
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
server.method('io.example.toggleLimit', {
|
|
157
|
+
rateLimit: [
|
|
158
|
+
{
|
|
159
|
+
durationMs: 5 * MINUTE,
|
|
160
|
+
points: 5,
|
|
161
|
+
calcPoints: ({ params }) => (params.shouldCount ? 1 : 0),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
durationMs: 5 * MINUTE,
|
|
165
|
+
points: 10,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
handler: (ctx: { params: xrpcServer.Params }) => ({
|
|
169
|
+
encoding: 'json',
|
|
170
|
+
body: ctx.params,
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
173
|
+
server.method('io.example.noLimit', {
|
|
174
|
+
handler: () => ({
|
|
175
|
+
encoding: 'json',
|
|
176
|
+
body: {},
|
|
177
|
+
}),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
xrpc.addLexicons(LEXICONS)
|
|
181
|
+
|
|
182
|
+
let client: ServiceClient
|
|
183
|
+
beforeAll(async () => {
|
|
184
|
+
const port = await getPort()
|
|
185
|
+
s = await createServer(port, server)
|
|
186
|
+
client = xrpc.service(`http://localhost:${port}`)
|
|
187
|
+
})
|
|
188
|
+
afterAll(async () => {
|
|
189
|
+
await closeServer(s)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('rate limits a given route', async () => {
|
|
193
|
+
const makeCall = () => client.call('io.example.routeLimit', { str: 'test' })
|
|
194
|
+
for (let i = 0; i < 5; i++) {
|
|
195
|
+
await makeCall()
|
|
196
|
+
}
|
|
197
|
+
await expect(makeCall).rejects.toThrow('Rate Limit Exceeded')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('rate limits on a shared route', async () => {
|
|
201
|
+
await client.call('io.example.sharedLimitOne', { points: 1 })
|
|
202
|
+
await client.call('io.example.sharedLimitTwo', { points: 1 })
|
|
203
|
+
await client.call('io.example.sharedLimitOne', { points: 2 })
|
|
204
|
+
await client.call('io.example.sharedLimitTwo', { points: 2 })
|
|
205
|
+
await expect(
|
|
206
|
+
client.call('io.example.sharedLimitOne', { points: 1 }),
|
|
207
|
+
).rejects.toThrow('Rate Limit Exceeded')
|
|
208
|
+
await expect(
|
|
209
|
+
client.call('io.example.sharedLimitTwo', { points: 1 }),
|
|
210
|
+
).rejects.toThrow('Rate Limit Exceeded')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('applies multiple rate-limits', async () => {
|
|
214
|
+
const makeCall = (shouldCount: boolean) =>
|
|
215
|
+
client.call('io.example.toggleLimit', { shouldCount })
|
|
216
|
+
for (let i = 0; i < 5; i++) {
|
|
217
|
+
await makeCall(true)
|
|
218
|
+
}
|
|
219
|
+
await expect(() => makeCall(true)).rejects.toThrow('Rate Limit Exceeded')
|
|
220
|
+
for (let i = 0; i < 4; i++) {
|
|
221
|
+
await makeCall(false)
|
|
222
|
+
}
|
|
223
|
+
await expect(() => makeCall(false)).rejects.toThrow('Rate Limit Exceeded')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('applies global limits', async () => {
|
|
227
|
+
const makeCall = () => client.call('io.example.noLimit')
|
|
228
|
+
const calls: Promise<unknown>[] = []
|
|
229
|
+
for (let i = 0; i < 110; i++) {
|
|
230
|
+
calls.push(makeCall())
|
|
231
|
+
}
|
|
232
|
+
await expect(Promise.all(calls)).rejects.toThrow('Rate Limit Exceeded')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('can bypass rate limits', async () => {
|
|
236
|
+
const makeCall = () =>
|
|
237
|
+
client.call(
|
|
238
|
+
'io.example.noLimit',
|
|
239
|
+
{},
|
|
240
|
+
{},
|
|
241
|
+
{ headers: { 'X-RateLimit-Bypass': 'bypass' } },
|
|
242
|
+
)
|
|
243
|
+
const calls: Promise<unknown>[] = []
|
|
244
|
+
for (let i = 0; i < 110; i++) {
|
|
245
|
+
calls.push(makeCall())
|
|
246
|
+
}
|
|
247
|
+
await Promise.all(calls)
|
|
248
|
+
})
|
|
249
|
+
})
|