galbe 0.15.6 → 0.16.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/src/parser.ts CHANGED
@@ -16,11 +16,10 @@ import type {
16
16
  STArray,
17
17
  } from './schema'
18
18
 
19
- import { readableStreamToArrayBuffer } from 'bun'
20
19
  import { Kind, Optional, Stream } from './schema'
21
- import { validate } from './validator'
22
- import { InternalServerError, RequestError } from './index'
23
- import { isIterator, inferBodyType, type ParseMode } from './util'
20
+ import { runCompiled } from './validator.compile'
21
+ import { InternalServerError, PayloadTooLargeError, RequestError } from './index'
22
+ import { isIterator, inferBodyType, responseEntryFor, type ParseMode } from './util'
24
23
 
25
24
  const textDecoder = new TextDecoder()
26
25
  const textEncoder = new TextEncoder()
@@ -43,12 +42,58 @@ async function* rsToAsyncIterator(readableStream: ReadableStream) {
43
42
  }
44
43
  }
45
44
 
45
+ // req.bytes() is untyped and returns ArrayBuffer or Uint8Array depending on
46
+ // body chunking (Bun 1.3) — normalize through arrayBuffer. With a limit,
47
+ // accumulate manually so chunked clients lying about their size (no or forged
48
+ // content-length) are cut off as soon as they cross it.
49
+ const reqBytes = async (req: Request, limit?: number) => {
50
+ if (limit === undefined) return new Uint8Array(await req.arrayBuffer())
51
+ const body = req.body
52
+ if (body === null) return new Uint8Array()
53
+ const chunks: Uint8Array[] = []
54
+ let total = 0
55
+ for await (const chunk of body) {
56
+ total += chunk.length
57
+ if (total > limit) throw new PayloadTooLargeError()
58
+ chunks.push(chunk)
59
+ }
60
+ const res = new Uint8Array(total)
61
+ let offset = 0
62
+ for (const chunk of chunks) {
63
+ res.set(chunk, offset)
64
+ offset += chunk.length
65
+ }
66
+ return res
67
+ }
68
+ const reqText = async (req: Request, limit?: number) =>
69
+ limit === undefined ? req.text() : textDecoder.decode(await reqBytes(req, limit))
70
+ const reqJson = async (req: Request, limit?: number) =>
71
+ limit === undefined ? req.json() : JSON.parse(await reqText(req, limit))
72
+
73
+ // Multipart boundary extraction from the content-type header: parameters are
74
+ // `;`-separated and extra legal parameters (charset, …) must not leak into
75
+ // the boundary value.
76
+ const multipartBoundary = (contentType: string | null): string => {
77
+ for (const param of contentType?.split(';') ?? []) {
78
+ const eq = param.indexOf('=')
79
+ if (eq === -1 || param.slice(0, eq).trim().toLowerCase() !== 'boundary') continue
80
+ let value = param.slice(eq + 1).trim()
81
+ if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
82
+ if (value) return value
83
+ }
84
+ throw new RequestError({ status: 400, payload: { body: `Missing multipart boundary` } })
85
+ }
86
+
87
+ // `contentType` is the normalized media type (no parameters); the raw header is
88
+ // read from the request only where its parameters matter (multipart boundary),
89
+ // so body parsing never needs the materialized header map
46
90
  export const requestBodyParser = async (
47
- body: ReadableStream | null,
48
- headers: Record<string, string>,
91
+ req: Request,
49
92
  schemas?: STBody | STNull,
50
- contentType?: string
93
+ contentType?: string,
94
+ limit?: number
51
95
  ) => {
96
+ const body = req.body
52
97
  const normalizedCT = contentType?.split(';')[0]?.trim()
53
98
  let parseMode: ParseMode = inferBodyType(contentType)
54
99
  let schema: STBodyValue | STNull | undefined =
@@ -68,27 +113,28 @@ export const requestBodyParser = async (
68
113
  // No schema defined, we base parsing on parseMode only
69
114
  if (parseMode === 'byteArray') {
70
115
  if (body === null) return new Uint8Array()
71
- return new Uint8Array(await readableStreamToArrayBuffer(body))
116
+ return await reqBytes(req, limit)
72
117
  } else if (parseMode === 'json') {
73
118
  if (body === null) return null
74
119
  try {
75
- return JSON.parse(await streamToString(body))
120
+ return await reqJson(req, limit)
76
121
  } catch (err: any) {
122
+ if (err instanceof RequestError) throw err
77
123
  throw new RequestError({
78
124
  status: 400,
79
- payload: { body: err?.message ?? 'Parsing error' },
125
+ payload: { body: 'Not a valid JSON body' },
80
126
  })
81
127
  }
82
128
  } else if (parseMode === 'text') {
83
129
  if (body === null) return ''
84
- return streamToString(body)
130
+ return await reqText(req, limit)
85
131
  } else if (parseMode === 'urlForm') {
86
132
  if (body === null) return {}
87
- return await streamToUrlForm(body)
133
+ return parseUrlForm(await reqText(req, limit))
88
134
  } else if (parseMode === 'multipart') {
89
135
  if (body === null) return {}
90
- const boundary = headers?.['content-type']?.match(/boundary\="?([^"]*)"?;?.*$/)?.[1] || ''
91
- return await streamToMultipartForm(body, boundary)
136
+ const boundary = multipartBoundary(req.headers.get('content-type'))
137
+ return await streamToMultipartForm(oneChunkStream(await reqBytes(req, limit)), boundary)
92
138
  } else return body === null ? null : rsToAsyncIterator(body)
93
139
  } else {
94
140
  // Schemas found
@@ -110,7 +156,7 @@ export const requestBodyParser = async (
110
156
  : new Uint8Array()
111
157
  }
112
158
  if (isStream) return rsToAsyncIterator(body)
113
- return new Uint8Array(await readableStreamToArrayBuffer(body))
159
+ return await reqBytes(req, limit)
114
160
  } else if (parseMode === 'text') {
115
161
  if (!kind || !['string', 'boolean', 'number', 'integer', 'anyOf', 'oneOf', 'literal'].includes(kind))
116
162
  throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
@@ -122,40 +168,44 @@ export const requestBodyParser = async (
122
168
  controller.close()
123
169
  },
124
170
  })
125
- : validate('', schema as STSchema, { parse: true })
171
+ : runCompiled('', schema as STSchema, { parse: true })
126
172
  if (isStream) return $streamToString(body)
127
- if (kind === 'anyOf' || kind === 'oneOf') {
128
- let str = await streamToString(body)
129
- return unionize(str, schema as STUnion)
130
- }
131
- return await streamToString(body, schema as STBodyValue)
173
+ const str = await reqText(req, limit)
174
+ if (kind === 'anyOf' || kind === 'oneOf') return unionize(str, schema as STUnion)
175
+ return runCompiled(str, schema as STSchema, { parse: true })
132
176
  } else if (parseMode === 'json') {
133
177
  if (
134
178
  !kind ||
135
- !['object', 'json', 'boolean', 'number', 'integer', 'string', 'array', 'anyOf', 'oneOf', 'intersection'].includes(kind)
179
+ ![
180
+ 'object',
181
+ 'json',
182
+ 'boolean',
183
+ 'number',
184
+ 'integer',
185
+ 'string',
186
+ 'array',
187
+ 'anyOf',
188
+ 'oneOf',
189
+ 'intersection',
190
+ ].includes(kind)
136
191
  )
137
192
  throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
138
- if (kind === 'anyOf' || kind === 'oneOf') {
139
- let str = body === null ? 'null' : await streamToString(body)
140
- let json = JSON.parse(str)
141
- return unionize(json, schema as STUnion)
142
- }
143
- if (kind === 'intersection') {
144
- let str = body === null ? 'null' : await streamToString(body)
145
- let json = JSON.parse(str)
146
- return intersectionize(json, schema as STIntersection<any>)
147
- }
148
- const str = body === null ? 'null' : await streamToString(body)
193
+ // an absent body parses as `null`, matching JSON.parse('null')
194
+ if (kind === 'anyOf' || kind === 'oneOf')
195
+ return unionize(body === null ? null : await reqJson(req, limit), schema as STUnion)
196
+ if (kind === 'intersection')
197
+ return intersectionize(body === null ? null : await reqJson(req, limit), schema as STIntersection<any>)
149
198
  let json
150
199
  try {
151
- json = JSON.parse(str)
200
+ json = body === null ? null : await reqJson(req, limit)
152
201
  } catch (err: any) {
202
+ if (err instanceof RequestError) throw err
153
203
  throw new RequestError({
154
204
  status: 400,
155
- payload: { body: err?.message ?? 'Parsing error' },
205
+ payload: { body: 'Not a valid JSON body' },
156
206
  })
157
207
  }
158
- return validate(json, schema as STSchema, { parse: true })
208
+ return runCompiled(json, schema as STSchema, { parse: true })
159
209
  } else if (parseMode === 'urlForm') {
160
210
  if (!kind || !['object', 'anyOf', 'oneOf'].includes(kind))
161
211
  throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
@@ -167,21 +217,11 @@ export const requestBodyParser = async (
167
217
  controller.close()
168
218
  },
169
219
  })
170
- : await streamToUrlForm(
171
- new ReadableStream({
172
- start(controller) {
173
- controller.enqueue(new Uint8Array())
174
- controller.close()
175
- },
176
- }),
177
- schema as STObject
178
- )
179
- if (kind === 'anyOf' || kind === 'oneOf') {
180
- const b = await streamToUrlForm(body)
181
- return unionize(b, schema as STUnion)
182
- }
220
+ : parseUrlForm('', schema as STObject)
221
+ if (kind === 'anyOf' || kind === 'oneOf')
222
+ return unionize(parseUrlForm(await reqText(req, limit)), schema as STUnion)
183
223
  if (isStream) return $streamToUrlForm(body, schema as STStream<STObject>)
184
- else return await streamToUrlForm(body, schema as STObject)
224
+ else return parseUrlForm(await reqText(req, limit), schema as STObject)
185
225
  } else if (parseMode === 'multipart') {
186
226
  if (kind !== 'multipartForm' && kind !== 'anyOf' && kind !== 'oneOf')
187
227
  throw new RequestError({ status: 400, payload: { body: `Not a valid body` } })
@@ -194,13 +234,18 @@ export const requestBodyParser = async (
194
234
  },
195
235
  })
196
236
  : {}
197
- const boundary = headers?.['content-type']?.match(/boundary\="?([^"]*)"?;?.*$/)?.[1] || ''
237
+ const boundary = multipartBoundary(req.headers.get('content-type'))
198
238
  if (kind === 'anyOf' || kind === 'oneOf') {
199
- let mp = await streamToMultipartForm(body, boundary)
239
+ let mp = await streamToMultipartForm(oneChunkStream(await reqBytes(req, limit)), boundary, undefined, limit)
200
240
  return unionize(mp, schema as STUnion)
201
241
  }
202
- if (isStream) return $streamToMultipartForm(body, boundary, schema as STStream<STMultipartForm>)
203
- return streamToMultipartForm(body, boundary, schema as STMultipartForm)
242
+ if (isStream) return $streamToMultipartForm(body, boundary, schema as STStream<STMultipartForm>, limit)
243
+ return streamToMultipartForm(
244
+ oneChunkStream(await reqBytes(req, limit)),
245
+ boundary,
246
+ schema as STMultipartForm,
247
+ limit
248
+ )
204
249
  } else if (parseMode === 'default') {
205
250
  throw new RequestError({ status: 400, payload: { body: `Not a valid content-type` } })
206
251
  }
@@ -218,12 +263,6 @@ async function* $streamToString(body: ReadableStream) {
218
263
  const tail = decoder.decode()
219
264
  if (tail) yield tail
220
265
  }
221
- const streamToString = async (body: ReadableStream, schema?: STBodyValue): Promise<any> => {
222
- let res = ''
223
- for await (const chunk of $streamToString(body)) res += chunk
224
- if (schema) return validate(res, schema, { parse: true })
225
- return res
226
- }
227
266
  async function* $streamToUrlForm(
228
267
  body: ReadableStream<Uint8Array>,
229
268
  schema?: STStream<STObject>
@@ -300,13 +339,20 @@ async function* $streamToUrlForm(
300
339
  payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` },
301
340
  })
302
341
  }
303
- const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STObject) => {
304
- let entries = []
342
+ // Buffered counterpart of $streamToUrlForm with the same pair semantics:
343
+ // segments split on `&`, key/value on the first `=`; bare tokens yield an
344
+ // empty key and are filtered out below.
345
+ const parseUrlForm = (text: string, schema?: STObject) => {
346
+ const entries: [string, any][] = text.split('&').map(seg => {
347
+ const eq = seg.indexOf('=')
348
+ return eq === -1
349
+ ? ['', decodeFormComponent(seg)]
350
+ : [decodeFormComponent(seg.slice(0, eq)), decodeFormComponent(seg.slice(eq + 1))]
351
+ })
305
352
  const required = Object.fromEntries(
306
353
  Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
307
354
  )
308
355
  let errors: Record<string, any> = Object.create(null)
309
- for await (const chunk of $streamToUrlForm(body)) entries.push(chunk)
310
356
  const object: Record<string, any> = Object.create(null)
311
357
  for (let e of entries.filter(([k]) => k)) {
312
358
  if (e[0] in object) {
@@ -339,9 +385,32 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STObje
339
385
  })
340
386
  return object
341
387
  }
342
- async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundary: string, schema?: STMultipartForm) {
388
+ // First index >= `from` where `needle` fully occurs in `hay`, -1 if none. The
389
+ // native indexOf skips to candidate positions (the needle's first byte) so only
390
+ // candidates are compared byte by byte, instead of every offset of the scan.
391
+ const indexOfSeq = (hay: Uint8Array, needle: Uint8Array, from: number) => {
392
+ if (!needle.length) return -1
393
+ const last = hay.length - needle.length
394
+ for (let i = hay.indexOf(needle[0]!, from); i !== -1 && i <= last; i = hay.indexOf(needle[0]!, i + 1)) {
395
+ let b = 1
396
+ while (b < needle.length && hay[i + b] === needle[b]) b++
397
+ if (b === needle.length) return i
398
+ }
399
+ return -1
400
+ }
401
+
402
+ async function* $streamToMultipartForm(
403
+ data: ReadableStream<Uint8Array>,
404
+ boundary: string,
405
+ schema?: STMultipartForm,
406
+ limit?: number
407
+ ) {
343
408
  const bound = textEncoder.encode(boundary)
344
409
  const delimiter = textEncoder.encode('\r\n\r\n')
410
+ // A match straddling two chunks is undetectable within a single chunk: carry
411
+ // the tail of the unprocessed bytes into each chunk's scan window so the
412
+ // lookahead never stops at the chunk seam.
413
+ const lookahead = Math.max(bound.length, delimiter.length) - 1
345
414
  let rest = new Uint8Array()
346
415
  let bK: Uint8Array = new Uint8Array()
347
416
  let bV: Uint8Array = new Uint8Array()
@@ -350,30 +419,25 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
350
419
  Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
351
420
  )
352
421
  for await (const chunk of data) {
422
+ const carry = rest.subarray(Math.max(0, rest.length - lookahead))
423
+ const scan = new Uint8Array(carry.length + chunk.length)
424
+ scan.set(carry)
425
+ scan.set(chunk, carry.length)
426
+ rest = rest.slice(0, rest.length - carry.length)
353
427
  start = 0
354
- for (let i = 0; i < chunk.length; i++) {
355
- let matchBound = true
356
- let matchDelimiter = true
357
- for (let b = 0; b < bound.length; b++) {
358
- if (chunk[i + b] === bound[b]) continue
359
- else {
360
- matchBound = false
361
- break
362
- }
363
- }
364
- if (!matchBound) {
365
- for (let b = 0; b < delimiter.length; b++) {
366
- if (chunk[i + b] === delimiter[b]) continue
367
- else {
368
- matchDelimiter = false
369
- break
370
- }
371
- }
372
- }
373
- if (matchBound) {
428
+ // jump from match to match: whichever of boundary/delimiter comes first
429
+ // (boundary wins a tie, as in the byte-wise scan this replaces)
430
+ for (let p = 0; p < scan.length;) {
431
+ const iB = indexOfSeq(scan, bound, p)
432
+ const iD = indexOfSeq(scan, delimiter, p)
433
+ if (iB === -1 && iD === -1) break
434
+ const isBound = iB !== -1 && (iD === -1 || iB <= iD)
435
+ const i = isBound ? iB : iD
436
+ if (isBound) {
374
437
  bV = new Uint8Array(rest.length + i - start)
375
438
  bV.set(rest)
376
- bV.set(chunk.slice(start, i), rest.length)
439
+ bV.set(scan.slice(start, i), rest.length)
440
+ if (limit !== undefined && bV.length > limit) throw new PayloadTooLargeError()
377
441
  bV = bV.slice(1, bV.length - 4)
378
442
  const headers = parseMultipartHeader(textDecoder.decode(bK))
379
443
  if (headers) {
@@ -391,22 +455,22 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
391
455
  bK = new Uint8Array()
392
456
  bV = new Uint8Array()
393
457
  start = i + bound.length
394
- rest = new Uint8Array()
395
- i = start
396
- } else if (matchDelimiter) {
458
+ } else {
397
459
  bK = new Uint8Array(rest.length + i - start)
398
460
  bK.set(rest)
399
- bK.set(chunk.slice(start, i), rest.length)
461
+ bK.set(scan.slice(start, i), rest.length)
400
462
  start = i + 3
401
- rest = new Uint8Array()
402
- i = start
403
- }
404
- if (i === chunk.length - 1) {
405
- const newRest = new Uint8Array(rest.length + i - start + 1)
406
- newRest.set(rest)
407
- newRest.set(chunk.slice(start, i + 1), rest.length)
408
- rest = newRest
409
463
  }
464
+ rest = new Uint8Array()
465
+ p = start
466
+ }
467
+ if (start < scan.length) {
468
+ const newRest = new Uint8Array(rest.length + scan.length - start)
469
+ newRest.set(rest)
470
+ newRest.set(scan.subarray(start), rest.length)
471
+ // rest accumulates the current part across chunks — cap its growth
472
+ if (limit !== undefined && newRest.length > limit) throw new PayloadTooLargeError()
473
+ rest = newRest
410
474
  }
411
475
  }
412
476
  const headers = parseMultipartHeader(textDecoder.decode(bK))
@@ -469,7 +533,7 @@ const parseMultipartContent = (
469
533
  try {
470
534
  result = JSON.parse(textDecoder.decode(content).trim())
471
535
  } catch (err: any) {
472
- throw new RequestError({ status: 400, payload: { body: { [headers.name]: err?.message || 'Parsing error' } } })
536
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: 'Not a valid JSON part' } } })
473
537
  }
474
538
  } else if (schema?.props) {
475
539
  const prop = schema.props[headers.name]!
@@ -479,17 +543,17 @@ const parseMultipartContent = (
479
543
  } catch (err: any) {
480
544
  throw new RequestError({
481
545
  status: 400,
482
- payload: { body: { [headers.name]: err?.message || 'Parsing error' } },
546
+ payload: { body: { [headers.name]: 'Not a valid JSON part' } },
483
547
  })
484
548
  }
485
549
  try {
486
- validate(result, prop)
550
+ runCompiled(result, prop)
487
551
  } catch (err) {
488
552
  throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
489
553
  }
490
554
  } else if (prop[Kind] === 'byteArray') {
491
555
  try {
492
- return validate(content, prop)
556
+ return runCompiled(content, prop)
493
557
  } catch (err) {
494
558
  throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
495
559
  }
@@ -506,7 +570,7 @@ const parseMultipartContent = (
506
570
  const s = getProp(schema?.props, headers.name)
507
571
  if (s) {
508
572
  try {
509
- validate(result, s[Kind] === 'array' ? s.items : s)
573
+ runCompiled(result, s[Kind] === 'array' ? s.items : s)
510
574
  } catch (err) {
511
575
  throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
512
576
  }
@@ -514,21 +578,34 @@ const parseMultipartContent = (
514
578
  }
515
579
  return result
516
580
  }
517
- const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary: string, schema?: STMultipartForm) => {
581
+ // Feeding the whole buffer as a single chunk keeps the streaming scanner's
582
+ // semantics while skipping per-network-chunk generator overhead.
583
+ const oneChunkStream = (buf: Uint8Array) =>
584
+ new ReadableStream<Uint8Array>({
585
+ start(controller) {
586
+ controller.enqueue(buf)
587
+ controller.close()
588
+ },
589
+ })
590
+ const streamToMultipartForm = async (
591
+ data: ReadableStream<Uint8Array>,
592
+ boundary: string,
593
+ schema?: STMultipartForm,
594
+ limit?: number
595
+ ) => {
518
596
  const res: Record<string, MultipartFormData> = Object.create(null)
519
597
  const errors: Record<string, any> = Object.create(null)
520
598
  const required = Object.fromEntries(
521
599
  Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
522
600
  )
523
- for await (const chunk of $streamToMultipartForm(data, boundary)) {
601
+ for await (const chunk of $streamToMultipartForm(data, boundary, undefined, limit)) {
524
602
  const name = chunk.headers.name
525
603
  if (name in res) {
526
604
  const existing = res[name]!
527
605
  if (!Array.isArray(existing.content)) existing.content = [existing.content]
528
606
  existing.content.push(chunk.content)
529
607
  } else {
530
- if (getProp(schema?.props, name)?.[Kind] === 'array')
531
- res[name] = { ...chunk, content: [chunk.content] }
608
+ if (getProp(schema?.props, name)?.[Kind] === 'array') res[name] = { ...chunk, content: [chunk.content] }
532
609
  else res[name] = chunk
533
610
  }
534
611
  delete required[name]
@@ -537,9 +614,8 @@ const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary:
537
614
  try {
538
615
  const prop = schema.props[name]!
539
616
  const entry = res[name]!
540
- if (Array.isArray(entry.content) && prop[Kind] !== 'array')
541
- throw `Multiple values found`
542
- entry.content = validate(entry.content, prop, {
617
+ if (Array.isArray(entry.content) && prop[Kind] !== 'array') throw `Multiple values found`
618
+ entry.content = runCompiled(entry.content, prop, {
543
619
  parse: true,
544
620
  })
545
621
  if (prop[Kind] === 'array')
@@ -585,7 +661,7 @@ const paramParser = (
585
661
  } else if (value === null) return null
586
662
  else if (Array.isArray(value)) {
587
663
  if (type[Kind] !== 'array') throw `Multiple values found`
588
- validate(value, type)
664
+ runCompiled(value, type)
589
665
  let pv = []
590
666
  let errors: Record<number, any> = {}
591
667
  for (let [idx, v] of value.entries()) {
@@ -607,16 +683,16 @@ const paramParser = (
607
683
  if (value === null || value === undefined || value === '') throw `Not a valid integer`
608
684
  const parsedValue = Number(value)
609
685
  if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue)) throw `Not a valid integer`
610
- validate(parsedValue, type)
686
+ runCompiled(parsedValue, type)
611
687
  return parsedValue
612
688
  } else if (type[Kind] === 'number') {
613
689
  if (value === null || value === undefined || value === '') throw `Not a valid number`
614
690
  const parsedValue = Number(value)
615
691
  if (!Number.isFinite(parsedValue)) throw `Not a valid number`
616
- validate(parsedValue, type)
692
+ runCompiled(parsedValue, type)
617
693
  return parsedValue
618
694
  } else if (type[Kind] === 'string') {
619
- validate(value, type)
695
+ runCompiled(value, type)
620
696
  return value
621
697
  } else if (type[Kind] === 'literal') {
622
698
  const lit = type as STLiteral
@@ -632,7 +708,7 @@ const paramParser = (
632
708
  } catch (e) {
633
709
  throw `Not a valid object`
634
710
  }
635
- return validate(json, type)
711
+ return runCompiled(json, type)
636
712
  } else if (type[Kind] === 'array') {
637
713
  return [paramParser(value, (type as STArray).items as STMultipartFormValues) as Static<STPropsValue>]
638
714
  } else if (type[Kind] === 'byteArray') {
@@ -688,6 +764,43 @@ export const requestPathParser = (input: string, path: string) => {
688
764
  return params
689
765
  }
690
766
 
767
+ /**
768
+ * OpenAPI's `deepObject`: `?filter[lat]=1&filter[lon]=2` is one object
769
+ * parameter. Gathers the bracketed keys belonging to `key` and coerces each
770
+ * value against the property schema governing it — `additionalProperties`
771
+ * included, so a `$T.record` query parameter works too. Returns undefined when
772
+ * the query carries no bracketed key for `key`, leaving the JSON-encoded
773
+ * spelling (`?filter={"lat":1}`) to `paramParser`.
774
+ */
775
+ const deepObjectParser = (params: { [key: string]: any }, key: string, type: STObject) => {
776
+ const prefix = `${key}[`
777
+ const out: Record<string, any> = {}
778
+ const errors: Record<string, any> = {}
779
+ let found = false
780
+ for (const k of Object.keys(params)) {
781
+ if (!k.startsWith(prefix) || !k.endsWith(']')) continue
782
+ // one level only: OpenAPI leaves nested deepObject undefined
783
+ const prop = k.slice(prefix.length, -1)
784
+ if (!prop || prop.includes('[') || prop.includes(']')) continue
785
+ found = true
786
+ const ps = (type.props?.[prop] ?? type.additionalProperties) as STMultipartFormValues | undefined
787
+ // an undeclared property is kept raw: `additionalProperties: false` rejects
788
+ // it downstream, an open object ignores it, and neither needs a guess here
789
+ if (!ps) {
790
+ out[prop] = params[k]
791
+ continue
792
+ }
793
+ try {
794
+ out[prop] = paramParser(params[k], ps)
795
+ } catch (error) {
796
+ errors[prop] = error
797
+ }
798
+ }
799
+ if (!found) return undefined
800
+ if (Object.keys(errors).length) throw errors
801
+ return out
802
+ }
803
+
691
804
  export const parseEntry = <T extends STProps>(
692
805
  params: { [key: string]: any },
693
806
  schema: T,
@@ -696,18 +809,38 @@ export const parseEntry = <T extends STProps>(
696
809
  const parsedParams: Partial<Static<STObject<T>>> = {}
697
810
  const errors: { [key: string]: string | string[] } = {}
698
811
 
699
- if (options?.i === true) {
700
- params = Object.keys(params).reduce((acc, key) => {
701
- acc[key.toLowerCase()] = params[key]
702
- return acc
703
- }, {} as { [key: string]: string | string[] })
812
+ // case-insensitive lookup (headers) without copying the whole map per request:
813
+ // the already-lowercased key usually hits, and the lowercased index — null
814
+ // prototype, the keys are untrusted — is only built when it does not
815
+ let lowercased: Record<string, any> | undefined
816
+ const lookup = (key: string) => {
817
+ const v = params[key]
818
+ if (v !== undefined || options?.i !== true) return v
819
+ if (!lowercased) {
820
+ const index: Record<string, any> = Object.create(null)
821
+ for (const k of Object.keys(params)) index[k.toLowerCase()] = params[k]
822
+ lowercased = index
823
+ }
824
+ return lowercased[key]
704
825
  }
705
826
 
706
827
  Object.entries(schema).forEach(([key, s]) => {
707
828
  const k = options?.i === true ? key.toLowerCase() : key
708
- let v = params[k]
709
- if (s[Kind] === 'array' && options?.name === 'query' && typeof v === 'string') v = v.split(',')
829
+ let v = lookup(k)
830
+ if (s[Kind] === 'array' && options?.name === 'query' && typeof v === 'string') {
831
+ // a single value may carry several items; repeated keys always may too
832
+ const delimiter = (s as unknown as STArray).split
833
+ if (delimiter !== false) v = v.split(delimiter || ',')
834
+ }
710
835
  try {
836
+ if (options?.name === 'query' && s[Kind] === 'object' && v === undefined) {
837
+ const deep = deepObjectParser(params, k, s as unknown as STObject)
838
+ if (deep !== undefined) {
839
+ //@ts-ignore
840
+ parsedParams[k] = runCompiled(deep, s as unknown as STSchema)
841
+ return
842
+ }
843
+ }
711
844
  let p = paramParser(v, s as STMultipartFormValues)
712
845
  //@ts-ignore
713
846
  if (p !== undefined) parsedParams[k] = p
@@ -753,10 +886,9 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
753
886
  }
754
887
  for (const cookie of cookies) existing.append('set-cookie', cookie)
755
888
  return response
756
- }
757
- else if (typeof response === 'string') {
889
+ } else if (typeof response === 'string') {
758
890
  if (!details?.headers?.has('content-type')) {
759
- const statusEntry: any = schema?.[details.status]
891
+ const statusEntry: any = responseEntryFor(schema as Partial<Record<string | number, any>>, details.status)
760
892
  const isJson = statusEntry?.[Kind]
761
893
  ? statusEntry[Kind] === 'json'
762
894
  : statusEntry?.['application/json'] && !statusEntry?.['text/plain']
@@ -779,16 +911,27 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
779
911
  const rs = new ReadableStream({
780
912
  type: 'direct',
781
913
  async pull(controller) {
782
- let id = ctx.request.headers.get('last-event-id') ?? crypto.randomUUID()
914
+ // ids only need to be unique within the stream: one random prefix per
915
+ // connection plus a counter, instead of a CSPRNG call per event
916
+ const prefix = crypto.randomUUID()
917
+ let n = 0
918
+ let id = ctx.request.headers.get('last-event-id') ?? `${prefix}:${n}`
783
919
  for await (const r of response) {
784
- let data = `id:${id}\ndata:${r}\n\n`
920
+ // multi-line values must be split into one data: field per line (SSE spec)
921
+ let data =
922
+ `id:${id}\n` +
923
+ String(r)
924
+ .split(/\r\n|\r|\n/)
925
+ .map(l => `data:${l}`)
926
+ .join('\n') +
927
+ '\n\n'
785
928
  try {
786
929
  await controller.write(data)
787
930
  await controller.flush()
788
931
  } catch (err) {
789
932
  console.error(err)
790
933
  }
791
- id = crypto.randomUUID()
934
+ id = `${prefix}:${++n}`
792
935
  }
793
936
  controller.close()
794
937
  },
@@ -810,13 +953,16 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
810
953
  const unionize = (b: any, schema: STUnion) => {
811
954
  let res
812
955
  let error
813
- const discriminants = schema.members.reduce((acc, obj) => {
814
- const props = (obj as STObject).props
815
- return acc.filter(k => props && k in props && props[k]?.[Kind] === 'literal' && !props[k]?.[Optional])
816
- }, Object.keys((schema.members[0] as STObject)?.props || {}))
956
+ const discriminants = schema.members.reduce(
957
+ (acc, obj) => {
958
+ const props = (obj as STObject).props
959
+ return acc.filter(k => props && k in props && props[k]?.[Kind] === 'literal' && !props[k]?.[Optional])
960
+ },
961
+ Object.keys((schema.members[0] as STObject)?.props || {})
962
+ )
817
963
  for (let s of schema.members) {
818
964
  try {
819
- res = validate(b, s, { parse: true })
965
+ res = runCompiled(b, s, { parse: true })
820
966
  if (res !== undefined) break
821
967
  } catch (err: any) {
822
968
  if (discriminants.every(d => !err?.[d]?.startsWith('Not a valid value'))) error = err
@@ -830,7 +976,7 @@ const unionize = (b: any, schema: STUnion) => {
830
976
  const intersectionize = (b: any, schema: STIntersection<any>) => {
831
977
  let res
832
978
  try {
833
- for (let s of schema.allOf) res = validate(b, s, { parse: true })
979
+ for (let s of schema.allOf) res = runCompiled(b, s, { parse: true })
834
980
  return res
835
981
  } catch (e) {
836
982
  throw new RequestError({ status: 400, payload: { body: `No matching body schema found` } })