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.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- 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
|
+
}
|