@atproto/xrpc-server 0.10.14 → 0.10.16
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 +23 -0
- package/dist/auth.d.ts +3 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +18 -0
- package/dist/auth.js.map +1 -1
- package/dist/errors.d.ts +7 -14
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +19 -6
- package/dist/errors.js.map +1 -1
- package/dist/server.d.ts +27 -11
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +115 -78
- package/dist/server.js.map +1 -1
- package/dist/stream/frames.d.ts +5 -1
- package/dist/stream/frames.d.ts.map +1 -1
- package/dist/stream/frames.js +32 -5
- package/dist/stream/frames.js.map +1 -1
- package/dist/stream/types.d.ts +18 -44
- package/dist/stream/types.d.ts.map +1 -1
- package/dist/stream/types.js +10 -10
- package/dist/stream/types.js.map +1 -1
- package/dist/types.d.ts +47 -70
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +28 -15
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +18 -9
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +180 -37
- package/dist/util.js.map +1 -1
- package/package.json +11 -7
- package/src/auth.ts +28 -2
- package/src/errors.ts +23 -7
- package/src/server.ts +307 -111
- package/src/stream/frames.ts +39 -6
- package/src/stream/types.ts +14 -14
- package/src/types.ts +106 -25
- package/src/util.ts +272 -60
- package/tests/_util.ts +62 -5
- package/tests/bodies.test.ts +442 -387
- package/tests/procedures.test.ts +71 -52
- package/tests/queries.test.ts +56 -39
- package/tests/subscriptions.test.ts +234 -221
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IncomingMessage } from 'node:http'
|
|
2
2
|
import { Readable } from 'node:stream'
|
|
3
3
|
import { NextFunction, Request, Response } from 'express'
|
|
4
|
-
import {
|
|
4
|
+
import { l } from '@atproto/lex-schema'
|
|
5
5
|
import { ErrorResult, XRPCError } from './errors'
|
|
6
6
|
import { CalcKeyFn, CalcPointsFn, RateLimiterI } from './rate-limiter'
|
|
7
7
|
|
|
@@ -51,44 +51,50 @@ export type AuthResult = {
|
|
|
51
51
|
artifacts?: unknown
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export const headersSchema =
|
|
54
|
+
export const headersSchema = l.dict(l.string(), l.string())
|
|
55
55
|
|
|
56
|
-
export type Headers =
|
|
56
|
+
export type Headers = l.Infer<typeof headersSchema>
|
|
57
57
|
|
|
58
|
-
export const handlerSuccess =
|
|
59
|
-
encoding:
|
|
60
|
-
body:
|
|
61
|
-
headers:
|
|
58
|
+
export const handlerSuccess = l.object({
|
|
59
|
+
encoding: l.string(),
|
|
60
|
+
body: l.unknown(),
|
|
61
|
+
headers: l.optional(headersSchema),
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
-
export type HandlerSuccess =
|
|
64
|
+
export type HandlerSuccess = l.Infer<typeof handlerSuccess>
|
|
65
65
|
|
|
66
|
-
export const handlerPipeThroughBuffer =
|
|
67
|
-
encoding:
|
|
68
|
-
buffer:
|
|
69
|
-
|
|
66
|
+
export const handlerPipeThroughBuffer = l.object({
|
|
67
|
+
encoding: l.string(),
|
|
68
|
+
buffer: l.custom(
|
|
69
|
+
(v): v is Buffer => v instanceof Buffer,
|
|
70
|
+
'Expected a Buffer',
|
|
71
|
+
),
|
|
72
|
+
headers: l.optional(headersSchema),
|
|
70
73
|
})
|
|
71
74
|
|
|
72
|
-
export type HandlerPipeThroughBuffer =
|
|
75
|
+
export type HandlerPipeThroughBuffer = l.Infer<typeof handlerPipeThroughBuffer>
|
|
73
76
|
|
|
74
|
-
export const handlerPipeThroughStream =
|
|
75
|
-
encoding:
|
|
76
|
-
stream:
|
|
77
|
-
|
|
77
|
+
export const handlerPipeThroughStream = l.object({
|
|
78
|
+
encoding: l.string(),
|
|
79
|
+
stream: l.custom(
|
|
80
|
+
(v): v is Readable => v instanceof Readable,
|
|
81
|
+
'Expected a Readable stream',
|
|
82
|
+
),
|
|
83
|
+
headers: l.optional(headersSchema),
|
|
78
84
|
})
|
|
79
85
|
|
|
80
|
-
export type HandlerPipeThroughStream =
|
|
86
|
+
export type HandlerPipeThroughStream = l.Infer<typeof handlerPipeThroughStream>
|
|
81
87
|
|
|
82
|
-
export const handlerPipeThrough =
|
|
88
|
+
export const handlerPipeThrough = l.union([
|
|
83
89
|
handlerPipeThroughBuffer,
|
|
84
90
|
handlerPipeThroughStream,
|
|
85
91
|
])
|
|
86
92
|
|
|
87
|
-
export type HandlerPipeThrough =
|
|
93
|
+
export type HandlerPipeThrough = l.Infer<typeof handlerPipeThrough>
|
|
88
94
|
|
|
89
95
|
export type Auth = void | AuthResult
|
|
90
96
|
export type Input = void | HandlerInput
|
|
91
|
-
export type Output = void | HandlerSuccess | ErrorResult
|
|
97
|
+
export type Output = void | HandlerSuccess | HandlerPipeThrough | ErrorResult
|
|
92
98
|
|
|
93
99
|
export type AuthVerifier<C, A extends AuthResult = AuthResult> =
|
|
94
100
|
| ((ctx: C) => Awaitable<A | ErrorResult>)
|
|
@@ -173,6 +179,19 @@ export type RouteOptions = {
|
|
|
173
179
|
textLimit?: number
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
export type MethodAuth<
|
|
183
|
+
A extends Auth = Auth,
|
|
184
|
+
P extends Params = Params,
|
|
185
|
+
> = MethodAuthVerifier<Extract<A, AuthResult>, P>
|
|
186
|
+
|
|
187
|
+
export type MethodRateLimit<
|
|
188
|
+
A extends Auth = Auth,
|
|
189
|
+
P extends Params = Params,
|
|
190
|
+
I extends Input = Input,
|
|
191
|
+
> =
|
|
192
|
+
| RateLimitOpts<HandlerContext<A, P, I>>
|
|
193
|
+
| RateLimitOpts<HandlerContext<A, P, I>>[]
|
|
194
|
+
|
|
176
195
|
export type MethodConfig<
|
|
177
196
|
A extends Auth = Auth,
|
|
178
197
|
P extends Params = Params,
|
|
@@ -180,11 +199,21 @@ export type MethodConfig<
|
|
|
180
199
|
O extends Output = Output,
|
|
181
200
|
> = {
|
|
182
201
|
handler: MethodHandler<A, P, I, O>
|
|
183
|
-
auth?:
|
|
202
|
+
auth?: MethodAuth<A, P>
|
|
203
|
+
opts?: RouteOptions
|
|
204
|
+
rateLimit?: MethodRateLimit<A, P, I>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type MethodConfigWithAuth<
|
|
208
|
+
A extends Auth = Auth,
|
|
209
|
+
P extends Params = Params,
|
|
210
|
+
I extends Input = Input,
|
|
211
|
+
O extends Output = Output,
|
|
212
|
+
> = {
|
|
213
|
+
handler: MethodHandler<A, P, I, O>
|
|
214
|
+
auth: MethodAuth<A, P>
|
|
184
215
|
opts?: RouteOptions
|
|
185
|
-
rateLimit?:
|
|
186
|
-
| RateLimitOpts<HandlerContext<A, P, I>>
|
|
187
|
-
| RateLimitOpts<HandlerContext<A, P, I>>[]
|
|
216
|
+
rateLimit?: MethodRateLimit<A, P, I>
|
|
188
217
|
}
|
|
189
218
|
|
|
190
219
|
export type MethodConfigOrHandler<
|
|
@@ -199,6 +228,43 @@ export type StreamAuthContext<P extends Params = Params> = {
|
|
|
199
228
|
req: IncomingMessage
|
|
200
229
|
}
|
|
201
230
|
|
|
231
|
+
export type LexMethodParams<M extends l.Procedure | l.Query | l.Subscription> =
|
|
232
|
+
l.InferMethodParams<M>
|
|
233
|
+
|
|
234
|
+
export type LexMethodInput<M extends l.Procedure | l.Query> =
|
|
235
|
+
l.InferMethodInput<M, Readable>
|
|
236
|
+
|
|
237
|
+
export type LexMethodOutput<M extends l.Procedure | l.Query> =
|
|
238
|
+
l.InferMethodOutput<M, Readable> extends undefined
|
|
239
|
+
? l.InferMethodOutput<M, Uint8Array | Readable> | void
|
|
240
|
+
: l.InferMethodOutput<M, Uint8Array | Readable>
|
|
241
|
+
|
|
242
|
+
export type LexMethodMessage<M extends l.Subscription> = l.InferMethodMessage<M>
|
|
243
|
+
|
|
244
|
+
export type LexMethodHandler<
|
|
245
|
+
M extends l.Procedure | l.Query,
|
|
246
|
+
A extends Auth = Auth,
|
|
247
|
+
> = MethodHandler<A, LexMethodParams<M>, LexMethodInput<M>, LexMethodOutput<M>>
|
|
248
|
+
|
|
249
|
+
export type LexMethodConfig<
|
|
250
|
+
M extends l.Procedure | l.Query,
|
|
251
|
+
A extends Auth = Auth,
|
|
252
|
+
> = MethodConfig<A, LexMethodParams<M>, LexMethodInput<M>, LexMethodOutput<M>>
|
|
253
|
+
|
|
254
|
+
export type LexSubscriptionHandler<
|
|
255
|
+
M extends l.Subscription,
|
|
256
|
+
A extends Auth = Auth,
|
|
257
|
+
> = StreamHandler<
|
|
258
|
+
Extract<A, AuthResult>,
|
|
259
|
+
LexMethodParams<M>,
|
|
260
|
+
LexMethodMessage<M>
|
|
261
|
+
>
|
|
262
|
+
|
|
263
|
+
export type LexSubscriptionConfig<
|
|
264
|
+
M extends l.Subscription,
|
|
265
|
+
A extends Auth = Auth,
|
|
266
|
+
> = StreamConfig<A, LexMethodParams<M>, LexMethodMessage<M>>
|
|
267
|
+
|
|
202
268
|
export type StreamAuthVerifier<
|
|
203
269
|
A extends AuthResult = AuthResult,
|
|
204
270
|
P extends Params = Params,
|
|
@@ -233,6 +299,21 @@ export type StreamConfigOrHandler<
|
|
|
233
299
|
O = unknown,
|
|
234
300
|
> = StreamHandler<A, P, O> | StreamConfig<A, P, O>
|
|
235
301
|
|
|
302
|
+
export function isHandlerSuccess(output: Output): output is HandlerSuccess {
|
|
303
|
+
// We only need to discriminate between possible Output values
|
|
304
|
+
return (
|
|
305
|
+
output != null &&
|
|
306
|
+
'body' in output && // body is non optional (contrary to what type inference may suggest)
|
|
307
|
+
'encoding' in output &&
|
|
308
|
+
// Allows using objects that extends HandlerSuccess with a "status" field as
|
|
309
|
+
// output, as long as the status is < 400, in order to avoid being confused
|
|
310
|
+
// with ErrorResult objects.
|
|
311
|
+
(!('status' in output) ||
|
|
312
|
+
output.status == null ||
|
|
313
|
+
Number(output.status) < 400)
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
236
317
|
export function isHandlerPipeThroughBuffer(
|
|
237
318
|
output: Output,
|
|
238
319
|
): output is HandlerPipeThroughBuffer {
|
package/src/util.ts
CHANGED
|
@@ -1,29 +1,61 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
import { IncomingMessage, OutgoingMessage } from 'node:http'
|
|
3
3
|
import { Duplex, Readable, pipeline } from 'node:stream'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Request as ExpressRequest,
|
|
6
|
+
Response as ExpressResponse,
|
|
7
|
+
json,
|
|
8
|
+
text,
|
|
9
|
+
} from 'express'
|
|
5
10
|
import { contentType } from 'mime-types'
|
|
6
11
|
import { MaxSizeChecker, createDecoders } from '@atproto/common'
|
|
12
|
+
import { jsonToLex } from '@atproto/lex-json'
|
|
13
|
+
import { l } from '@atproto/lex-schema'
|
|
7
14
|
import {
|
|
8
|
-
LexXrpcBody,
|
|
9
|
-
LexXrpcProcedure,
|
|
10
|
-
LexXrpcQuery,
|
|
11
|
-
LexXrpcSubscription,
|
|
15
|
+
type LexXrpcBody,
|
|
16
|
+
type LexXrpcProcedure,
|
|
17
|
+
type LexXrpcQuery,
|
|
18
|
+
type LexXrpcSubscription,
|
|
12
19
|
Lexicons,
|
|
13
|
-
jsonToLex,
|
|
20
|
+
jsonToLex as jsonToLexWithBlobRef,
|
|
14
21
|
} from '@atproto/lexicon'
|
|
15
22
|
import { ResponseType } from '@atproto/xrpc'
|
|
16
|
-
import { InternalServerError, InvalidRequestError, XRPCError } from './errors'
|
|
17
23
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
ErrorResult,
|
|
25
|
+
InternalServerError,
|
|
26
|
+
InvalidRequestError,
|
|
27
|
+
XRPCError,
|
|
28
|
+
} from './errors'
|
|
29
|
+
import {
|
|
30
|
+
Auth,
|
|
20
31
|
Input,
|
|
32
|
+
LexMethodInput,
|
|
33
|
+
LexMethodOutput,
|
|
34
|
+
LexMethodParams,
|
|
35
|
+
Output,
|
|
21
36
|
Params,
|
|
22
37
|
RouteOptions,
|
|
23
38
|
UndecodedParams,
|
|
24
39
|
handlerSuccess,
|
|
25
40
|
} from './types'
|
|
26
41
|
|
|
42
|
+
export type ParamsVerifierInternal<P extends Params = Params> = (
|
|
43
|
+
req: IncomingMessage | ExpressRequest,
|
|
44
|
+
) => P
|
|
45
|
+
|
|
46
|
+
export type AuthVerifierInternal<C, A extends Auth = Auth> = (
|
|
47
|
+
ctx: C,
|
|
48
|
+
) => Promise<Exclude<A, ErrorResult>>
|
|
49
|
+
|
|
50
|
+
export type InputVerifierInternal<I extends Input = Input> = (
|
|
51
|
+
req: ExpressRequest,
|
|
52
|
+
res: ExpressResponse,
|
|
53
|
+
) => Promise<I>
|
|
54
|
+
|
|
55
|
+
export type OutputVerifierInternal<O extends Output = Output> = (
|
|
56
|
+
handleOutput: O,
|
|
57
|
+
) => void
|
|
58
|
+
|
|
27
59
|
export const asArray = <T>(arr: T | T[]): T[] =>
|
|
28
60
|
Array.isArray(arr) ? arr : [arr]
|
|
29
61
|
|
|
@@ -38,7 +70,7 @@ export function setHeaders(
|
|
|
38
70
|
}
|
|
39
71
|
}
|
|
40
72
|
|
|
41
|
-
|
|
73
|
+
function decodeQueryParams(
|
|
42
74
|
def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription,
|
|
43
75
|
params: UndecodedParams,
|
|
44
76
|
): Params {
|
|
@@ -81,26 +113,37 @@ export function decodeQueryParam(
|
|
|
81
113
|
}
|
|
82
114
|
}
|
|
83
115
|
|
|
84
|
-
export
|
|
85
|
-
|
|
86
|
-
const result: QueryParams = Object.create(null)
|
|
116
|
+
export function getSearchParams(url?: string): URLSearchParams | undefined {
|
|
117
|
+
if (!url) return undefined
|
|
87
118
|
|
|
88
119
|
const queryStringIdx = url.indexOf('?')
|
|
89
|
-
if (queryStringIdx === -1) return
|
|
120
|
+
if (queryStringIdx === -1) return undefined
|
|
90
121
|
|
|
91
122
|
const queryString = url.slice(queryStringIdx + 1)
|
|
92
|
-
if (queryString ===
|
|
123
|
+
if (queryString.length === 0) return undefined
|
|
93
124
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
}
|
|
125
|
+
return new URLSearchParams(queryString)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getQueryParams(
|
|
129
|
+
req: IncomingMessage | ExpressRequest,
|
|
130
|
+
): UndecodedParams {
|
|
131
|
+
if ('query' in req) return req.query
|
|
103
132
|
|
|
133
|
+
const result: UndecodedParams = Object.create(null)
|
|
134
|
+
|
|
135
|
+
const searchParams = getSearchParams(req.url)
|
|
136
|
+
if (!searchParams) return result
|
|
137
|
+
|
|
138
|
+
if (searchParams.has('__proto__')) {
|
|
139
|
+
// Prevent prototype pollution
|
|
140
|
+
throw new InvalidRequestError(
|
|
141
|
+
`Invalid query parameter: __proto__`,
|
|
142
|
+
'InvalidQueryParameter',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const key of searchParams.keys()) {
|
|
104
147
|
const values = searchParams.getAll(key)
|
|
105
148
|
result[key] = values.length === 1 ? values[0] : values
|
|
106
149
|
}
|
|
@@ -108,14 +151,49 @@ export function getQueryParams(url = ''): QueryParams {
|
|
|
108
151
|
return result
|
|
109
152
|
}
|
|
110
153
|
|
|
111
|
-
export function
|
|
154
|
+
export function createLexiconParamsVerifier<P extends Params = Params>(
|
|
155
|
+
nsid: string,
|
|
156
|
+
def: LexXrpcQuery | LexXrpcProcedure | LexXrpcSubscription,
|
|
157
|
+
lexicons: Lexicons,
|
|
158
|
+
): ParamsVerifierInternal<P> {
|
|
159
|
+
return (req) => {
|
|
160
|
+
const queryParams = getQueryParams(req)
|
|
161
|
+
const params = decodeQueryParams(def, queryParams)
|
|
162
|
+
try {
|
|
163
|
+
return lexicons.assertValidXrpcParams(nsid, params) as P
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// @NOTE WE historically did not check for specific error types here,
|
|
166
|
+
throw new InvalidRequestError(String(e))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createSchemaParamsVerifier<
|
|
172
|
+
M extends l.Procedure | l.Query | l.Subscription,
|
|
173
|
+
>(ns: l.Main<M>): ParamsVerifierInternal<LexMethodParams<M>> {
|
|
174
|
+
const schema = l.getMain(ns)
|
|
175
|
+
return (req) => {
|
|
176
|
+
const urlSearchParams = getSearchParams(req.url) ?? new URLSearchParams()
|
|
177
|
+
try {
|
|
178
|
+
const params = schema.parameters.fromURLSearchParams(urlSearchParams)
|
|
179
|
+
return params as LexMethodParams<M>
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof l.LexValidationError) {
|
|
182
|
+
throw new InvalidRequestError(err.message)
|
|
183
|
+
}
|
|
184
|
+
throw err
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createLexiconInputVerifier<I extends Input = Input>(
|
|
112
190
|
nsid: string,
|
|
113
191
|
def: LexXrpcProcedure | LexXrpcQuery,
|
|
114
192
|
options: RouteOptions,
|
|
115
193
|
lexicons: Lexicons,
|
|
116
|
-
):
|
|
194
|
+
): InputVerifierInternal<I> {
|
|
117
195
|
if (def.type === 'query' || !def.input) {
|
|
118
|
-
return (req) => {
|
|
196
|
+
return async (req) => {
|
|
119
197
|
// @NOTE We allow (and ignore) "empty" bodies
|
|
120
198
|
if (getBodyPresence(req) === 'present') {
|
|
121
199
|
throw new InvalidRequestError(
|
|
@@ -123,7 +201,7 @@ export function createInputVerifier(
|
|
|
123
201
|
)
|
|
124
202
|
}
|
|
125
203
|
|
|
126
|
-
return undefined
|
|
204
|
+
return undefined as I
|
|
127
205
|
}
|
|
128
206
|
}
|
|
129
207
|
|
|
@@ -159,11 +237,13 @@ export function createInputVerifier(
|
|
|
159
237
|
|
|
160
238
|
if (input.schema) {
|
|
161
239
|
try {
|
|
162
|
-
const lexBody = req.body ?
|
|
240
|
+
const lexBody = req.body ? jsonToLexWithBlobRef(req.body) : req.body
|
|
163
241
|
req.body = lexicons.assertValidXrpcInput(nsid, lexBody)
|
|
164
|
-
} catch (
|
|
242
|
+
} catch (cause) {
|
|
165
243
|
throw new InvalidRequestError(
|
|
166
|
-
|
|
244
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
245
|
+
undefined,
|
|
246
|
+
{ cause },
|
|
167
247
|
)
|
|
168
248
|
}
|
|
169
249
|
}
|
|
@@ -172,54 +252,169 @@ export function createInputVerifier(
|
|
|
172
252
|
// otherwise, we pass along a decoded readable stream
|
|
173
253
|
const body = req.readableEnded ? req.body : decodeBodyStream(req, blobLimit)
|
|
174
254
|
|
|
175
|
-
return { encoding: reqEncoding, body }
|
|
255
|
+
return { encoding: reqEncoding, body } as I
|
|
176
256
|
}
|
|
177
257
|
}
|
|
178
258
|
|
|
179
|
-
export function
|
|
259
|
+
export function createSchemaInputVerifier<M extends l.Procedure | l.Query>(
|
|
260
|
+
ns: l.Main<M>,
|
|
261
|
+
options: RouteOptions,
|
|
262
|
+
): InputVerifierInternal<LexMethodInput<M>> {
|
|
263
|
+
const schema = l.getMain(ns)
|
|
264
|
+
const { blobLimit } = options
|
|
265
|
+
|
|
266
|
+
const input: l.Payload | undefined =
|
|
267
|
+
'input' in schema ? schema.input : undefined
|
|
268
|
+
|
|
269
|
+
if (!input?.encoding) {
|
|
270
|
+
//
|
|
271
|
+
return async (req) => {
|
|
272
|
+
if (getBodyPresence(req) === 'present') {
|
|
273
|
+
throw new InvalidRequestError(
|
|
274
|
+
`A request body was provided when none was expected`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return undefined as LexMethodInput<M>
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const bodyParser = createBodyParser(input.encoding, options)
|
|
283
|
+
|
|
284
|
+
return async (req, res) => {
|
|
285
|
+
if (getBodyPresence(req) === 'missing') {
|
|
286
|
+
throw new InvalidRequestError(
|
|
287
|
+
`A request body is expected but none was provided`,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const reqEncoding = parseReqEncoding(req)
|
|
292
|
+
if (!input.matchesEncoding(reqEncoding)) {
|
|
293
|
+
throw new InvalidRequestError(
|
|
294
|
+
`Wrong request encoding (Content-Type): ${reqEncoding}`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (bodyParser) {
|
|
299
|
+
await bodyParser(req, res)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (input.schema) {
|
|
303
|
+
try {
|
|
304
|
+
const lexBody = req.body ? jsonToLex(req.body) : req.body
|
|
305
|
+
req.body = input.schema.parse(lexBody)
|
|
306
|
+
} catch (cause) {
|
|
307
|
+
throw new InvalidRequestError(
|
|
308
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
309
|
+
undefined,
|
|
310
|
+
{ cause },
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// if middleware already got the body, we pass that along as input
|
|
316
|
+
// otherwise, we pass along a decoded readable stream
|
|
317
|
+
const body = req.readableEnded ? req.body : decodeBodyStream(req, blobLimit)
|
|
318
|
+
|
|
319
|
+
return { encoding: reqEncoding, body } as LexMethodInput<M>
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function createLexiconOutputVerifier<O extends Output = Output>(
|
|
180
324
|
nsid: string,
|
|
181
325
|
def: LexXrpcProcedure | LexXrpcQuery,
|
|
182
|
-
output: HandlerSuccess | void,
|
|
183
326
|
lexicons: Lexicons,
|
|
184
|
-
):
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
327
|
+
): OutputVerifierInternal<O> {
|
|
328
|
+
const outputDef = def.output
|
|
329
|
+
|
|
330
|
+
// Expects no output
|
|
331
|
+
if (!outputDef) {
|
|
332
|
+
return (handlerOutput) => {
|
|
333
|
+
if (handlerOutput !== undefined) {
|
|
334
|
+
throw new InternalServerError(
|
|
335
|
+
`A response body was provided when none was expected`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// An output is expected
|
|
342
|
+
return (handlerOutput) => {
|
|
343
|
+
if (handlerOutput === undefined) {
|
|
188
344
|
throw new InternalServerError(
|
|
189
345
|
`A response body is expected but none was provided`,
|
|
190
346
|
)
|
|
191
347
|
}
|
|
192
348
|
|
|
349
|
+
if (!('encoding' in handlerOutput)) {
|
|
350
|
+
// Ensure handlerOutput is valid ErrorResult
|
|
351
|
+
if ('status' in handlerOutput && handlerOutput.status >= 400) {
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
throw new InternalServerError(`Invalid handler output: missing encoding`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!('body' in handlerOutput)) {
|
|
359
|
+
// Ensure handlerOutput is valid HandlerPipeThrough
|
|
360
|
+
if ('stream' in handlerOutput || 'buffer' in handlerOutput) {
|
|
361
|
+
return // Validation is ignored for pipe-through outputs
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
throw new InternalServerError(`Invalid handler output: missing body`)
|
|
365
|
+
}
|
|
366
|
+
|
|
193
367
|
// Fool-proofing (should not be necessary due to type system)
|
|
194
|
-
const result = handlerSuccess.safeParse(
|
|
368
|
+
const result = handlerSuccess.safeParse(handlerOutput)
|
|
195
369
|
if (!result.success) {
|
|
196
370
|
throw new InternalServerError(`Invalid handler output`, undefined, {
|
|
197
|
-
cause: result.
|
|
371
|
+
cause: result.reason,
|
|
198
372
|
})
|
|
199
373
|
}
|
|
200
374
|
|
|
201
375
|
// output mime
|
|
202
|
-
const { encoding } =
|
|
203
|
-
if (!
|
|
376
|
+
const { encoding } = handlerOutput
|
|
377
|
+
if (!isValidEncoding(outputDef, encoding)) {
|
|
204
378
|
throw new InternalServerError(`Invalid response encoding: ${encoding}`)
|
|
205
379
|
}
|
|
206
380
|
|
|
207
381
|
// output schema
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
382
|
+
try {
|
|
383
|
+
lexicons.assertValidXrpcOutput(nsid, handlerOutput.body)
|
|
384
|
+
// @TODO Since the output verifier is typically enabled in dev/tests and
|
|
385
|
+
// disabled in production, we don't want to assign the (altered) output
|
|
386
|
+
// back to the handlerOutput object, as this would cause different
|
|
387
|
+
// behaviors between environments. Instead, we should compare the value
|
|
388
|
+
// returned by assertValidXrpcOutput with the original output and throw if
|
|
389
|
+
// they differ (indicating that the output was mutated during validation,
|
|
390
|
+
// e.g. due to default values being applied).
|
|
391
|
+
} catch (cause) {
|
|
392
|
+
const message =
|
|
393
|
+
cause instanceof Error ? cause.message : 'Output body validation failed'
|
|
394
|
+
throw new InternalServerError(message, undefined, { cause })
|
|
216
395
|
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function createSchemaOutputVerifier<M extends l.Procedure | l.Query>(
|
|
400
|
+
ns: l.Main<M>,
|
|
401
|
+
): OutputVerifierInternal<LexMethodOutput<M>> {
|
|
402
|
+
const outputSchema = l.getMain(ns).output
|
|
403
|
+
return (handlerOutput) => {
|
|
404
|
+
// @NOTE If the user of the lib wants to return an output that doesn't
|
|
405
|
+
// conform to the schema, they can use HandlerPipeThrough return types
|
|
406
|
+
if (!outputSchema.matchesEncoding(handlerOutput?.encoding)) {
|
|
407
|
+
throw new InternalServerError('Output encoding mismatch')
|
|
408
|
+
}
|
|
409
|
+
if (outputSchema.schema) {
|
|
410
|
+
const result = outputSchema.schema.safeValidate(handlerOutput?.body)
|
|
411
|
+
if (!result.success) {
|
|
412
|
+
throw new InternalServerError(result.reason.message, undefined, {
|
|
413
|
+
cause: result.reason,
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
} else if (!outputSchema.encoding && handlerOutput?.body !== undefined) {
|
|
417
|
+
throw new InternalServerError('Output body not expected')
|
|
223
418
|
}
|
|
224
419
|
}
|
|
225
420
|
}
|
|
@@ -251,12 +446,29 @@ function trimString(str: string): string {
|
|
|
251
446
|
return str.trim()
|
|
252
447
|
}
|
|
253
448
|
|
|
254
|
-
function isValidEncoding(output: LexXrpcBody, encoding
|
|
449
|
+
function isValidEncoding(output: LexXrpcBody, encoding?: string) {
|
|
450
|
+
if (!encoding) return false
|
|
451
|
+
|
|
255
452
|
const normalized = normalizeMime(encoding)
|
|
256
453
|
if (!normalized) return false
|
|
257
454
|
|
|
258
455
|
const allowed = parseDefEncoding(output)
|
|
259
|
-
|
|
456
|
+
if (!allowed.length) return false
|
|
457
|
+
|
|
458
|
+
if (allowed.includes(ENCODING_ANY)) return true
|
|
459
|
+
if (allowed.includes(normalized)) return true
|
|
460
|
+
|
|
461
|
+
// Check for wildcard matches (e.g. normalized=application/json, allowed=application/*)
|
|
462
|
+
for (const allowedEnc of allowed) {
|
|
463
|
+
if (
|
|
464
|
+
allowedEnc.endsWith('/*') &&
|
|
465
|
+
normalized.startsWith(allowedEnc.slice(0, -1))
|
|
466
|
+
) {
|
|
467
|
+
return true
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return false
|
|
260
472
|
}
|
|
261
473
|
|
|
262
474
|
type BodyPresence = 'missing' | 'empty' | 'present'
|
|
@@ -277,7 +489,7 @@ function createBodyParser(inputEncoding: string, options: RouteOptions) {
|
|
|
277
489
|
const jsonParser = json({ limit: jsonLimit })
|
|
278
490
|
const textParser = text({ limit: textLimit })
|
|
279
491
|
// Transform json and text parser middlewares into a single function
|
|
280
|
-
return (req:
|
|
492
|
+
return (req: ExpressRequest, res: ExpressResponse) => {
|
|
281
493
|
return new Promise<void>((resolve, reject) => {
|
|
282
494
|
jsonParser(req, res, (err) => {
|
|
283
495
|
if (err) return reject(XRPCError.fromError(err))
|
|
@@ -377,7 +589,7 @@ export interface ServerTiming {
|
|
|
377
589
|
description?: string
|
|
378
590
|
}
|
|
379
591
|
|
|
380
|
-
export const parseReqNsid = (req:
|
|
592
|
+
export const parseReqNsid = (req: ExpressRequest | IncomingMessage) =>
|
|
381
593
|
parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
|
|
382
594
|
|
|
383
595
|
/**
|