@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/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 { z } from 'zod'
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 = z.record(z.string())
54
+ export const headersSchema = l.dict(l.string(), l.string())
55
55
 
56
- export type Headers = z.infer<typeof headersSchema>
56
+ export type Headers = l.Infer<typeof headersSchema>
57
57
 
58
- export const handlerSuccess = z.object({
59
- encoding: z.string(),
60
- body: z.any(),
61
- headers: headersSchema.optional(),
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 = z.infer<typeof handlerSuccess>
64
+ export type HandlerSuccess = l.Infer<typeof handlerSuccess>
65
65
 
66
- export const handlerPipeThroughBuffer = z.object({
67
- encoding: z.string(),
68
- buffer: z.instanceof(Buffer),
69
- headers: headersSchema.optional(),
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 = z.infer<typeof handlerPipeThroughBuffer>
75
+ export type HandlerPipeThroughBuffer = l.Infer<typeof handlerPipeThroughBuffer>
73
76
 
74
- export const handlerPipeThroughStream = z.object({
75
- encoding: z.string(),
76
- stream: z.instanceof(Readable),
77
- headers: headersSchema.optional(),
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 = z.infer<typeof handlerPipeThroughStream>
86
+ export type HandlerPipeThroughStream = l.Infer<typeof handlerPipeThroughStream>
81
87
 
82
- export const handlerPipeThrough = z.union([
88
+ export const handlerPipeThrough = l.union([
83
89
  handlerPipeThroughBuffer,
84
90
  handlerPipeThroughStream,
85
91
  ])
86
92
 
87
- export type HandlerPipeThrough = z.infer<typeof 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?: MethodAuthVerifier<Extract<A, AuthResult>, P>
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 { Request, Response, json, text } from 'express'
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
- Awaitable,
19
- HandlerSuccess,
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
- export function decodeQueryParams(
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 type QueryParams = Record<string, undefined | string | string[]>
85
- export function getQueryParams(url = ''): QueryParams {
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 result
120
+ if (queryStringIdx === -1) return undefined
90
121
 
91
122
  const queryString = url.slice(queryStringIdx + 1)
92
- if (queryString === '') return result
123
+ if (queryString.length === 0) return undefined
93
124
 
94
- const searchParams = new URLSearchParams(queryString)
95
- for (const key of searchParams.keys()) {
96
- if (key === '__proto__') {
97
- // Prevent prototype pollution
98
- throw new InvalidRequestError(
99
- `Invalid query parameter: ${key}`,
100
- 'InvalidQueryParameter',
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 createInputVerifier(
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
- ): (req: Request, res: Response) => Awaitable<Input> {
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 ? jsonToLex(req.body) : req.body
240
+ const lexBody = req.body ? jsonToLexWithBlobRef(req.body) : req.body
163
241
  req.body = lexicons.assertValidXrpcInput(nsid, lexBody)
164
- } catch (e) {
242
+ } catch (cause) {
165
243
  throw new InvalidRequestError(
166
- e instanceof Error ? e.message : String(e),
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 validateOutput(
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
- ): void {
185
- if (def.output) {
186
- // An output is expected
187
- if (output === undefined) {
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(output)
368
+ const result = handlerSuccess.safeParse(handlerOutput)
195
369
  if (!result.success) {
196
370
  throw new InternalServerError(`Invalid handler output`, undefined, {
197
- cause: result.error,
371
+ cause: result.reason,
198
372
  })
199
373
  }
200
374
 
201
375
  // output mime
202
- const { encoding } = output
203
- if (!encoding || !isValidEncoding(def.output, encoding)) {
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
- if (def.output.schema) {
209
- try {
210
- output.body = lexicons.assertValidXrpcOutput(nsid, output.body)
211
- } catch (e) {
212
- throw new InternalServerError(
213
- e instanceof Error ? e.message : String(e),
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
- } else {
218
- // Expects no output
219
- if (output !== undefined) {
220
- throw new InternalServerError(
221
- `A response body was provided when none was expected`,
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: string) {
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
- return allowed.includes(ENCODING_ANY) || allowed.includes(normalized)
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: Request, res: Response) => {
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: Request | IncomingMessage) =>
592
+ export const parseReqNsid = (req: ExpressRequest | IncomingMessage) =>
381
593
  parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
382
594
 
383
595
  /**