@atproto/xrpc-server 0.11.4 → 0.11.6

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/util.ts DELETED
@@ -1,708 +0,0 @@
1
- import assert from 'node:assert'
2
- import { IncomingMessage, OutgoingMessage } from 'node:http'
3
- import { Duplex, Readable, pipeline } from 'node:stream'
4
- import {
5
- Request as ExpressRequest,
6
- Response as ExpressResponse,
7
- json,
8
- text,
9
- } from 'express'
10
- // eslint-disable-next-line import/default, import/no-named-as-default-member
11
- import mimeTypes from 'mime-types'
12
- // eslint-disable-next-line import/no-named-as-default-member
13
- const { contentType } = mimeTypes
14
- import { MaxSizeChecker, createDecoders } from '@atproto/common'
15
- import { jsonToLex } from '@atproto/lex-json'
16
- import { l } from '@atproto/lex-schema'
17
- import {
18
- type LexXrpcBody,
19
- type LexXrpcProcedure,
20
- type LexXrpcQuery,
21
- type LexXrpcSubscription,
22
- Lexicons,
23
- jsonToLex as jsonToLexWithBlobRef,
24
- } from '@atproto/lexicon'
25
- import { ResponseType } from '@atproto/xrpc'
26
- import {
27
- ErrorResult,
28
- InternalServerError,
29
- InvalidRequestError,
30
- XRPCError,
31
- } from './errors.js'
32
- import {
33
- Auth,
34
- Input,
35
- LexMethodInput,
36
- LexMethodOutput,
37
- LexMethodParams,
38
- Output,
39
- Params,
40
- RouteOptions,
41
- UndecodedParams,
42
- handlerSuccess,
43
- } from './types.js'
44
-
45
- export type ParamsVerifierInternal<P extends Params = Params> = (
46
- req: IncomingMessage | ExpressRequest,
47
- ) => P
48
-
49
- export type AuthVerifierInternal<C, A extends Auth = Auth> = (
50
- ctx: C,
51
- ) => Promise<Exclude<A, ErrorResult>>
52
-
53
- export type InputVerifierInternal<I extends Input = Input> = (
54
- req: ExpressRequest,
55
- res: ExpressResponse,
56
- ) => Promise<I>
57
-
58
- export type OutputVerifierInternal<O extends Output = Output> = (
59
- handleOutput: O,
60
- ) => void
61
-
62
- export const asArray = <T>(arr: T | T[]): T[] =>
63
- Array.isArray(arr) ? arr : [arr]
64
-
65
- export function setHeaders(
66
- res: OutgoingMessage,
67
- headers?: Record<string, string | number>,
68
- ) {
69
- if (headers) {
70
- for (const [name, val] of Object.entries(headers)) {
71
- if (val != null) res.setHeader(name, val)
72
- }
73
- }
74
- }
75
-
76
- function decodeQueryParams(
77
- def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription,
78
- params: UndecodedParams,
79
- ): Params {
80
- const decoded: Params = {}
81
- for (const k in params) {
82
- const val = params[k]
83
- const property = def.parameters?.properties?.[k]
84
- if (property) {
85
- if (property.type === 'array') {
86
- const vals: (typeof val)[] = []
87
- decoded[k] = val
88
- ? vals
89
- .concat(val) // Cast to array
90
- .flatMap((v) => decodeQueryParam(property.items.type, v) ?? [])
91
- : undefined
92
- } else {
93
- decoded[k] = decodeQueryParam(property.type, val)
94
- }
95
- }
96
- }
97
- return decoded
98
- }
99
-
100
- export function decodeQueryParam(
101
- type: string,
102
- value: unknown,
103
- ): string | number | boolean | undefined {
104
- if (!value) {
105
- return undefined
106
- }
107
- if (type === 'string' || type === 'datetime') {
108
- return String(value)
109
- }
110
- if (type === 'float') {
111
- return Number(String(value))
112
- } else if (type === 'integer') {
113
- return parseInt(String(value), 10) || 0
114
- } else if (type === 'boolean') {
115
- return value === 'true'
116
- }
117
- }
118
-
119
- function getSearchParams(
120
- url?: string,
121
- opts?: { parseLoose?: boolean },
122
- ): URLSearchParams | undefined {
123
- if (!url) return undefined
124
-
125
- const queryStringIdx = url.indexOf('?')
126
- if (queryStringIdx === -1) return undefined
127
-
128
- const queryString = url.slice(queryStringIdx + 1)
129
- if (queryString.length === 0) return undefined
130
-
131
- const urlSearchParams = new URLSearchParams(queryString)
132
-
133
- if (opts?.parseLoose) {
134
- // @NOTE this is non-standard and should only be used for limited backwards-compatibility purposes.
135
- // Converts "foo[]=bar&foo[0]=baz" syntax into "foo=bar&foo=baz"
136
-
137
- // We cannot "delete()" while iterating. SO we'll first collect all keys
138
- // that need to be changed, then apply the changes after
139
- const toAppend = new URLSearchParams()
140
- const toDelete = new Set<string>()
141
-
142
- for (const [key, value] of urlSearchParams) {
143
- const match = key.endsWith(']') ? key.match(/^([^[]*)\[\d*\]$/) : null
144
- if (match) {
145
- toAppend.append(match[1], value)
146
- toDelete.add(key)
147
- }
148
- }
149
-
150
- for (const key of toDelete) {
151
- urlSearchParams.delete(key)
152
- }
153
-
154
- for (const [key, value] of toAppend) {
155
- urlSearchParams.append(key, value)
156
- }
157
- }
158
-
159
- return urlSearchParams
160
- }
161
-
162
- export function getQueryParams(
163
- req: IncomingMessage | ExpressRequest,
164
- opts?: { parseLoose?: boolean },
165
- ): UndecodedParams {
166
- if ('query' in req) return req.query
167
-
168
- const result: UndecodedParams = Object.create(null)
169
-
170
- const searchParams = getSearchParams(req.url, opts)
171
- if (!searchParams) return result
172
-
173
- if (searchParams.has('__proto__')) {
174
- // Prevent prototype pollution
175
- throw new InvalidRequestError(
176
- `Invalid query parameter: __proto__`,
177
- 'InvalidQueryParameter',
178
- )
179
- }
180
-
181
- for (const key of searchParams.keys()) {
182
- const values = searchParams.getAll(key)
183
- result[key] = values.length === 1 ? values[0] : values
184
- }
185
-
186
- return result
187
- }
188
-
189
- export function createLexiconParamsVerifier<P extends Params = Params>(
190
- nsid: string,
191
- def: LexXrpcQuery | LexXrpcProcedure | LexXrpcSubscription,
192
- lexicons: Lexicons,
193
- ): ParamsVerifierInternal<P> {
194
- return (req) => {
195
- const queryParams = getQueryParams(req)
196
- const params = decodeQueryParams(def, queryParams)
197
- try {
198
- return lexicons.assertValidXrpcParams(nsid, params) as P
199
- } catch (cause) {
200
- // @NOTE WE historically did not check for specific error types here,
201
- throw new InvalidRequestError(String(cause), undefined, { cause })
202
- }
203
- }
204
- }
205
-
206
- export function createSchemaParamsVerifier<
207
- M extends l.Procedure | l.Query | l.Subscription,
208
- >(
209
- ns: l.Main<M>,
210
- options?: RouteOptions,
211
- ): ParamsVerifierInternal<LexMethodParams<M>> {
212
- const schema = l.getMain(ns)
213
- const queryOpts = { parseLoose: options?.paramsParseLoose }
214
- return (req) => {
215
- const urlSearchParams =
216
- getSearchParams(req.url, queryOpts) ?? new URLSearchParams()
217
- try {
218
- const params = schema.parameters.fromURLSearchParams(urlSearchParams)
219
- return params as LexMethodParams<M>
220
- } catch (cause) {
221
- if (cause instanceof l.LexValidationError) {
222
- const message = `Invalid ${schema.nsid} params: ${cause.issues
223
- .map((issue) => issue.message)
224
- .join(', ')}`
225
- throw new InvalidRequestError(message, undefined, { cause })
226
- }
227
- throw cause
228
- }
229
- }
230
- }
231
-
232
- export function createLexiconInputVerifier<I extends Input = Input>(
233
- nsid: string,
234
- def: LexXrpcProcedure | LexXrpcQuery,
235
- options: RouteOptions,
236
- lexicons: Lexicons,
237
- ): InputVerifierInternal<I> {
238
- if (def.type === 'query' || !def.input) {
239
- return async (req) => {
240
- // @NOTE We allow (and ignore) "empty" bodies
241
- if (getBodyPresence(req) === 'present') {
242
- throw new InvalidRequestError(
243
- `A request body was provided when none was expected`,
244
- )
245
- }
246
-
247
- return undefined as I
248
- }
249
- }
250
-
251
- // Lexicon definition expects a request body
252
-
253
- const { input } = def
254
- const { blobLimit } = options
255
-
256
- const allowedEncodings = parseDefEncoding(input)
257
- const checkEncoding = allowedEncodings.includes(ENCODING_ANY)
258
- ? undefined // No need to check
259
- : (encoding: string) => allowedEncodings.includes(encoding)
260
-
261
- const bodyParser = createBodyParser(input.encoding, options)
262
-
263
- return async (req, res) => {
264
- if (getBodyPresence(req) === 'missing') {
265
- throw new InvalidRequestError(
266
- `A request body is expected but none was provided`,
267
- )
268
- }
269
-
270
- const reqEncoding = parseReqEncoding(req)
271
- if (checkEncoding && !checkEncoding(reqEncoding)) {
272
- throw new InvalidRequestError(
273
- `Wrong request encoding (Content-Type): ${reqEncoding}`,
274
- )
275
- }
276
-
277
- if (bodyParser) {
278
- await bodyParser(req, res)
279
- }
280
-
281
- if (input.schema) {
282
- try {
283
- const lexBody = req.body ? jsonToLexWithBlobRef(req.body) : req.body
284
- req.body = lexicons.assertValidXrpcInput(nsid, lexBody)
285
- } catch (cause) {
286
- throw new InvalidRequestError(
287
- cause instanceof Error ? cause.message : String(cause),
288
- undefined,
289
- { cause },
290
- )
291
- }
292
- }
293
-
294
- // if middleware already got the body, we pass that along as input
295
- // otherwise, we pass along a decoded readable stream
296
- const body = req.readableEnded ? req.body : decodeBodyStream(req, blobLimit)
297
-
298
- return { encoding: reqEncoding, body } as I
299
- }
300
- }
301
-
302
- export function createSchemaInputVerifier<M extends l.Procedure | l.Query>(
303
- ns: l.Main<M>,
304
- options: RouteOptions,
305
- ): InputVerifierInternal<LexMethodInput<M>> {
306
- const schema = l.getMain(ns)
307
- const { blobLimit } = options
308
-
309
- const input: l.Payload | undefined =
310
- 'input' in schema ? schema.input : undefined
311
-
312
- if (!input?.encoding) {
313
- //
314
- return async (req) => {
315
- if (getBodyPresence(req) === 'present') {
316
- // @NOTE we *could* also discard the body here instead of throwing an error
317
- throw new InvalidRequestError(
318
- `A request body was provided when none was expected`,
319
- )
320
- }
321
-
322
- return undefined as LexMethodInput<M>
323
- }
324
- }
325
-
326
- const bodyParser = createBodyParser(input.encoding, options)
327
-
328
- return async (req, res) => {
329
- if (getBodyPresence(req) === 'missing') {
330
- throw new InvalidRequestError(
331
- `A request body is expected but none was provided`,
332
- )
333
- }
334
-
335
- const reqEncoding = parseReqEncoding(req)
336
- if (!input.matchesEncoding(reqEncoding)) {
337
- throw new InvalidRequestError(
338
- `Wrong request encoding (Content-Type): ${reqEncoding}`,
339
- )
340
- }
341
-
342
- if (bodyParser) {
343
- await bodyParser(req, res)
344
- }
345
-
346
- if (input.schema) {
347
- try {
348
- const lexBody = req.body ? jsonToLex(req.body) : req.body
349
- req.body = input.schema.parse(lexBody)
350
- } catch (cause) {
351
- throw new InvalidRequestError(
352
- cause instanceof Error ? cause.message : String(cause),
353
- undefined,
354
- { cause },
355
- )
356
- }
357
- }
358
-
359
- // if middleware already got the body, we pass that along as input
360
- // otherwise, we pass along a decoded readable stream
361
- const body = req.readableEnded ? req.body : decodeBodyStream(req, blobLimit)
362
-
363
- return { encoding: reqEncoding, body } as LexMethodInput<M>
364
- }
365
- }
366
-
367
- export function createLexiconOutputVerifier<O extends Output = Output>(
368
- nsid: string,
369
- def: LexXrpcProcedure | LexXrpcQuery,
370
- lexicons: Lexicons,
371
- ): OutputVerifierInternal<O> {
372
- const outputDef = def.output
373
-
374
- // Expects no output
375
- if (!outputDef) {
376
- return (handlerOutput) => {
377
- if (handlerOutput !== undefined) {
378
- throw new InternalServerError(
379
- `A response body was provided when none was expected`,
380
- )
381
- }
382
- }
383
- }
384
-
385
- // An output is expected
386
- return (handlerOutput) => {
387
- if (handlerOutput === undefined) {
388
- throw new InternalServerError(
389
- `A response body is expected but none was provided`,
390
- )
391
- }
392
-
393
- if (!('encoding' in handlerOutput)) {
394
- // Ensure handlerOutput is valid ErrorResult
395
- if ('status' in handlerOutput && handlerOutput.status >= 400) {
396
- return
397
- }
398
-
399
- throw new InternalServerError(`Invalid handler output: missing encoding`)
400
- }
401
-
402
- if (!('body' in handlerOutput)) {
403
- // Ensure handlerOutput is valid HandlerPipeThrough
404
- if ('stream' in handlerOutput || 'buffer' in handlerOutput) {
405
- return // Validation is ignored for pipe-through outputs
406
- }
407
-
408
- throw new InternalServerError(`Invalid handler output: missing body`)
409
- }
410
-
411
- // Fool-proofing (should not be necessary due to type system)
412
- const result = handlerSuccess.safeParse(handlerOutput)
413
- if (!result.success) {
414
- throw new InternalServerError(`Invalid handler output`, undefined, {
415
- cause: result.reason,
416
- })
417
- }
418
-
419
- // output mime
420
- const { encoding } = handlerOutput
421
- if (!isValidEncoding(outputDef, encoding)) {
422
- throw new InternalServerError(`Invalid response encoding: ${encoding}`)
423
- }
424
-
425
- // output schema
426
- try {
427
- lexicons.assertValidXrpcOutput(nsid, handlerOutput.body)
428
- // @TODO Since the output verifier is typically enabled in dev/tests and
429
- // disabled in production, we don't want to assign the (altered) output
430
- // back to the handlerOutput object, as this would cause different
431
- // behaviors between environments. Instead, we should compare the value
432
- // returned by assertValidXrpcOutput with the original output and throw if
433
- // they differ (indicating that the output was mutated during validation,
434
- // e.g. due to default values being applied).
435
- } catch (cause) {
436
- const message =
437
- cause instanceof Error ? cause.message : 'Output body validation failed'
438
- throw new InternalServerError(message, undefined, { cause })
439
- }
440
- }
441
- }
442
-
443
- export function createSchemaOutputVerifier<M extends l.Procedure | l.Query>(
444
- ns: l.Main<M>,
445
- ): OutputVerifierInternal<LexMethodOutput<M>> {
446
- const outputSchema = l.getMain(ns).output
447
- return (handlerOutput) => {
448
- // @NOTE If the user of the lib wants to return an output that doesn't
449
- // conform to the schema, they can use HandlerPipeThrough return types
450
- if (!outputSchema.matchesEncoding(handlerOutput?.encoding)) {
451
- throw new InternalServerError('Output encoding mismatch')
452
- }
453
- if (outputSchema.schema) {
454
- const result = outputSchema.schema.safeValidate(handlerOutput?.body)
455
- if (!result.success) {
456
- throw new InternalServerError(result.reason.message, undefined, {
457
- cause: result.reason,
458
- })
459
- }
460
- } else if (!outputSchema.encoding && handlerOutput?.body !== undefined) {
461
- throw new InternalServerError('Output body not expected')
462
- }
463
- }
464
- }
465
-
466
- export function parseReqEncoding(req: IncomingMessage): string {
467
- const encoding = normalizeMime(req.headers['content-type'])
468
- if (encoding) return encoding
469
- throw new InvalidRequestError(
470
- `Request encoding (Content-Type) required but not provided`,
471
- )
472
- }
473
-
474
- function normalizeMime(v?: string): string | null {
475
- if (!v) return null
476
- const fullType = contentType(v)
477
- if (!fullType) return null
478
- const shortType = fullType.split(';')[0]
479
- if (!shortType) return null
480
- return shortType
481
- }
482
-
483
- const ENCODING_ANY = '*/*'
484
-
485
- function parseDefEncoding({ encoding }: LexXrpcBody) {
486
- return encoding.split(',').map(trimString)
487
- }
488
-
489
- function trimString(str: string): string {
490
- return str.trim()
491
- }
492
-
493
- function isValidEncoding(output: LexXrpcBody, encoding?: string) {
494
- if (!encoding) return false
495
-
496
- const normalized = normalizeMime(encoding)
497
- if (!normalized) return false
498
-
499
- const allowed = parseDefEncoding(output)
500
- if (!allowed.length) return false
501
-
502
- if (allowed.includes(ENCODING_ANY)) return true
503
- if (allowed.includes(normalized)) return true
504
-
505
- // Check for wildcard matches (e.g. normalized=application/json, allowed=application/*)
506
- for (const allowedEnc of allowed) {
507
- if (
508
- allowedEnc.endsWith('/*') &&
509
- normalized.startsWith(allowedEnc.slice(0, -1))
510
- ) {
511
- return true
512
- }
513
- }
514
-
515
- return false
516
- }
517
-
518
- type BodyPresence = 'missing' | 'empty' | 'present'
519
-
520
- function getBodyPresence(req: IncomingMessage): BodyPresence {
521
- if (req.headers['transfer-encoding'] != null) return 'present'
522
- if (req.headers['content-length'] === '0') return 'empty'
523
- if (req.headers['content-length'] != null) return 'present'
524
- return 'missing'
525
- }
526
-
527
- function createBodyParser(inputEncoding: string, options: RouteOptions) {
528
- if (inputEncoding === ENCODING_ANY) {
529
- // When the lexicon's input encoding is */*, the handler will determine how to process it
530
- return
531
- }
532
- const { jsonLimit, textLimit } = options
533
- const jsonParser = json({ limit: jsonLimit })
534
- const textParser = text({ limit: textLimit })
535
- // Transform json and text parser middlewares into a single function
536
- return (req: ExpressRequest, res: ExpressResponse) => {
537
- return new Promise<void>((resolve, reject) => {
538
- jsonParser(req, res, (err) => {
539
- if (err) return reject(XRPCError.fromError(err))
540
- textParser(req, res, (err) => {
541
- if (err) return reject(XRPCError.fromError(err))
542
- resolve()
543
- })
544
- })
545
- })
546
- }
547
- }
548
-
549
- function decodeBodyStream(
550
- req: IncomingMessage,
551
- maxSize: number | undefined,
552
- ): Readable {
553
- const contentEncoding = req.headers['content-encoding']
554
- const contentLength = req.headers['content-length']
555
-
556
- const contentLengthParsed = contentLength
557
- ? parseInt(contentLength, 10)
558
- : undefined
559
-
560
- if (Number.isNaN(contentLengthParsed)) {
561
- throw new XRPCError(ResponseType.InvalidRequest, 'invalid content-length')
562
- }
563
-
564
- if (
565
- maxSize !== undefined &&
566
- contentLengthParsed !== undefined &&
567
- contentLengthParsed > maxSize
568
- ) {
569
- throw new XRPCError(
570
- ResponseType.PayloadTooLarge,
571
- 'request entity too large',
572
- )
573
- }
574
-
575
- let transforms: Duplex[]
576
- try {
577
- transforms = createDecoders(contentEncoding)
578
- } catch (cause) {
579
- throw new XRPCError(
580
- ResponseType.UnsupportedMediaType,
581
- 'unsupported content-encoding',
582
- undefined,
583
- { cause },
584
- )
585
- }
586
-
587
- if (maxSize !== undefined) {
588
- const maxSizeChecker = new MaxSizeChecker(
589
- maxSize,
590
- () =>
591
- new XRPCError(ResponseType.PayloadTooLarge, 'request entity too large'),
592
- )
593
- transforms.push(maxSizeChecker)
594
- }
595
-
596
- return transforms.length > 0
597
- ? (pipeline([req, ...transforms], () => {}) as Duplex)
598
- : req
599
- }
600
-
601
- export function serverTimingHeader(timings: ServerTiming[]) {
602
- return timings
603
- .map((timing) => {
604
- let header = timing.name
605
- if (timing.duration) header += `;dur=${timing.duration}`
606
- if (timing.description) header += `;desc="${timing.description}"`
607
- return header
608
- })
609
- .join(', ')
610
- }
611
-
612
- export class ServerTimer implements ServerTiming {
613
- public duration?: number
614
- private startMs?: number
615
- constructor(
616
- public name: string,
617
- public description?: string,
618
- ) {}
619
- start() {
620
- this.startMs = Date.now()
621
- return this
622
- }
623
- stop() {
624
- assert(this.startMs, "timer hasn't been started")
625
- this.duration = Date.now() - this.startMs
626
- return this
627
- }
628
- }
629
-
630
- export interface ServerTiming {
631
- name: string
632
- duration?: number
633
- description?: string
634
- }
635
-
636
- export const parseReqNsid = (req: ExpressRequest | IncomingMessage) =>
637
- parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
638
-
639
- /**
640
- * Validates and extracts the nsid from an xrpc path
641
- */
642
- export const parseUrlNsid = (url: string): string => {
643
- const nsid = extractUrlNsid(url)
644
- if (nsid) return nsid
645
- throw new InvalidRequestError('invalid xrpc path')
646
- }
647
-
648
- export const extractUrlNsid = (url: string): string | undefined => {
649
- // /!\ Hot path
650
-
651
- if (
652
- // Ordered by likelihood of failure
653
- url.length <= 6 ||
654
- url[5] !== '/' ||
655
- url[4] !== 'c' ||
656
- url[3] !== 'p' ||
657
- url[2] !== 'r' ||
658
- url[1] !== 'x' ||
659
- url[0] !== '/'
660
- ) {
661
- return undefined
662
- }
663
-
664
- const startOfNsid = 6
665
-
666
- let curr = startOfNsid
667
- let char: number
668
- let alphaNumRequired = true
669
- for (; curr < url.length; curr++) {
670
- char = url.charCodeAt(curr)
671
- if (
672
- (char >= 48 && char <= 57) || // 0-9
673
- (char >= 65 && char <= 90) || // A-Z
674
- (char >= 97 && char <= 122) // a-z
675
- ) {
676
- alphaNumRequired = false
677
- } else if (char === 45 /* "-" */ || char === 46 /* "." */) {
678
- if (alphaNumRequired) {
679
- return undefined
680
- }
681
- alphaNumRequired = true
682
- } else if (char === 47 /* "/" */) {
683
- // Allow trailing slash (next char is either EOS or "?")
684
- if (curr === url.length - 1 || url.charCodeAt(curr + 1) === 63) {
685
- break
686
- }
687
- return undefined
688
- } else if (char === 63 /* "?"" */) {
689
- break
690
- } else {
691
- return undefined
692
- }
693
- }
694
-
695
- // last char was one of: '-', '.', '/'
696
- if (alphaNumRequired) {
697
- return undefined
698
- }
699
-
700
- // A domain name consists of minimum two characters
701
- if (curr - startOfNsid < 2) {
702
- return undefined
703
- }
704
-
705
- // @TODO check max length of nsid
706
-
707
- return url.slice(startOfNsid, curr)
708
- }