@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/server.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { IncomingMessage } from 'node:http'
|
|
1
3
|
import { Readable } from 'node:stream'
|
|
2
4
|
import { pipeline } from 'node:stream/promises'
|
|
3
5
|
import express, {
|
|
4
6
|
Application,
|
|
5
7
|
ErrorRequestHandler,
|
|
6
8
|
Express,
|
|
7
|
-
NextFunction,
|
|
8
9
|
Request,
|
|
9
10
|
RequestHandler,
|
|
10
|
-
Response,
|
|
11
11
|
Router,
|
|
12
|
-
json as jsonParser,
|
|
13
|
-
text as textParser,
|
|
14
12
|
} from 'express'
|
|
15
13
|
import { check, schema } from '@atproto/common'
|
|
16
14
|
import {
|
|
@@ -21,36 +19,51 @@ import {
|
|
|
21
19
|
Lexicons,
|
|
22
20
|
lexToJson,
|
|
23
21
|
} from '@atproto/lexicon'
|
|
22
|
+
import {
|
|
23
|
+
InternalServerError,
|
|
24
|
+
InvalidRequestError,
|
|
25
|
+
MethodNotImplementedError,
|
|
26
|
+
XRPCError,
|
|
27
|
+
excludeErrorResult,
|
|
28
|
+
isErrorResult,
|
|
29
|
+
} from './errors'
|
|
24
30
|
import log, { LOGGER_NAME } from './logger'
|
|
25
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
CalcKeyFn,
|
|
33
|
+
CalcPointsFn,
|
|
34
|
+
RateLimiterI,
|
|
35
|
+
RateLimiterOptions,
|
|
36
|
+
RouteRateLimiter,
|
|
37
|
+
WrappedRateLimiter,
|
|
38
|
+
} from './rate-limiter'
|
|
26
39
|
import { ErrorFrame, Frame, MessageFrame, XrpcStreamServer } from './stream'
|
|
27
40
|
import {
|
|
41
|
+
Auth,
|
|
42
|
+
AuthResult,
|
|
28
43
|
AuthVerifier,
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
CatchallHandler,
|
|
45
|
+
HandlerContext,
|
|
31
46
|
HandlerSuccess,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
Input,
|
|
48
|
+
MethodConfig,
|
|
49
|
+
MethodConfigOrHandler,
|
|
35
50
|
Options,
|
|
36
51
|
Params,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
XRPCHandlerConfig,
|
|
42
|
-
XRPCReqContext,
|
|
43
|
-
XRPCStreamHandler,
|
|
44
|
-
XRPCStreamHandlerConfig,
|
|
45
|
-
isHandlerError,
|
|
52
|
+
RouteOptions,
|
|
53
|
+
ServerRateLimitDescription,
|
|
54
|
+
StreamConfig,
|
|
55
|
+
StreamConfigOrHandler,
|
|
46
56
|
isHandlerPipeThroughBuffer,
|
|
47
57
|
isHandlerPipeThroughStream,
|
|
48
|
-
|
|
58
|
+
isSharedRateLimitOpts,
|
|
49
59
|
} from './types'
|
|
50
60
|
import {
|
|
61
|
+
asArray,
|
|
62
|
+
createInputVerifier,
|
|
51
63
|
decodeQueryParams,
|
|
64
|
+
extractUrlNsid,
|
|
52
65
|
getQueryParams,
|
|
53
|
-
|
|
66
|
+
setHeaders,
|
|
54
67
|
validateOutput,
|
|
55
68
|
} from './util'
|
|
56
69
|
|
|
@@ -64,45 +77,38 @@ export class Server {
|
|
|
64
77
|
subscriptions = new Map<string, XrpcStreamServer>()
|
|
65
78
|
lex = new Lexicons()
|
|
66
79
|
options: Options
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
sharedRateLimiters: Record<string, RateLimiterI>
|
|
70
|
-
routeRateLimiters: Record<string, RateLimiterI[]>
|
|
80
|
+
globalRateLimiter?: RouteRateLimiter<HandlerContext>
|
|
81
|
+
sharedRateLimiters?: Map<string, RateLimiterI<HandlerContext>>
|
|
71
82
|
|
|
72
83
|
constructor(lexicons?: LexiconDoc[], opts: Options = {}) {
|
|
73
84
|
if (lexicons) {
|
|
74
85
|
this.addLexicons(lexicons)
|
|
75
86
|
}
|
|
76
87
|
this.router.use(this.routes)
|
|
77
|
-
this.router.use(
|
|
88
|
+
this.router.use(this.catchall)
|
|
78
89
|
this.router.use(createErrorMiddleware(opts))
|
|
79
90
|
this.router.once('mount', (app: Application) => {
|
|
80
91
|
this.enableStreamingOnListen(app)
|
|
81
92
|
})
|
|
82
93
|
this.options = opts
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const rateLimiter = opts.rateLimits.creator({
|
|
93
|
-
...limit,
|
|
94
|
-
keyPrefix: `rl-${limit.name}`,
|
|
95
|
-
})
|
|
96
|
-
this.globalRateLimiters.push(rateLimiter)
|
|
94
|
+
|
|
95
|
+
if (opts.rateLimits) {
|
|
96
|
+
const { global, shared, creator, bypass } = opts.rateLimits
|
|
97
|
+
|
|
98
|
+
if (global) {
|
|
99
|
+
this.globalRateLimiter = RouteRateLimiter.from(
|
|
100
|
+
global.map((options) => creator(buildRateLimiterOptions(options))),
|
|
101
|
+
{ bypass },
|
|
102
|
+
)
|
|
97
103
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
|
|
105
|
+
if (shared) {
|
|
106
|
+
this.sharedRateLimiters = new Map(
|
|
107
|
+
shared.map((options) => [
|
|
108
|
+
options.name,
|
|
109
|
+
creator(buildRateLimiterOptions(options)),
|
|
110
|
+
]),
|
|
111
|
+
)
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
}
|
|
@@ -110,11 +116,17 @@ export class Server {
|
|
|
110
116
|
// handlers
|
|
111
117
|
// =
|
|
112
118
|
|
|
113
|
-
method
|
|
119
|
+
method<A extends Auth = Auth>(
|
|
120
|
+
nsid: string,
|
|
121
|
+
configOrFn: MethodConfigOrHandler<A>,
|
|
122
|
+
) {
|
|
114
123
|
this.addMethod(nsid, configOrFn)
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
addMethod
|
|
126
|
+
addMethod<A extends Auth = Auth>(
|
|
127
|
+
nsid: string,
|
|
128
|
+
configOrFn: MethodConfigOrHandler<A>,
|
|
129
|
+
) {
|
|
118
130
|
const config =
|
|
119
131
|
typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
|
|
120
132
|
const def = this.lex.getDef(nsid)
|
|
@@ -125,16 +137,16 @@ export class Server {
|
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
streamMethod(
|
|
140
|
+
streamMethod<A extends Auth = Auth>(
|
|
129
141
|
nsid: string,
|
|
130
|
-
configOrFn:
|
|
142
|
+
configOrFn: StreamConfigOrHandler<A>,
|
|
131
143
|
) {
|
|
132
144
|
this.addStreamMethod(nsid, configOrFn)
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
addStreamMethod(
|
|
147
|
+
addStreamMethod<A extends Auth = Auth>(
|
|
136
148
|
nsid: string,
|
|
137
|
-
configOrFn:
|
|
149
|
+
configOrFn: StreamConfigOrHandler<A>,
|
|
138
150
|
) {
|
|
139
151
|
const config =
|
|
140
152
|
typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
|
|
@@ -162,160 +174,175 @@ export class Server {
|
|
|
162
174
|
// http
|
|
163
175
|
// =
|
|
164
176
|
|
|
165
|
-
protected async addRoute(
|
|
177
|
+
protected async addRoute<A extends Auth = Auth>(
|
|
166
178
|
nsid: string,
|
|
167
179
|
def: LexXrpcQuery | LexXrpcProcedure,
|
|
168
|
-
config:
|
|
180
|
+
config: MethodConfig<A>,
|
|
169
181
|
) {
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
middleware.push(this.middleware.json)
|
|
178
|
-
middleware.push(this.middleware.text)
|
|
182
|
+
const path = `/xrpc/${nsid}`
|
|
183
|
+
const handler = this.createHandler(nsid, def, config)
|
|
184
|
+
|
|
185
|
+
if (def.type === 'procedure') {
|
|
186
|
+
this.routes.post(path, handler)
|
|
187
|
+
} else {
|
|
188
|
+
this.routes.get(path, handler)
|
|
179
189
|
}
|
|
180
|
-
this.setupRouteRateLimits(nsid, config)
|
|
181
|
-
this.routes[verb](
|
|
182
|
-
`/xrpc/${nsid}`,
|
|
183
|
-
...middleware,
|
|
184
|
-
this.createHandler(nsid, def, config),
|
|
185
|
-
)
|
|
186
190
|
}
|
|
187
191
|
|
|
188
|
-
async
|
|
189
|
-
|
|
192
|
+
catchall: CatchallHandler = async (req, res, next) => {
|
|
193
|
+
// catchall handler only applies to XRPC routes
|
|
194
|
+
if (!req.url.startsWith('/xrpc/')) return next()
|
|
195
|
+
|
|
196
|
+
// Validate the NSID
|
|
197
|
+
const nsid = extractUrlNsid(req.url)
|
|
198
|
+
if (!nsid) {
|
|
199
|
+
return next(new InvalidRequestError('invalid xrpc path'))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (this.globalRateLimiter) {
|
|
190
203
|
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
|
-
}
|
|
204
|
+
await this.globalRateLimiter.handle({
|
|
205
|
+
req,
|
|
206
|
+
res,
|
|
207
|
+
auth: undefined,
|
|
208
|
+
params: {},
|
|
209
|
+
input: undefined,
|
|
210
|
+
async resetRouteRateLimits() {},
|
|
211
|
+
})
|
|
207
212
|
} catch (err) {
|
|
208
213
|
return next(err)
|
|
209
214
|
}
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
// Ensure that known XRPC methods are only called with the correct HTTP
|
|
218
|
+
// method.
|
|
219
|
+
const def = this.lex.getDef(nsid)
|
|
220
|
+
if (def) {
|
|
221
|
+
const expectedMethod =
|
|
222
|
+
def.type === 'procedure' ? 'POST' : def.type === 'query' ? 'GET' : null
|
|
223
|
+
if (expectedMethod != null && expectedMethod !== req.method) {
|
|
224
|
+
return next(
|
|
225
|
+
new InvalidRequestError(
|
|
226
|
+
`Incorrect HTTP method (${req.method}) expected ${expectedMethod}`,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
212
232
|
if (this.options.catchall) {
|
|
213
|
-
|
|
233
|
+
this.options.catchall.call(null, req, res, next)
|
|
234
|
+
} else if (!def) {
|
|
235
|
+
next(new MethodNotImplementedError())
|
|
236
|
+
} else {
|
|
237
|
+
next()
|
|
214
238
|
}
|
|
239
|
+
}
|
|
215
240
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
protected createParamsVerifier(
|
|
242
|
+
nsid: string,
|
|
243
|
+
def: LexXrpcQuery | LexXrpcProcedure | LexXrpcSubscription,
|
|
244
|
+
) {
|
|
245
|
+
return (req: Request | IncomingMessage): Params => {
|
|
246
|
+
const queryParams = 'query' in req ? req.query : getQueryParams(req.url)
|
|
247
|
+
const params: Params = decodeQueryParams(def, queryParams)
|
|
248
|
+
try {
|
|
249
|
+
return this.lex.assertValidXrpcParams(nsid, params) as Params
|
|
250
|
+
} catch (e) {
|
|
251
|
+
throw new InvalidRequestError(String(e))
|
|
252
|
+
}
|
|
219
253
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
protected createInputVerifier(
|
|
257
|
+
nsid: string,
|
|
258
|
+
def: LexXrpcQuery | LexXrpcProcedure,
|
|
259
|
+
routeOpts: RouteOptions,
|
|
260
|
+
) {
|
|
261
|
+
return createInputVerifier(nsid, def, routeOpts, this.lex)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
protected createAuthVerifier<C, A extends Auth>(cfg: {
|
|
265
|
+
auth?: AuthVerifier<C, A & AuthResult>
|
|
266
|
+
}): null | ((ctx: C) => Promise<A>) {
|
|
267
|
+
const { auth } = cfg
|
|
268
|
+
if (!auth) return null
|
|
269
|
+
|
|
270
|
+
return async (ctx: C) => {
|
|
271
|
+
const result = await auth(ctx)
|
|
272
|
+
return excludeErrorResult(result)
|
|
233
273
|
}
|
|
234
|
-
return next()
|
|
235
274
|
}
|
|
236
275
|
|
|
237
|
-
createHandler(
|
|
276
|
+
createHandler<A extends Auth = Auth>(
|
|
238
277
|
nsid: string,
|
|
239
278
|
def: LexXrpcQuery | LexXrpcProcedure,
|
|
240
|
-
|
|
279
|
+
cfg: MethodConfig<A>,
|
|
241
280
|
): RequestHandler {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
281
|
+
const authVerifier = this.createAuthVerifier(cfg)
|
|
282
|
+
const paramsVerifier = this.createParamsVerifier(nsid, def)
|
|
283
|
+
const inputVerifier = this.createInputVerifier(nsid, def, {
|
|
284
|
+
blobLimit: cfg.opts?.blobLimit ?? this.options.payload?.blobLimit,
|
|
285
|
+
jsonLimit: cfg.opts?.jsonLimit ?? this.options.payload?.jsonLimit,
|
|
286
|
+
textLimit: cfg.opts?.textLimit ?? this.options.payload?.textLimit,
|
|
287
|
+
})
|
|
288
|
+
|
|
247
289
|
const validateResOutput =
|
|
248
290
|
this.options.validateResponse === false
|
|
249
291
|
? null
|
|
250
|
-
: (output:
|
|
292
|
+
: (output: void | HandlerSuccess) =>
|
|
251
293
|
validateOutput(nsid, def, output, this.lex)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const rls = this.routeRateLimiters[nsid] ?? []
|
|
255
|
-
const consumeRateLimit = (reqCtx: XRPCReqContext) =>
|
|
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
|
-
)
|
|
294
|
+
|
|
295
|
+
const routeLimiter = this.createRouteRateLimiter(nsid, cfg)
|
|
266
296
|
|
|
267
297
|
return async function (req, res, next) {
|
|
268
298
|
try {
|
|
269
|
-
// validate
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const input = validateReqInput(req)
|
|
299
|
+
// parse & validate params
|
|
300
|
+
const params: Params = paramsVerifier(req)
|
|
301
|
+
|
|
302
|
+
// authenticate request
|
|
303
|
+
const auth: A = authVerifier
|
|
304
|
+
? await authVerifier({ req, res, params })
|
|
305
|
+
: (undefined as A)
|
|
277
306
|
|
|
278
|
-
|
|
307
|
+
// parse & validate input
|
|
308
|
+
const input: Input = await inputVerifier(req, res)
|
|
279
309
|
|
|
280
|
-
const
|
|
310
|
+
const ctx: HandlerContext<A> = {
|
|
281
311
|
params,
|
|
282
312
|
input,
|
|
283
|
-
auth
|
|
313
|
+
auth,
|
|
284
314
|
req,
|
|
285
315
|
res,
|
|
286
|
-
resetRouteRateLimits: async () =>
|
|
316
|
+
resetRouteRateLimits: async () => routeLimiter?.reset(ctx),
|
|
287
317
|
}
|
|
288
318
|
|
|
289
319
|
// handle rate limits
|
|
290
|
-
|
|
291
|
-
if (result instanceof RateLimitExceededError) {
|
|
292
|
-
return next(result)
|
|
293
|
-
}
|
|
320
|
+
if (routeLimiter) await routeLimiter.handle(ctx)
|
|
294
321
|
|
|
295
322
|
// run the handler
|
|
296
|
-
const output = await
|
|
323
|
+
const output = await cfg.handler(ctx)
|
|
297
324
|
|
|
298
325
|
if (!output) {
|
|
299
326
|
validateResOutput?.(output)
|
|
300
327
|
res.status(200)
|
|
301
328
|
res.end()
|
|
302
329
|
} else if (isHandlerPipeThroughStream(output)) {
|
|
303
|
-
setHeaders(res, output)
|
|
330
|
+
setHeaders(res, output.headers)
|
|
304
331
|
res.status(200)
|
|
305
332
|
res.header('Content-Type', output.encoding)
|
|
306
333
|
await pipeline(output.stream, res)
|
|
307
334
|
} else if (isHandlerPipeThroughBuffer(output)) {
|
|
308
|
-
setHeaders(res, output)
|
|
335
|
+
setHeaders(res, output.headers)
|
|
309
336
|
res.status(200)
|
|
310
337
|
res.header('Content-Type', output.encoding)
|
|
311
338
|
res.end(output.buffer)
|
|
312
|
-
} else if (
|
|
339
|
+
} else if (isErrorResult(output)) {
|
|
313
340
|
next(XRPCError.fromError(output))
|
|
314
341
|
} else {
|
|
315
342
|
validateResOutput?.(output)
|
|
316
343
|
|
|
317
344
|
res.status(200)
|
|
318
|
-
setHeaders(res, output)
|
|
345
|
+
setHeaders(res, output.headers)
|
|
319
346
|
|
|
320
347
|
if (
|
|
321
348
|
output.encoding === 'application/json' ||
|
|
@@ -350,34 +377,29 @@ export class Server {
|
|
|
350
377
|
}
|
|
351
378
|
}
|
|
352
379
|
|
|
353
|
-
protected async addSubscription(
|
|
380
|
+
protected async addSubscription<A extends Auth = Auth>(
|
|
354
381
|
nsid: string,
|
|
355
382
|
def: LexXrpcSubscription,
|
|
356
|
-
|
|
383
|
+
cfg: StreamConfig<A>,
|
|
357
384
|
) {
|
|
358
|
-
const
|
|
359
|
-
|
|
385
|
+
const paramsVerifier = this.createParamsVerifier(nsid, def)
|
|
386
|
+
const authVerifier = this.createAuthVerifier(cfg)
|
|
387
|
+
|
|
388
|
+
const { handler } = cfg
|
|
360
389
|
this.subscriptions.set(
|
|
361
390
|
nsid,
|
|
362
391
|
new XrpcStreamServer({
|
|
363
392
|
noServer: true,
|
|
364
393
|
handler: async function* (req, signal) {
|
|
365
394
|
try {
|
|
366
|
-
// authenticate request
|
|
367
|
-
const auth = await config.auth?.({ req })
|
|
368
|
-
if (isHandlerError(auth)) {
|
|
369
|
-
throw XRPCError.fromHandlerError(auth)
|
|
370
|
-
}
|
|
371
395
|
// validate request
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
396
|
+
const params = paramsVerifier(req)
|
|
397
|
+
// authenticate request
|
|
398
|
+
const auth = authVerifier
|
|
399
|
+
? await authVerifier({ req, params })
|
|
400
|
+
: (undefined as A)
|
|
378
401
|
// stream
|
|
379
|
-
const
|
|
380
|
-
for await (const item of items) {
|
|
402
|
+
for await (const item of handler({ req, params, auth, signal })) {
|
|
381
403
|
if (item instanceof Frame) {
|
|
382
404
|
yield item
|
|
383
405
|
continue
|
|
@@ -419,10 +441,8 @@ export class Server {
|
|
|
419
441
|
// @ts-ignore the args spread
|
|
420
442
|
const httpServer = _listen.call(app, ...args)
|
|
421
443
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
422
|
-
const
|
|
423
|
-
const sub =
|
|
424
|
-
? this.subscriptions.get(url.pathname.replace('/xrpc/', ''))
|
|
425
|
-
: undefined
|
|
444
|
+
const nsid = req.url ? extractUrlNsid(req.url) : undefined
|
|
445
|
+
const sub = nsid ? this.subscriptions.get(nsid) : undefined
|
|
426
446
|
if (!sub) return socket.destroy()
|
|
427
447
|
sub.wss.handleUpgrade(req, socket, head, (ws) =>
|
|
428
448
|
sub.wss.emit('connection', ws, req),
|
|
@@ -432,107 +452,57 @@ export class Server {
|
|
|
432
452
|
}
|
|
433
453
|
}
|
|
434
454
|
|
|
435
|
-
private
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
}
|
|
455
|
+
private createRouteRateLimiter<A extends Auth, C extends HandlerContext>(
|
|
456
|
+
nsid: string,
|
|
457
|
+
config: MethodConfig<A>,
|
|
458
|
+
): RouteRateLimiter<C> | undefined {
|
|
459
|
+
// @NOTE global & shared rate limiters are instantiated with a context of
|
|
460
|
+
// HandlerContext which is compatible (more generic) with the context of
|
|
461
|
+
// this route specific rate limiters (C). For this reason, it's safe to
|
|
462
|
+
// cast these with an `any` context
|
|
463
|
+
|
|
464
|
+
const globalRateLimiter = this.globalRateLimiter as
|
|
465
|
+
| RouteRateLimiter<any>
|
|
466
|
+
| undefined
|
|
467
|
+
|
|
468
|
+
// No route specific rate limiting configured, use the global rate limiter.
|
|
469
|
+
if (!config.rateLimit) return globalRateLimiter
|
|
470
|
+
|
|
471
|
+
const { rateLimits } = this.options
|
|
472
|
+
|
|
473
|
+
// @NOTE Silently ignore creation of route specific rate limiter if the
|
|
474
|
+
// `rateLimits` options was not provided to the constructor.
|
|
475
|
+
if (!rateLimits) return globalRateLimiter
|
|
476
|
+
|
|
477
|
+
const { creator, bypass } = rateLimits
|
|
478
|
+
|
|
479
|
+
const rateLimiters = asArray(config.rateLimit).map((options, i) => {
|
|
480
|
+
if (isSharedRateLimitOpts(options)) {
|
|
481
|
+
const rateLimiter = this.sharedRateLimiters?.get(options.name)
|
|
482
|
+
|
|
483
|
+
// The route config references a shared rate limiter that does not
|
|
484
|
+
// exist. This is a configuration error.
|
|
485
|
+
assert(rateLimiter, `Shared rate limiter "${options.name}" not defined`)
|
|
486
|
+
|
|
487
|
+
return WrappedRateLimiter.from<any>(rateLimiter, options)
|
|
488
|
+
} else {
|
|
489
|
+
return creator({
|
|
490
|
+
...options,
|
|
491
|
+
calcKey: options.calcKey ?? defaultKey,
|
|
492
|
+
calcPoints: options.calcPoints ?? defaultPoints,
|
|
493
|
+
keyPrefix: `${nsid}-${i}`,
|
|
494
|
+
})
|
|
491
495
|
}
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
496
|
+
})
|
|
495
497
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
result: HandlerSuccess | HandlerPipeThrough,
|
|
499
|
-
) {
|
|
500
|
-
const { headers } = result
|
|
501
|
-
if (headers) {
|
|
502
|
-
for (const [name, val] of Object.entries(headers)) {
|
|
503
|
-
if (val != null) res.header(name, val)
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
498
|
+
// If the route config contains an empty array, use global rate limiter.
|
|
499
|
+
if (!rateLimiters.length) return globalRateLimiter
|
|
507
500
|
|
|
508
|
-
|
|
501
|
+
// The global rate limiter (if present) should be applied in addition to
|
|
502
|
+
// the route specific rate limiters.
|
|
503
|
+
if (globalRateLimiter) rateLimiters.push(globalRateLimiter)
|
|
509
504
|
|
|
510
|
-
|
|
511
|
-
return function (req, _res, next) {
|
|
512
|
-
const locals: RequestLocals = { auth: undefined, nsid }
|
|
513
|
-
req[kRequestLocals] = locals
|
|
514
|
-
return next()
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
type RequestLocals = {
|
|
519
|
-
auth: HandlerAuth | undefined
|
|
520
|
-
nsid: string
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
function createAuthMiddleware(verifier: AuthVerifier): RequestHandler {
|
|
524
|
-
return async function (req, res, next) {
|
|
525
|
-
try {
|
|
526
|
-
const result = await verifier({ req, res })
|
|
527
|
-
if (isHandlerError(result)) {
|
|
528
|
-
throw XRPCError.fromHandlerError(result)
|
|
529
|
-
}
|
|
530
|
-
const locals: RequestLocals = req[kRequestLocals]
|
|
531
|
-
locals.auth = result
|
|
532
|
-
next()
|
|
533
|
-
} catch (err: unknown) {
|
|
534
|
-
next(err)
|
|
535
|
-
}
|
|
505
|
+
return RouteRateLimiter.from<any>(rateLimiters, { bypass })
|
|
536
506
|
}
|
|
537
507
|
}
|
|
538
508
|
|
|
@@ -540,9 +510,7 @@ function createErrorMiddleware({
|
|
|
540
510
|
errorParser = (err) => XRPCError.fromError(err),
|
|
541
511
|
}: Options): ErrorRequestHandler {
|
|
542
512
|
return (err, req, res, next) => {
|
|
543
|
-
const
|
|
544
|
-
const methodSuffix = locals ? ` method ${locals.nsid}` : ''
|
|
545
|
-
|
|
513
|
+
const nsid = extractUrlNsid(req.originalUrl)
|
|
546
514
|
const xrpcError = errorParser(err)
|
|
547
515
|
|
|
548
516
|
// Use the request's logger (if available) to benefit from request context
|
|
@@ -551,6 +519,10 @@ function createErrorMiddleware({
|
|
|
551
519
|
|
|
552
520
|
const isInternalError = xrpcError instanceof InternalServerError
|
|
553
521
|
|
|
522
|
+
const msgPrefix = isInternalError ? 'unhandled exception' : 'error'
|
|
523
|
+
const msgSuffix = nsid ? `xrpc method ${nsid}` : `${req.method} ${req.url}`
|
|
524
|
+
const msg = `${msgPrefix} in ${msgSuffix}`
|
|
525
|
+
|
|
554
526
|
logger.error(
|
|
555
527
|
{
|
|
556
528
|
// @NOTE Computation of error stack is an expensive operation, so
|
|
@@ -561,7 +533,7 @@ function createErrorMiddleware({
|
|
|
561
533
|
: toSimplifiedErrorLike(err),
|
|
562
534
|
|
|
563
535
|
// XRPC specific properties, for easier browsing of logs
|
|
564
|
-
nsid
|
|
536
|
+
nsid,
|
|
565
537
|
type: xrpcError.type,
|
|
566
538
|
status: xrpcError.statusCode,
|
|
567
539
|
payload: xrpcError.payload,
|
|
@@ -570,9 +542,7 @@ function createErrorMiddleware({
|
|
|
570
542
|
// the name of the pino-http logger, to ensure consistency across logs.
|
|
571
543
|
name: LOGGER_NAME,
|
|
572
544
|
},
|
|
573
|
-
|
|
574
|
-
? `unhandled exception in xrpc${methodSuffix}`
|
|
575
|
-
: `error in xrpc${methodSuffix}`,
|
|
545
|
+
msg,
|
|
576
546
|
)
|
|
577
547
|
|
|
578
548
|
if (res.headersSent) {
|
|
@@ -583,7 +553,7 @@ function createErrorMiddleware({
|
|
|
583
553
|
}
|
|
584
554
|
}
|
|
585
555
|
|
|
586
|
-
function isPinoHttpRequest(req:
|
|
556
|
+
function isPinoHttpRequest(req: IncomingMessage): req is IncomingMessage & {
|
|
587
557
|
log: { error: (obj: unknown, msg: string) => void }
|
|
588
558
|
} {
|
|
589
559
|
return typeof (req as { log?: any }).log?.error === 'function'
|
|
@@ -608,3 +578,22 @@ function toSimplifiedErrorLike(err: unknown): unknown {
|
|
|
608
578
|
|
|
609
579
|
return err
|
|
610
580
|
}
|
|
581
|
+
|
|
582
|
+
function buildRateLimiterOptions<C extends HandlerContext = HandlerContext>({
|
|
583
|
+
name,
|
|
584
|
+
calcKey = defaultKey,
|
|
585
|
+
calcPoints = defaultPoints,
|
|
586
|
+
...desc
|
|
587
|
+
}: ServerRateLimitDescription<C>): RateLimiterOptions<C> {
|
|
588
|
+
return { ...desc, calcKey, calcPoints, keyPrefix: `rl-${name}` }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const defaultPoints: CalcPointsFn = () => 1
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* @note when using a proxy, ensure headers are getting forwarded correctly:
|
|
595
|
+
* `app.set('trust proxy', true)`
|
|
596
|
+
*
|
|
597
|
+
* @see {@link https://expressjs.com/en/guide/behind-proxies.html}
|
|
598
|
+
*/
|
|
599
|
+
const defaultKey: CalcKeyFn<HandlerContext> = ({ req }) => req.ip
|