ingenium 0.0.1

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,21 @@
1
+ import { Transform, type TransformCallback } from 'node:stream'
2
+ import { IngeniumPayloadTooLargeError } from '../errors.ts'
3
+
4
+ /**
5
+ * A `Transform` stream that aborts with `IngeniumPayloadTooLargeError` as soon as
6
+ * cumulative throughput exceeds `maxBytes`. The check happens before the
7
+ * chunk is emitted downstream, so consumers never see bytes past the limit.
8
+ */
9
+ export function createByteLimit(maxBytes: number): Transform {
10
+ let total = 0
11
+ return new Transform({
12
+ transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback) {
13
+ total += chunk.length
14
+ if (total > maxBytes) {
15
+ callback(new IngeniumPayloadTooLargeError(`Request body exceeded ${maxBytes} bytes`))
16
+ return
17
+ }
18
+ callback(null, chunk)
19
+ },
20
+ })
21
+ }
@@ -0,0 +1,30 @@
1
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
2
+
3
+ /**
4
+ * Express compatibility shim. In Ingenium, body parsing is lazy via
5
+ * `ctx.body.json()` / `ctx.body.urlencoded()` / `ctx.body.text()` — there is
6
+ * no parse-on-every-request middleware to register. This factory exists so
7
+ * existing `app.use(express.json())` migration patterns keep compiling and
8
+ * reading naturally; the returned middleware is a zero-cost no-op.
9
+ *
10
+ * If you need to enforce a default `maxBytes` across all body access, set it
11
+ * via the `limit` option here and read it inside your handlers when calling
12
+ * `ctx.body.json({ limit })` — Ingenium doesn't store it implicitly.
13
+ *
14
+ * @returns a no-op middleware
15
+ */
16
+ export function jsonMiddleware(_opts?: { limit?: number }): IngeniumMiddleware {
17
+ return async (_ctx, next) => {
18
+ await next()
19
+ }
20
+ }
21
+
22
+ /**
23
+ * See `jsonMiddleware` — same rationale. URL-encoded parsing is lazy via
24
+ * `ctx.body.urlencoded()`.
25
+ */
26
+ export function urlencodedMiddleware(_opts?: { limit?: number }): IngeniumMiddleware {
27
+ return async (_ctx, next) => {
28
+ await next()
29
+ }
30
+ }
@@ -0,0 +1,40 @@
1
+ import type { Buffer } from 'node:buffer'
2
+
3
+ /**
4
+ * Options for `IngeniumBody.multipart()`.
5
+ *
6
+ * All limits are validated mid-parse — exceeding any of them throws before
7
+ * the full body is fully decoded so memory usage stays bounded.
8
+ */
9
+ export interface MultipartOptions {
10
+ /** Total request body cap. Default 100,000 bytes (matches Express's body-parser default). */
11
+ maxBytes?: number
12
+ /** Per-file size cap. Default 10 * 1024 * 1024 (10 MiB). */
13
+ maxFileSize?: number
14
+ /** Maximum number of file parts in one request. Default 20. */
15
+ maxFiles?: number
16
+ /** Maximum number of plain (non-file) field parts. Default 100. */
17
+ maxFields?: number
18
+ /** Allowed MIME prefixes (e.g. ['image/']). File rejected if no prefix matches. Default: any. */
19
+ allowedMimePrefixes?: string[]
20
+ }
21
+
22
+ /** A single uploaded file part, fully buffered. */
23
+ export interface MultipartFile {
24
+ /** Original filename as supplied by the client. */
25
+ filename: string
26
+ /** MIME type from the part's `Content-Type` header (defaults to `application/octet-stream`). */
27
+ mimeType: string
28
+ /** Byte length of `data`. */
29
+ size: number
30
+ /** Raw file bytes — fully buffered. For very large uploads, prefer `ctx.body.stream()`. */
31
+ data: Buffer
32
+ }
33
+
34
+ /** Result of parsing a `multipart/form-data` body. */
35
+ export interface MultipartResult {
36
+ /** Plain-text form fields keyed by name. Repeated names collapse into an array. */
37
+ fields: Record<string, string | string[]>
38
+ /** File parts keyed by name. Repeated names collapse into an array. */
39
+ files: Record<string, MultipartFile | MultipartFile[]>
40
+ }
@@ -0,0 +1,254 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { IngeniumBadRequestError, IngeniumPayloadTooLargeError } from '../errors.ts'
3
+ import type { MultipartFile, MultipartOptions, MultipartResult } from './multipart-types.ts'
4
+
5
+ /** Default per-file cap: 10 MiB. */
6
+ const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024
7
+ /** Default file-count cap. */
8
+ const DEFAULT_MAX_FILES = 20
9
+ /** Default field-count cap. */
10
+ const DEFAULT_MAX_FIELDS = 100
11
+
12
+ const CRLF = Buffer.from('\r\n')
13
+ const DOUBLE_CRLF = Buffer.from('\r\n\r\n')
14
+ const DASH_DASH = Buffer.from('--')
15
+
16
+ /**
17
+ * Extract the `boundary` parameter from a `Content-Type` header.
18
+ * Per RFC 7578 / RFC 2046 the boundary is required and case-insensitive
19
+ * parameter name; the value may be quoted.
20
+ */
21
+ function extractBoundary(contentType: string | undefined): string {
22
+ if (!contentType) {
23
+ throw new IngeniumBadRequestError('Content-Type header missing')
24
+ }
25
+ // The mime type itself is case-insensitive.
26
+ const lower = contentType.toLowerCase()
27
+ if (!lower.startsWith('multipart/form-data')) {
28
+ throw new IngeniumBadRequestError('Content-Type is not multipart/form-data')
29
+ }
30
+ // Walk parameters: split on `;` but only after the type. We don't bother with
31
+ // RFC 2231 continuations — boundaries are restricted to a 70-char ASCII subset.
32
+ const params = contentType.slice(contentType.indexOf(';') + 1).split(';')
33
+ for (const raw of params) {
34
+ const eq = raw.indexOf('=')
35
+ if (eq === -1) continue
36
+ const name = raw.slice(0, eq).trim().toLowerCase()
37
+ if (name !== 'boundary') continue
38
+ let value = raw.slice(eq + 1).trim()
39
+ if (value.startsWith('"') && value.endsWith('"')) {
40
+ value = value.slice(1, -1)
41
+ }
42
+ if (value.length === 0) {
43
+ throw new IngeniumBadRequestError('multipart boundary is empty')
44
+ }
45
+ return value
46
+ }
47
+ throw new IngeniumBadRequestError('multipart boundary missing')
48
+ }
49
+
50
+ interface PartHeaders {
51
+ /** form-data field name (`Content-Disposition: ...; name="..."`). */
52
+ name: string
53
+ /** filename, if present. Presence marks the part as a file. */
54
+ filename: string | undefined
55
+ /** Content-Type header, if present. */
56
+ contentType: string | undefined
57
+ }
58
+
59
+ /**
60
+ * Parse the headers of a single part from a header-block string (already
61
+ * split off at the `\r\n\r\n` boundary). Header names are case-insensitive.
62
+ */
63
+ function parsePartHeaders(block: string): PartHeaders {
64
+ const lines = block.split('\r\n')
65
+ let name: string | undefined
66
+ let filename: string | undefined
67
+ let contentType: string | undefined
68
+
69
+ for (const line of lines) {
70
+ if (line.length === 0) continue
71
+ const colon = line.indexOf(':')
72
+ if (colon === -1) {
73
+ throw new IngeniumBadRequestError('Malformed multipart body: invalid header line')
74
+ }
75
+ const headerName = line.slice(0, colon).trim().toLowerCase()
76
+ const headerValue = line.slice(colon + 1).trim()
77
+
78
+ if (headerName === 'content-disposition') {
79
+ // form-data; name="x"; filename="y"
80
+ const params = headerValue.split(';')
81
+ // First token is the disposition (`form-data`); we only accept that.
82
+ const disposition = params[0]?.trim().toLowerCase()
83
+ if (disposition !== 'form-data') {
84
+ throw new IngeniumBadRequestError('Malformed multipart body: unsupported Content-Disposition')
85
+ }
86
+ for (let i = 1; i < params.length; i++) {
87
+ const p = params[i]!
88
+ const eq = p.indexOf('=')
89
+ if (eq === -1) continue
90
+ const k = p.slice(0, eq).trim().toLowerCase()
91
+ let v = p.slice(eq + 1).trim()
92
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1)
93
+ // Decode common escapes (\" and \\) — RFC 7578 references RFC 2183.
94
+ v = v.replace(/\\(.)/g, '$1')
95
+ if (k === 'name') name = v
96
+ else if (k === 'filename') filename = v
97
+ }
98
+ } else if (headerName === 'content-type') {
99
+ contentType = headerValue
100
+ }
101
+ // Other headers (Content-Transfer-Encoding etc.) are ignored.
102
+ }
103
+
104
+ if (name === undefined) {
105
+ throw new IngeniumBadRequestError('Malformed multipart body: missing form-data name')
106
+ }
107
+ return { name, filename, contentType }
108
+ }
109
+
110
+ /**
111
+ * Stash a parsed field into `fields` / `files`, collapsing repeated names
112
+ * into arrays in arrival order. Mixing field+file under one name follows
113
+ * arrival order too (rare; not specifically supported).
114
+ */
115
+ function appendField(
116
+ result: MultipartResult,
117
+ headers: PartHeaders,
118
+ body: Buffer,
119
+ ): void {
120
+ if (headers.filename !== undefined) {
121
+ const file: MultipartFile = {
122
+ filename: headers.filename,
123
+ mimeType: headers.contentType ?? 'application/octet-stream',
124
+ size: body.length,
125
+ data: body,
126
+ }
127
+ const existing = result.files[headers.name]
128
+ if (existing === undefined) {
129
+ result.files[headers.name] = file
130
+ } else if (Array.isArray(existing)) {
131
+ existing.push(file)
132
+ } else {
133
+ result.files[headers.name] = [existing, file]
134
+ }
135
+ } else {
136
+ const value = body.toString('utf8')
137
+ const existing = result.fields[headers.name]
138
+ if (existing === undefined) {
139
+ result.fields[headers.name] = value
140
+ } else if (Array.isArray(existing)) {
141
+ existing.push(value)
142
+ } else {
143
+ result.fields[headers.name] = [existing, value]
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Parse a `multipart/form-data` request body. Operates on raw bytes — boundary
150
+ * sequences can legally appear inside binary file payloads, so we never
151
+ * convert the payload to a string before splitting.
152
+ *
153
+ * @param buffer Full body bytes (already buffered & length-checked by caller).
154
+ * @param contentType Raw `Content-Type` header — boundary is extracted from it.
155
+ * @param opts Limits and filters.
156
+ */
157
+ export function parseMultipart(
158
+ buffer: Buffer,
159
+ contentType: string | undefined,
160
+ opts: MultipartOptions = {},
161
+ ): MultipartResult {
162
+ const maxFileSize = opts.maxFileSize ?? DEFAULT_MAX_FILE_SIZE
163
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES
164
+ const maxFields = opts.maxFields ?? DEFAULT_MAX_FIELDS
165
+ const allowed = opts.allowedMimePrefixes
166
+
167
+ const boundary = extractBoundary(contentType)
168
+ const result: MultipartResult = { fields: {}, files: {} }
169
+
170
+ // Empty body → empty result. (No boundary delimiter at all.)
171
+ if (buffer.length === 0) return result
172
+
173
+ // RFC 7578: each part is preceded by `--<boundary>`. The first occurrence
174
+ // may not be at byte 0 (a "preamble" is permitted but must be ignored).
175
+ const dashBoundary = Buffer.concat([DASH_DASH, Buffer.from(boundary)])
176
+
177
+ let cursor = buffer.indexOf(dashBoundary)
178
+ if (cursor === -1) {
179
+ throw new IngeniumBadRequestError('Malformed multipart body: opening boundary not found')
180
+ }
181
+ cursor += dashBoundary.length
182
+
183
+ let fileCount = 0
184
+ let fieldCount = 0
185
+
186
+ // Loop over parts. After each `--<boundary>` we expect either:
187
+ // `--` → final close delimiter (end of stream)
188
+ // `\r\n` → start of a part (headers follow)
189
+ // anything else is malformed.
190
+ for (;;) {
191
+ if (cursor + 2 > buffer.length) {
192
+ throw new IngeniumBadRequestError('Malformed multipart body: truncated after boundary')
193
+ }
194
+ // Final delimiter: `--<boundary>--`
195
+ if (buffer[cursor] === 0x2d && buffer[cursor + 1] === 0x2d) {
196
+ // Close delimiter — done. We deliberately accept any trailing epilogue.
197
+ return result
198
+ }
199
+ // Otherwise expect CRLF before headers.
200
+ if (buffer[cursor] !== 0x0d || buffer[cursor + 1] !== 0x0a) {
201
+ throw new IngeniumBadRequestError('Malformed multipart body: expected CRLF after boundary')
202
+ }
203
+ cursor += 2
204
+
205
+ // Header block ends at the first `\r\n\r\n`.
206
+ const headerEnd = buffer.indexOf(DOUBLE_CRLF, cursor)
207
+ if (headerEnd === -1) {
208
+ throw new IngeniumBadRequestError('Malformed multipart body: missing header terminator')
209
+ }
210
+ const headerBlock = buffer.slice(cursor, headerEnd).toString('utf8')
211
+ const headers = parsePartHeaders(headerBlock)
212
+ cursor = headerEnd + DOUBLE_CRLF.length
213
+
214
+ // Body bytes run until the next `\r\n--<boundary>`. Boundaries may appear
215
+ // inside binary payloads so we MUST scan bytes, not strings.
216
+ const delimiter = Buffer.concat([CRLF, dashBoundary])
217
+ const partEnd = buffer.indexOf(delimiter, cursor)
218
+ if (partEnd === -1) {
219
+ throw new IngeniumBadRequestError('Malformed multipart body: missing closing boundary')
220
+ }
221
+
222
+ const partBody = buffer.slice(cursor, partEnd)
223
+
224
+ if (headers.filename !== undefined) {
225
+ // File part — apply size + count + mime checks.
226
+ if (partBody.length > maxFileSize) {
227
+ throw new IngeniumPayloadTooLargeError(
228
+ `File "${headers.filename}" exceeded ${maxFileSize} bytes`,
229
+ )
230
+ }
231
+ if (allowed && allowed.length > 0) {
232
+ const mime = (headers.contentType ?? 'application/octet-stream').toLowerCase()
233
+ const ok = allowed.some((prefix) => mime.startsWith(prefix.toLowerCase()))
234
+ if (!ok) {
235
+ throw new IngeniumBadRequestError('Disallowed mime type')
236
+ }
237
+ }
238
+ fileCount++
239
+ if (fileCount > maxFiles) {
240
+ throw new IngeniumBadRequestError('Too many files')
241
+ }
242
+ } else {
243
+ fieldCount++
244
+ if (fieldCount > maxFields) {
245
+ throw new IngeniumBadRequestError('Too many fields')
246
+ }
247
+ }
248
+
249
+ appendField(result, headers, partBody)
250
+
251
+ cursor = partEnd + delimiter.length
252
+ // Next iteration will check for `--` (close) or `\r\n` (next part).
253
+ }
254
+ }
@@ -0,0 +1,324 @@
1
+ import type { Readable } from 'node:stream'
2
+ import { Buffer } from 'node:buffer'
3
+ import { IngeniumBadRequestError, IngeniumPayloadTooLargeError, IngeniumValidationError } from '../errors.ts'
4
+ import { createByteLimit } from '../body/limit.ts'
5
+ import { parseMultipart } from '../body/multipart.ts'
6
+ import type { MultipartOptions, MultipartResult } from '../body/multipart-types.ts'
7
+ import {
8
+ isStandardSchema,
9
+ type StandardIssue,
10
+ type StandardSchemaV1,
11
+ } from '../schema/standard.ts'
12
+
13
+ /** Minimal duck-type for any validation library that accepts unknown and returns a typed value. */
14
+ export interface ParseSchema<T> {
15
+ parse(input: unknown): T
16
+ }
17
+
18
+ /** Optional Zod-like schema: success/failure object output (used internally for friendlier errors). */
19
+ export interface SafeParseSchema<T> {
20
+ safeParse(input: unknown): { success: true; data: T } | { success: false; error: { issues: ZodLikeIssue[] } }
21
+ }
22
+
23
+ interface ZodLikeIssue {
24
+ path: ReadonlyArray<string | number>
25
+ message: string
26
+ }
27
+
28
+ /** Normalize a Standard Schema issue path into a dot-joined field key. */
29
+ function standardPathToField(path: StandardIssue['path']): string {
30
+ if (!path || path.length === 0) return '_'
31
+ const parts: string[] = []
32
+ for (const seg of path) {
33
+ if (seg !== null && typeof seg === 'object' && 'key' in seg) {
34
+ parts.push(String(seg.key))
35
+ } else {
36
+ parts.push(String(seg))
37
+ }
38
+ }
39
+ return parts.join('.') || '_'
40
+ }
41
+
42
+ /**
43
+ * Default body size limit for `IngeniumBody.json/text/urlencoded/buffer`.
44
+ * 100,000 bytes matches Express's `body-parser` default (`'100kb'`),
45
+ * which is the convention every Express app implicitly relies on. Override
46
+ * per-call (`ctx.body.json(undefined, 5_000_000)`) or set a different
47
+ * default by configuring your `ingenium.json({ limit })` middleware (the
48
+ * middleware is currently a stub — see `body/middleware.ts`).
49
+ */
50
+ const DEFAULT_MAX_BYTES = 100_000
51
+
52
+ /**
53
+ * Lazy body accessor. Bytes are not read until one of the consume methods
54
+ * (`json`, `text`, `urlencoded`, `buffer`, `stream`) is called.
55
+ *
56
+ * One instance is allocated per `IngeniumContext` (pool-bound), so per-request
57
+ * cost is just a `reset()`.
58
+ */
59
+ export class IngeniumBody {
60
+ /** @internal */ _source: Readable | null = null
61
+ /** @internal */ _consumed = false
62
+ /** @internal */ _contentType: string | undefined = undefined
63
+ /** @internal */ _contentLength: number | undefined = undefined
64
+ /**
65
+ * @internal
66
+ * Parse cache. Stores the raw body bytes after the first successful
67
+ * `buffer()` (or `text()` / `json()` / `urlencoded()`, which all go
68
+ * through `buffer()`). Subsequent buffer-producing consumers reuse
69
+ * these bytes instead of throwing "already consumed".
70
+ *
71
+ * Caches the RAW Buffer (not parsed objects) so different callers can
72
+ * apply different schemas / decoders against the same bytes — a
73
+ * common pattern when an audit middleware reads the body before the
74
+ * handler does. Re-parsing JSON from a cached buffer is cheap; mixing
75
+ * schemas against a cached parsed object would be incorrect.
76
+ *
77
+ * `stream()` opts out (it hands the caller ownership of the raw
78
+ * Readable) and `multipart()` opts out (its result is bespoke and
79
+ * re-parsing with different options would be ambiguous).
80
+ *
81
+ * Checked with `!== null` rather than truthiness so an empty body
82
+ * (`Buffer.alloc(0)`) still hits the cache on subsequent reads.
83
+ */
84
+ /** @internal */ _cached: Buffer | null = null
85
+
86
+ /** @internal Adapter calls this on each request before dispatch. */
87
+ _attach(source: Readable | null, contentType: string | undefined, contentLength: number | undefined): void {
88
+ this._source = source
89
+ this._consumed = false
90
+ this._contentType = contentType
91
+ this._contentLength = contentLength
92
+ this._cached = null
93
+ }
94
+
95
+ /** @internal Pool reset. */
96
+ _reset(): void {
97
+ this._source = null
98
+ this._consumed = false
99
+ this._contentType = undefined
100
+ this._contentLength = undefined
101
+ this._cached = null
102
+ }
103
+
104
+ /**
105
+ * Returns the raw request body stream. Throws if already consumed OR
106
+ * if the body has already been buffered (cached) — once we hold the
107
+ * bytes, we can't hand the caller back a fresh Readable to own.
108
+ */
109
+ stream(): Readable {
110
+ if (this._cached !== null) throw new IngeniumBadRequestError('Request body already consumed')
111
+ if (this._consumed) throw new IngeniumBadRequestError('Request body already consumed')
112
+ if (!this._source) throw new IngeniumBadRequestError('Request has no body')
113
+ this._consumed = true
114
+ return this._source
115
+ }
116
+
117
+ /**
118
+ * Buffers the entire body into a `Buffer`. Honors `maxBytes` (default 100KB).
119
+ *
120
+ * If the body has already been buffered once (by any prior `buffer()`,
121
+ * `text()`, `json()`, or `urlencoded()` call), returns the cached bytes
122
+ * — `maxBytes` is still enforced against `cached.length`, so a caller
123
+ * passing a tighter cap than the original still gets a 413.
124
+ */
125
+ async buffer(maxBytes: number = DEFAULT_MAX_BYTES): Promise<Buffer> {
126
+ // Cached path: reuse bytes, but honor the caller's ceiling.
127
+ if (this._cached !== null) {
128
+ if (this._cached.length > maxBytes) {
129
+ throw new IngeniumPayloadTooLargeError(
130
+ `Request body exceeded ${maxBytes} bytes`,
131
+ )
132
+ }
133
+ return this._cached
134
+ }
135
+ if (this._consumed) throw new IngeniumBadRequestError('Request body already consumed')
136
+ if (!this._source) {
137
+ // Empty body — cache the empty buffer so subsequent consumers
138
+ // also hit the cached path (consistent semantics).
139
+ this._cached = Buffer.alloc(0)
140
+ return this._cached
141
+ }
142
+ this._consumed = true
143
+
144
+ const cl = this._contentLength
145
+ if (cl !== undefined && cl > maxBytes) {
146
+ // Drain source so the connection can be reused.
147
+ this._source.resume()
148
+ throw new IngeniumPayloadTooLargeError(
149
+ `Request body exceeded ${maxBytes} bytes`,
150
+ )
151
+ }
152
+
153
+ const source = this._source
154
+
155
+ // Fast path: Content-Length is known and within cap. Pre-allocate exactly
156
+ // one Buffer and copy chunks directly into it — eliminates the chunks[]
157
+ // array, the per-chunk push, and the final Buffer.concat copy. Same
158
+ // observable behavior, ~2-3 fewer allocations per request, and a single
159
+ // contiguous write instead of a copy-then-walk.
160
+ //
161
+ // The transport-layer Transform may already be installed; that's fine —
162
+ // it just no-ops for in-bounds bodies. We do not install a second one
163
+ // here when we know the length (the transport already enforced).
164
+ if (cl !== undefined && cl >= 0) {
165
+ const buf = Buffer.allocUnsafe(cl)
166
+ let offset = 0
167
+ return new Promise<Buffer>((resolve, reject) => {
168
+ source.on('data', (chunk: Buffer) => {
169
+ // Defensive: clamp to declared length so a misbehaving stream
170
+ // can't write past our pre-allocated buffer. (`node:http` itself
171
+ // already enforces Content-Length, so this is belt + suspenders.)
172
+ const remaining = cl - offset
173
+ if (remaining <= 0) return
174
+ const n = chunk.length <= remaining ? chunk.length : remaining
175
+ chunk.copy(buf, offset, 0, n)
176
+ offset += n
177
+ })
178
+ source.on('end', () => {
179
+ // If the client truncated, return the partial buffer (matches the
180
+ // chunks-then-concat path's behavior pre-optimization).
181
+ const result = offset === cl ? buf : buf.subarray(0, offset)
182
+ // Cache before resolving so a follow-up consumer that awaits this
183
+ // same promise can read `_cached` immediately after.
184
+ this._cached = result
185
+ resolve(result)
186
+ })
187
+ source.on('error', reject)
188
+ })
189
+ }
190
+
191
+ // Unknown length (chunked encoding, no Content-Length) — fall back to
192
+ // the chunks + Buffer.concat path with the byte-limit Transform on top.
193
+ const limited = source.pipe(createByteLimit(maxBytes))
194
+ const chunks: Buffer[] = []
195
+ return new Promise<Buffer>((resolve, reject) => {
196
+ limited.on('data', (chunk: Buffer) => chunks.push(chunk))
197
+ limited.on('end', () => {
198
+ const result = Buffer.concat(chunks)
199
+ this._cached = result
200
+ resolve(result)
201
+ })
202
+ limited.on('error', reject)
203
+ })
204
+ }
205
+
206
+ /** Buffers the body and decodes as UTF-8 text. */
207
+ async text(maxBytes?: number): Promise<string> {
208
+ const buf = await this.buffer(maxBytes)
209
+ return buf.length === 0 ? '' : buf.toString('utf8')
210
+ }
211
+
212
+ /**
213
+ * Parses the body as JSON. If a schema is provided, the parsed value is
214
+ * validated. Detection order:
215
+ *
216
+ * 1. Standard Schema v1 (`["~standard"]`) — async-aware, multi-issue
217
+ * 2. Zod-like `safeParse(input)` — multi-issue
218
+ * 3. Plain `parse(input): T` — throws on failure
219
+ *
220
+ * Validation failures are normalized into `IngeniumValidationError` with a
221
+ * field-level `fields` map (dot-joined paths; empty path → `_`).
222
+ */
223
+ async json<T = unknown>(
224
+ schema?: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>,
225
+ maxBytes?: number,
226
+ ): Promise<T> {
227
+ // Inline `text()` to skip one async indirection (one microtask saved
228
+ // per ctx.body.json() call). Same observable behavior.
229
+ const buf = await this.buffer(maxBytes)
230
+ const text = buf.length === 0 ? '' : buf.toString('utf8')
231
+ let parsed: unknown
232
+ try {
233
+ parsed = text.length === 0 ? null : JSON.parse(text)
234
+ } catch (err) {
235
+ throw new IngeniumBadRequestError('Invalid JSON', err)
236
+ }
237
+ if (schema) {
238
+ // 1. Standard Schema v1 — most modern, takes precedence.
239
+ if (isStandardSchema(schema)) {
240
+ const maybe = schema['~standard'].validate(parsed)
241
+ const result = maybe instanceof Promise ? await maybe : maybe
242
+ if (result.issues) {
243
+ const fields: Record<string, string> = {}
244
+ for (const issue of result.issues) {
245
+ fields[standardPathToField(issue.path)] = issue.message
246
+ }
247
+ throw new IngeniumValidationError(fields)
248
+ }
249
+ return result.value as T
250
+ }
251
+ // 2. Zod-like safeParse.
252
+ if ('safeParse' in schema && typeof (schema as SafeParseSchema<T>).safeParse === 'function') {
253
+ const result = (schema as SafeParseSchema<T>).safeParse(parsed)
254
+ if (!result.success) {
255
+ const fields: Record<string, string> = {}
256
+ for (const issue of result.error.issues) {
257
+ fields[issue.path.join('.') || '_'] = issue.message
258
+ }
259
+ throw new IngeniumValidationError(fields)
260
+ }
261
+ return result.data
262
+ }
263
+ // 3. Plain parse.
264
+ try {
265
+ return (schema as ParseSchema<T>).parse(parsed)
266
+ } catch (err) {
267
+ throw new IngeniumValidationError({ _: (err as Error).message ?? 'validation failed' })
268
+ }
269
+ }
270
+ return parsed as T
271
+ }
272
+
273
+ /** Parses the body as `application/x-www-form-urlencoded`. */
274
+ async urlencoded(maxBytes?: number): Promise<Record<string, string>> {
275
+ const text = await this.text(maxBytes)
276
+ const params = new URLSearchParams(text)
277
+ const out: Record<string, string> = {}
278
+ for (const [k, v] of params) out[k] = v
279
+ return out
280
+ }
281
+
282
+ /**
283
+ * Parses the body as `multipart/form-data` (RFC 7578).
284
+ *
285
+ * Returns plain-text fields and fully buffered file parts. For very large
286
+ * uploads prefer `stream()` and parse manually — this method holds every
287
+ * file in memory.
288
+ *
289
+ * Failure modes:
290
+ * - Body exceeds `maxBytes` → `IngeniumPayloadTooLargeError`
291
+ * - Single file exceeds `maxFileSize` → `IngeniumPayloadTooLargeError`
292
+ * - Too many files / fields → `IngeniumBadRequestError`
293
+ * - Disallowed mime type → `IngeniumBadRequestError`
294
+ * - Content-Type isn't `multipart/form-data` or boundary missing → `IngeniumBadRequestError`
295
+ * - Malformed body → `IngeniumBadRequestError`
296
+ */
297
+ async multipart(opts: MultipartOptions = {}): Promise<MultipartResult> {
298
+ // Multipart opts out of the buffer cache. Unlike json/text/urlencoded —
299
+ // which all return idempotent decodings of the same bytes — multipart's
300
+ // result is bespoke (file parts, field limits, mime allow-lists), and
301
+ // re-parsing the same bytes under different `opts` would silently return
302
+ // a different shape. Safer to treat multipart as a terminal consumer:
303
+ // it runs once, then any further body access throws.
304
+ //
305
+ // To enforce that, we either consume the live stream (first call) or
306
+ // read the cached buffer (someone called .text()/.json() first), then
307
+ // immediately invalidate both — clearing `_cached` and setting
308
+ // `_consumed = true` so subsequent .json()/.text()/.multipart()/.stream()
309
+ // calls throw "already consumed".
310
+ const contentType = this._contentType
311
+ const buf = await this.buffer(opts.maxBytes ?? DEFAULT_MAX_BYTES)
312
+ this._cached = null
313
+ this._consumed = true
314
+ try {
315
+ return parseMultipart(buf, contentType, opts)
316
+ } catch (err) {
317
+ // Preserve framework errors (415/413/400) as-is.
318
+ if (err instanceof IngeniumPayloadTooLargeError || err instanceof IngeniumBadRequestError) {
319
+ throw err
320
+ }
321
+ throw new IngeniumBadRequestError('Malformed multipart body', err)
322
+ }
323
+ }
324
+ }