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,650 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http'
|
|
2
|
+
import type { Readable } from 'node:stream'
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { IngeniumBody, type ParseSchema, type SafeParseSchema } from './body.ts'
|
|
5
|
+
import { makeIngeniumCookies, type IngeniumCookies } from './cookies.ts'
|
|
6
|
+
import { isStandardSchema, type StandardIssue, type StandardSchemaV1 } from '../schema/standard.ts'
|
|
7
|
+
import type { HttpMethod } from '../router/types.ts'
|
|
8
|
+
import { resolveForwarded, type ForwardedInfo, type TrustProxy } from '../proxy/trust.ts'
|
|
9
|
+
import {
|
|
10
|
+
accepts as acceptsFn,
|
|
11
|
+
acceptsCharsets as acceptsCharsetsFn,
|
|
12
|
+
acceptsLanguages as acceptsLanguagesFn,
|
|
13
|
+
acceptsEncodings as acceptsEncodingsFn,
|
|
14
|
+
} from '../negotiation/negotiate.ts'
|
|
15
|
+
import { formatResponse, type FormatHandlers } from '../negotiation/format.ts'
|
|
16
|
+
import { isFresh } from '../negotiation/fresh.ts'
|
|
17
|
+
import { respondJsonWithEtag, type JsonEtagOptions } from '../negotiation/json-etag.ts'
|
|
18
|
+
import {
|
|
19
|
+
IngeniumHaltError,
|
|
20
|
+
IngeniumHeaderInjectionError,
|
|
21
|
+
IngeniumUnserializableError,
|
|
22
|
+
IngeniumValidationError,
|
|
23
|
+
} from '../errors.ts'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Dev-mode gate. Captured ONCE at module load; in production V8 dead-code-
|
|
27
|
+
* eliminates the branch bodies behind `if (IS_DEV)`. Every dev diagnostic
|
|
28
|
+
* MUST check this first so it pays nothing on the hot path.
|
|
29
|
+
*/
|
|
30
|
+
const IS_DEV = process.env.NODE_ENV !== 'production'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @internal Test-only flag for the trust-proxy / XFF mismatch warning. Once
|
|
34
|
+
* per process — read-once UX. Exposed via `_resetFootgunWarnings()` for tests.
|
|
35
|
+
*/
|
|
36
|
+
let _trustProxyWarned = false
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @internal Test-only reset hook. Clears all module-scoped once-flags used by
|
|
40
|
+
* the dev footgun warnings. Not part of the public API.
|
|
41
|
+
*/
|
|
42
|
+
export function _resetFootgunWarnings(): void {
|
|
43
|
+
_trustProxyWarned = false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** CR/LF detector for header-injection guard. Tested against names + values. */
|
|
47
|
+
const CRLF_RE = /[\r\n]/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reject header NAMES containing CR or LF. Empty/undefined names are
|
|
51
|
+
* allowed through — the underlying header bag's own type system rejects
|
|
52
|
+
* those naturally.
|
|
53
|
+
*/
|
|
54
|
+
function assertHeaderNameSafe(name: string): void {
|
|
55
|
+
if (CRLF_RE.test(name)) {
|
|
56
|
+
throw new IngeniumHeaderInjectionError(
|
|
57
|
+
`Header name contains CR/LF (possible header injection): ${JSON.stringify(name)}`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reject header VALUES containing CR or LF. Accepts a single string or an
|
|
64
|
+
* array — the array form checks each element. `undefined` is allowed (some
|
|
65
|
+
* call sites pass through optionals); empty string is allowed (legitimate).
|
|
66
|
+
*/
|
|
67
|
+
function assertHeaderValueSafe(name: string, value: string | string[]): void {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
for (let i = 0; i < value.length; i++) {
|
|
70
|
+
const v = value[i]
|
|
71
|
+
if (typeof v === 'string' && CRLF_RE.test(v)) {
|
|
72
|
+
throw new IngeniumHeaderInjectionError(
|
|
73
|
+
`Header value contains CR/LF (possible header injection): ${name}[${i}]`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (typeof value === 'string' && CRLF_RE.test(value)) {
|
|
80
|
+
throw new IngeniumHeaderInjectionError(
|
|
81
|
+
`Header value contains CR/LF (possible header injection): ${name}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strict `JSON.stringify` wrapper used by the response helpers. Surfaces
|
|
88
|
+
* `BigInt` / circular / other serialization failures as a
|
|
89
|
+
* `IngeniumUnserializableError` so the framework error boundary can render
|
|
90
|
+
* a clean 500 instead of a deep `TypeError` from V8.
|
|
91
|
+
*/
|
|
92
|
+
function strictStringify(body: unknown): string {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.stringify(body) as string
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
97
|
+
let reason: string
|
|
98
|
+
if (/circular/i.test(msg)) {
|
|
99
|
+
reason = `circular structure (${msg})`
|
|
100
|
+
} else if (/BigInt/i.test(msg)) {
|
|
101
|
+
reason = `BigInt value (${msg})`
|
|
102
|
+
} else {
|
|
103
|
+
reason = msg
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
process.emitWarning(
|
|
107
|
+
`IngeniumUnserializableError: ${reason}`,
|
|
108
|
+
{ type: 'IngeniumUnserializableError' },
|
|
109
|
+
)
|
|
110
|
+
} catch {
|
|
111
|
+
// process.emitWarning can throw in unusual runtimes (workers); swallow.
|
|
112
|
+
}
|
|
113
|
+
throw new IngeniumUnserializableError(
|
|
114
|
+
`Response body cannot be serialized: ${reason}`,
|
|
115
|
+
err,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Sentinel for routes with no params — frozen, so `ctx.params.foo` is safe. */
|
|
121
|
+
const EMPTY_PARAMS = Object.freeze(Object.create(null) as Record<string, string>)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `URLSearchParams` augmented with a `parse(schema)` method that runs the
|
|
125
|
+
* query through the same schema-detection pipeline as `ctx.body.json(schema)`.
|
|
126
|
+
*
|
|
127
|
+
* The shape passed to the schema is a **shallow array-aware** object:
|
|
128
|
+
*
|
|
129
|
+
* `?id=42&tag=a&tag=b&active=true`
|
|
130
|
+
* → `{ id: '42', tag: ['a','b'], active: 'true' }`
|
|
131
|
+
*
|
|
132
|
+
* Single-occurrence keys → `string`. Repeated keys → `string[]`. Everything is
|
|
133
|
+
* a string on the wire, so the user's schema is responsible for coercing
|
|
134
|
+
* numbers/booleans (Zod: use `z.coerce.number()`; ArkType: use `'string.numeric.parse'`).
|
|
135
|
+
*
|
|
136
|
+
* Rationale for picking THIS coercion over alternatives:
|
|
137
|
+
* - "raw strings only" loses repeated-key fidelity (qs/Express-style arrays)
|
|
138
|
+
* - "pre-coerced booleans/numbers" surprises users when "12foo" silently
|
|
139
|
+
* becomes a string or "true" becomes a boolean against their schema
|
|
140
|
+
* - Shallow-array matches `Object.fromEntries` semantics PLUS the most
|
|
141
|
+
* common ergonomic ask (tag=a&tag=b → tag: string[])
|
|
142
|
+
*/
|
|
143
|
+
export interface IngeniumQuery extends URLSearchParams {
|
|
144
|
+
parse<T = unknown>(
|
|
145
|
+
schema: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>,
|
|
146
|
+
): T
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Normalize a Standard Schema issue path into a dot-joined field key. */
|
|
150
|
+
function queryPathToField(path: StandardIssue['path']): string {
|
|
151
|
+
if (!path || path.length === 0) return '_'
|
|
152
|
+
const parts: string[] = []
|
|
153
|
+
for (const seg of path) {
|
|
154
|
+
if (seg !== null && typeof seg === 'object' && 'key' in seg) {
|
|
155
|
+
parts.push(String(seg.key))
|
|
156
|
+
} else {
|
|
157
|
+
parts.push(String(seg))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return parts.join('.') || '_'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the `{ key: string | string[] }` input that gets fed to the schema.
|
|
165
|
+
* Walks the URLSearchParams once; collisions promote a scalar to an array.
|
|
166
|
+
*
|
|
167
|
+
* Allocated lazily on `parse()` only — never paid by handlers that just read
|
|
168
|
+
* `ctx.query.get(...)`. Iteration of URLSearchParams is iteration-order stable
|
|
169
|
+
* and yields decoded values, so no manual percent-decoding here.
|
|
170
|
+
*/
|
|
171
|
+
function toShallowArrayObject(usp: URLSearchParams): Record<string, string | string[]> {
|
|
172
|
+
const out: Record<string, string | string[]> = Object.create(null)
|
|
173
|
+
for (const [k, v] of usp) {
|
|
174
|
+
const existing = out[k]
|
|
175
|
+
if (existing === undefined) {
|
|
176
|
+
out[k] = v
|
|
177
|
+
} else if (Array.isArray(existing)) {
|
|
178
|
+
existing.push(v)
|
|
179
|
+
} else {
|
|
180
|
+
out[k] = [existing, v]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function makeIngeniumQuery(raw: string): IngeniumQuery {
|
|
187
|
+
const usp = new URLSearchParams(raw) as IngeniumQuery
|
|
188
|
+
Object.defineProperty(usp, 'parse', {
|
|
189
|
+
configurable: true,
|
|
190
|
+
enumerable: false,
|
|
191
|
+
writable: false,
|
|
192
|
+
value: function parse<T>(
|
|
193
|
+
this: URLSearchParams,
|
|
194
|
+
schema: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>,
|
|
195
|
+
): T {
|
|
196
|
+
const input = toShallowArrayObject(this)
|
|
197
|
+
// 1. Standard Schema v1 takes precedence.
|
|
198
|
+
if (isStandardSchema(schema)) {
|
|
199
|
+
const maybe = schema['~standard'].validate(input)
|
|
200
|
+
// Query parsing is synchronous — async validators are still accepted
|
|
201
|
+
// but throw a clearer error than awaiting at the wire would.
|
|
202
|
+
if (maybe instanceof Promise) {
|
|
203
|
+
throw new IngeniumValidationError({
|
|
204
|
+
_: 'async Standard Schema validators are not supported on ctx.query.parse (use ctx.body.json for async)',
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
if (maybe.issues) {
|
|
208
|
+
const fields: Record<string, string> = {}
|
|
209
|
+
for (const issue of maybe.issues) {
|
|
210
|
+
fields[queryPathToField(issue.path)] = issue.message
|
|
211
|
+
}
|
|
212
|
+
throw new IngeniumValidationError(fields)
|
|
213
|
+
}
|
|
214
|
+
return maybe.value as T
|
|
215
|
+
}
|
|
216
|
+
// 2. Zod-like safeParse.
|
|
217
|
+
if (
|
|
218
|
+
'safeParse' in schema &&
|
|
219
|
+
typeof (schema as SafeParseSchema<T>).safeParse === 'function'
|
|
220
|
+
) {
|
|
221
|
+
const result = (schema as SafeParseSchema<T>).safeParse(input)
|
|
222
|
+
if (!result.success) {
|
|
223
|
+
const fields: Record<string, string> = {}
|
|
224
|
+
for (const issue of result.error.issues) {
|
|
225
|
+
fields[issue.path.join('.') || '_'] = issue.message
|
|
226
|
+
}
|
|
227
|
+
throw new IngeniumValidationError(fields)
|
|
228
|
+
}
|
|
229
|
+
return result.data
|
|
230
|
+
}
|
|
231
|
+
// 3. Plain parse.
|
|
232
|
+
try {
|
|
233
|
+
return (schema as ParseSchema<T>).parse(input)
|
|
234
|
+
} catch (err) {
|
|
235
|
+
throw new IngeniumValidationError({ _: (err as Error).message ?? 'validation failed' })
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
return usp
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Internal response body shape — adapter writes one of these to the wire. */
|
|
243
|
+
export type ResponseBody =
|
|
244
|
+
| { kind: 'none' }
|
|
245
|
+
| { kind: 'buffer'; data: Buffer }
|
|
246
|
+
| { kind: 'string'; data: string }
|
|
247
|
+
| { kind: 'stream'; data: Readable }
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Per-request context. Pool-bound: one instance per pool slot, reused
|
|
251
|
+
* across thousands of requests. All mutable fields are reset between uses.
|
|
252
|
+
*
|
|
253
|
+
* The `Params` generic is a phantom — it narrows `ctx.params` for typed
|
|
254
|
+
* route handlers but is `Record<string, string>` at runtime.
|
|
255
|
+
*/
|
|
256
|
+
export class IngeniumContext<Params = Record<string, string>> {
|
|
257
|
+
// ───── Request ─────────────────────────────────────────────────────────
|
|
258
|
+
/** HTTP method, uppercase. */
|
|
259
|
+
method: HttpMethod = 'GET'
|
|
260
|
+
/** Full request URL including query string (e.g. `/users/42?expand=posts`). */
|
|
261
|
+
url = '/'
|
|
262
|
+
/** Path portion of the URL (no query string). Set by the adapter. */
|
|
263
|
+
path = '/'
|
|
264
|
+
/** Raw query string (no leading `?`). Use `query` for parsed access. */
|
|
265
|
+
rawQuery = ''
|
|
266
|
+
/** Route params, written at trie-match time. */
|
|
267
|
+
params: Params = EMPTY_PARAMS as unknown as Params
|
|
268
|
+
/** Lowercased request headers (Node convention). */
|
|
269
|
+
headers: IncomingHttpHeaders = {}
|
|
270
|
+
/** Lazy body accessor. */
|
|
271
|
+
readonly body: IngeniumBody = new IngeniumBody()
|
|
272
|
+
/** Free-form per-request state for plugins/middleware (e.g. `ctx.user = ...`). */
|
|
273
|
+
state: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Per-request handle to enqueue background jobs onto a registered queue.
|
|
277
|
+
* Wired by `IngeniumApp` as a lazy decorator (declared with `!` because the
|
|
278
|
+
* runtime value is installed by the decorator registry, not the class
|
|
279
|
+
* initializer). Throws if the named queue isn't registered.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* await ctx.queue<{ to: string }>('emails').add({ to: 'a@b.com' })
|
|
283
|
+
*/
|
|
284
|
+
queue!: <TData = unknown>(name: string) => import('../jobs/types.ts').JobHandle<TData>
|
|
285
|
+
|
|
286
|
+
/** Lazy-parsed query. First access caches the URLSearchParams. */
|
|
287
|
+
private _query: IngeniumQuery | null = null
|
|
288
|
+
get query(): IngeniumQuery {
|
|
289
|
+
if (!this._query) this._query = makeIngeniumQuery(this.rawQuery)
|
|
290
|
+
return this._query
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @internal Lazy cookie holder. `null` until first read of `ctx.cookies`.
|
|
295
|
+
* Reset to `null` in `reset()` so a context returned to the pool drops the
|
|
296
|
+
* parsed-cookie cache (and any closed-over write state — though writes go
|
|
297
|
+
* straight to `_headers`, which is itself reset by reassignment).
|
|
298
|
+
*/
|
|
299
|
+
_cookies: IngeniumCookies | null = null
|
|
300
|
+
/**
|
|
301
|
+
* First-class cookie API. Lazy: the holder is allocated on first access so
|
|
302
|
+
* apps that never touch cookies pay zero per-request overhead. See
|
|
303
|
+
* `cookies.ts` for the read/write contract and signing rules.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* const sid = ctx.cookies.get('sid', { signed: true })
|
|
307
|
+
* ctx.cookies.set('theme', 'dark', { httpOnly: true, sameSite: 'lax' })
|
|
308
|
+
* ctx.cookies.clear('legacy')
|
|
309
|
+
*/
|
|
310
|
+
get cookies(): IngeniumCookies {
|
|
311
|
+
if (!this._cookies) this._cookies = makeIngeniumCookies(this)
|
|
312
|
+
return this._cookies
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* @internal App-wide cookie-signing secrets. Stamped by `IngeniumApp.handle`
|
|
317
|
+
* on dispatch entry when configured (mirrors `_trustProxy`). First secret
|
|
318
|
+
* signs new cookies; all entries verify reads (supports rotation). Empty
|
|
319
|
+
* means signed cookies will throw `IngeniumError(500, 'COOKIE_SECRET_MISSING')`.
|
|
320
|
+
*
|
|
321
|
+
* NOT cleared in `reset()` — this is app-wide config, not per-request state,
|
|
322
|
+
* and the cost of re-stamping every request is wasteful when the value is
|
|
323
|
+
* stable across the app's lifetime. The first call to `handle()` after a
|
|
324
|
+
* compose sets it; subsequent requests reuse the same array reference.
|
|
325
|
+
*/
|
|
326
|
+
_cookieSecrets: readonly string[] = []
|
|
327
|
+
|
|
328
|
+
// ───── Network info (trust-proxy aware) ────────────────────────────────
|
|
329
|
+
/** Immediate socket peer address — populated by the adapter. */
|
|
330
|
+
remoteAddress = '127.0.0.1'
|
|
331
|
+
/** Underlying transport protocol — populated by the adapter (http for node:http, https for TLS). */
|
|
332
|
+
baseProtocol: 'http' | 'https' = 'http'
|
|
333
|
+
/** @internal `trustProxy` config carried in from the app. */ _trustProxy: TrustProxy = false
|
|
334
|
+
/** @internal Cached forwarded resolution; computed lazily from headers. */
|
|
335
|
+
private _forwarded: ForwardedInfo | null = null
|
|
336
|
+
|
|
337
|
+
private resolveForwarded(): ForwardedInfo {
|
|
338
|
+
if (!this._forwarded) {
|
|
339
|
+
this._forwarded = resolveForwarded(
|
|
340
|
+
this._trustProxy,
|
|
341
|
+
this.remoteAddress,
|
|
342
|
+
this.headers as Record<string, string | string[] | undefined>,
|
|
343
|
+
this.baseProtocol,
|
|
344
|
+
)
|
|
345
|
+
// Dev-only — warn once per process when the user reads forwarded info
|
|
346
|
+
// with trustProxy disabled BUT the request carries X-Forwarded-For.
|
|
347
|
+
// Almost always means the user is behind a proxy and forgot to enable
|
|
348
|
+
// trustProxy, so `ctx.ip` is silently returning the proxy's address.
|
|
349
|
+
if (IS_DEV && !_trustProxyWarned && this._trustProxy === false) {
|
|
350
|
+
if (this.headers['x-forwarded-for'] !== undefined) {
|
|
351
|
+
_trustProxyWarned = true
|
|
352
|
+
try {
|
|
353
|
+
process.emitWarning(
|
|
354
|
+
"Read ctx.ip with trustProxy disabled, but request has X-Forwarded-For. Set 'trustProxy' on the app if behind a reverse proxy.",
|
|
355
|
+
{ type: 'IngeniumTrustProxyWarning' },
|
|
356
|
+
)
|
|
357
|
+
} catch {
|
|
358
|
+
// process.emitWarning can throw in unusual runtimes (workers); swallow.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return this._forwarded
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Best-effort client IP. With `trustProxy: false` this is the immediate
|
|
368
|
+
* socket peer; with trust-proxy enabled the X-Forwarded-For chain is
|
|
369
|
+
* walked according to the configured trust policy.
|
|
370
|
+
*/
|
|
371
|
+
get ip(): string { return this.resolveForwarded().ip }
|
|
372
|
+
/** Full forwarded chain (left-to-right, immediate peer last). */
|
|
373
|
+
get ips(): readonly string[] { return this.resolveForwarded().ips }
|
|
374
|
+
/** Best-effort protocol — honors `X-Forwarded-Proto` when trust-proxy is enabled. */
|
|
375
|
+
get protocol(): 'http' | 'https' { return this.resolveForwarded().protocol }
|
|
376
|
+
/** Convenience: `protocol === 'https'`. */
|
|
377
|
+
get secure(): boolean { return this.protocol === 'https' }
|
|
378
|
+
/** Best-effort hostname (no port) — honors `X-Forwarded-Host` when trust-proxy is enabled. */
|
|
379
|
+
get hostname(): string { return this.resolveForwarded().hostname }
|
|
380
|
+
|
|
381
|
+
// ───── Response ────────────────────────────────────────────────────────
|
|
382
|
+
/** @internal */ _statusCode = 200
|
|
383
|
+
/** @internal */ _headers: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
|
384
|
+
/** @internal */ _body: ResponseBody = { kind: 'none' }
|
|
385
|
+
/** @internal Whether a response helper has been called. */
|
|
386
|
+
_written = false
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @internal Per-request generation counter. Incremented every time the
|
|
390
|
+
* pool resets this context (and also bumped by `IngeniumApp.handle` when a
|
|
391
|
+
* request times out, so writes from the orphaned handler can be detected
|
|
392
|
+
* as stale). Compared against `_dispatchEpoch` by every response writer.
|
|
393
|
+
*/
|
|
394
|
+
_epoch = 0
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @internal Last `_epoch` value captured by `IngeniumApp.withEpochGuard`.
|
|
398
|
+
* Set on dispatch entry; the per-dispatch wrappers installed around the
|
|
399
|
+
* response writers close over this value to detect late writes from an
|
|
400
|
+
* orphaned (timed-out) handler. The wrappers compare `_epoch` against
|
|
401
|
+
* the captured value at call time — mismatch ⇒ orphan ⇒ swallow.
|
|
402
|
+
*
|
|
403
|
+
* `0` means no guard is active (no `requestTimeoutMs` configured, or
|
|
404
|
+
* the dispatch already resolved naturally).
|
|
405
|
+
*/
|
|
406
|
+
_dispatchEpoch = 0
|
|
407
|
+
|
|
408
|
+
// ───── Response helpers ────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* @internal Dev-only — emit `IngeniumDoubleWriteWarning` when a writer is
|
|
412
|
+
* called after `_written` is already true. No-op in production: V8
|
|
413
|
+
* eliminates the branch body behind the `IS_DEV` gate.
|
|
414
|
+
*/
|
|
415
|
+
private _warnDoubleWrite(method: string): void {
|
|
416
|
+
if (!IS_DEV) return
|
|
417
|
+
if (!this._written) return
|
|
418
|
+
try {
|
|
419
|
+
process.emitWarning(
|
|
420
|
+
`ctx.${method}() called after response was already written. Second call overrides the first; use 'return' to short-circuit.`,
|
|
421
|
+
{ type: 'IngeniumDoubleWriteWarning' },
|
|
422
|
+
)
|
|
423
|
+
} catch {
|
|
424
|
+
// process.emitWarning can throw in unusual runtimes (workers); swallow.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Set the HTTP status code. Returns `this` for chaining. */
|
|
429
|
+
status(code: number): this {
|
|
430
|
+
this._statusCode = code
|
|
431
|
+
return this
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Set a response header (case-insensitive). Returns `this` for chaining.
|
|
436
|
+
*
|
|
437
|
+
* Throws `IngeniumHeaderInjectionError` if `name` or `value` contains CR
|
|
438
|
+
* or LF — these would otherwise enable header-injection / response-
|
|
439
|
+
* splitting attacks if a caller forwards untrusted user input directly.
|
|
440
|
+
*/
|
|
441
|
+
set(name: string, value: string | string[]): this {
|
|
442
|
+
assertHeaderNameSafe(name)
|
|
443
|
+
assertHeaderValueSafe(name, value)
|
|
444
|
+
this._headers[name.toLowerCase()] = value
|
|
445
|
+
return this
|
|
446
|
+
}
|
|
447
|
+
/** Alias for `set` — matches Express's `res.setHeader`. */
|
|
448
|
+
setHeader(name: string, value: string | string[]): this {
|
|
449
|
+
return this.set(name, value)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Get a previously-set response header (lowercase lookup). */
|
|
453
|
+
getHeader(name: string): string | string[] | undefined {
|
|
454
|
+
return this._headers[name.toLowerCase()]
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Send a JSON response.
|
|
459
|
+
*
|
|
460
|
+
* Throws `IngeniumUnserializableError` if `body` cannot be encoded
|
|
461
|
+
* (circular structure, `BigInt`, etc.) — surfaces a clean 500 from the
|
|
462
|
+
* framework error boundary instead of a deep `TypeError`.
|
|
463
|
+
*/
|
|
464
|
+
json(body: unknown, status?: number): void {
|
|
465
|
+
this._warnDoubleWrite('json')
|
|
466
|
+
const data = strictStringify(body)
|
|
467
|
+
if (status !== undefined) this._statusCode = status
|
|
468
|
+
if (!this._headers['content-type']) this._headers['content-type'] = 'application/json; charset=utf-8'
|
|
469
|
+
this._body = { kind: 'string', data }
|
|
470
|
+
this._written = true
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Send a `text/plain` response. */
|
|
474
|
+
text(body: string, status?: number): void {
|
|
475
|
+
this._warnDoubleWrite('text')
|
|
476
|
+
if (status !== undefined) this._statusCode = status
|
|
477
|
+
if (!this._headers['content-type']) this._headers['content-type'] = 'text/plain; charset=utf-8'
|
|
478
|
+
this._body = { kind: 'string', data: body }
|
|
479
|
+
this._written = true
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Send a `text/html` response. */
|
|
483
|
+
html(body: string, status?: number): void {
|
|
484
|
+
this._warnDoubleWrite('html')
|
|
485
|
+
if (status !== undefined) this._statusCode = status
|
|
486
|
+
if (!this._headers['content-type']) this._headers['content-type'] = 'text/html; charset=utf-8'
|
|
487
|
+
this._body = { kind: 'string', data: body }
|
|
488
|
+
this._written = true
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Send a redirect (default 302). */
|
|
492
|
+
redirect(location: string, status = 302): void {
|
|
493
|
+
this._warnDoubleWrite('redirect')
|
|
494
|
+
this._statusCode = status
|
|
495
|
+
this._headers.location = location
|
|
496
|
+
this._body = { kind: 'none' }
|
|
497
|
+
this._written = true
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Stream a `Readable` to the client. Sets content-type if not already set. */
|
|
501
|
+
stream(readable: Readable, contentType?: string): void {
|
|
502
|
+
this._warnDoubleWrite('stream')
|
|
503
|
+
if (contentType && !this._headers['content-type']) this._headers['content-type'] = contentType
|
|
504
|
+
this._body = { kind: 'stream', data: readable }
|
|
505
|
+
this._written = true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Sinatra-style short-circuit. Throws `IngeniumHaltError(status, body?)`
|
|
510
|
+
* — the framework error boundary catches it and serializes per `bodyShape`:
|
|
511
|
+
*
|
|
512
|
+
* - `ctx.halt(401)` → 401 with default JSON `{ error, code: 'HALT' }`.
|
|
513
|
+
* - `ctx.halt(404, 'Not Found')` → 404 `text/plain` body verbatim.
|
|
514
|
+
* - `ctx.halt(422, { fields })` → 422 `application/json` body verbatim.
|
|
515
|
+
*
|
|
516
|
+
* The TypeScript `never` return type lets `if (!found) ctx.halt(404)`
|
|
517
|
+
* narrow the rest of the function — code after the call is unreachable.
|
|
518
|
+
*
|
|
519
|
+
* To bypass the error boundary entirely (write the response without
|
|
520
|
+
* throwing) call `ctx.json(body, status)` and `return` from the handler.
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* if (!authorized(ctx)) ctx.halt(401, 'Unauthorized')
|
|
524
|
+
* if (!user) ctx.halt(404, { error: 'Not Found', id })
|
|
525
|
+
*/
|
|
526
|
+
halt(status: number, body?: string | Record<string, unknown>): never {
|
|
527
|
+
throw new IngeniumHaltError(status, body)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Send a `Buffer` body verbatim. */
|
|
531
|
+
send(body: Buffer | string, status?: number): void {
|
|
532
|
+
this._warnDoubleWrite('send')
|
|
533
|
+
if (status !== undefined) this._statusCode = status
|
|
534
|
+
if (typeof body === 'string') {
|
|
535
|
+
if (!this._headers['content-type']) this._headers['content-type'] = 'text/plain; charset=utf-8'
|
|
536
|
+
this._body = { kind: 'string', data: body }
|
|
537
|
+
} else {
|
|
538
|
+
if (!this._headers['content-type']) this._headers['content-type'] = 'application/octet-stream'
|
|
539
|
+
this._body = { kind: 'buffer', data: body }
|
|
540
|
+
}
|
|
541
|
+
this._written = true
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ───── Content negotiation (request side) ──────────────────────────────
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Return the best mime type the client accepts from the offered list, or
|
|
548
|
+
* `false` if none are acceptable. With no arguments, returns the parsed
|
|
549
|
+
* preference-ordered list of accepted types from `Accept`.
|
|
550
|
+
*
|
|
551
|
+
* Each `type` may be a shorthand (`'json'`, `'html'`, `'csv'`, …) or a full
|
|
552
|
+
* mime (`'application/json'`). Quality factors are honored.
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* if (ctx.accepts('json')) ctx.json({ ok: true })
|
|
556
|
+
* else ctx.status(406).text('Not Acceptable')
|
|
557
|
+
*/
|
|
558
|
+
accepts(): string[]
|
|
559
|
+
accepts(...types: string[]): string | false
|
|
560
|
+
accepts(...types: string[]): string | false | string[] {
|
|
561
|
+
return types.length === 0 ? acceptsFn(this) : acceptsFn(this, ...types)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Best matching charset from the offered list against `Accept-Charset`. */
|
|
565
|
+
acceptsCharsets(): string[]
|
|
566
|
+
acceptsCharsets(...charsets: string[]): string | false
|
|
567
|
+
acceptsCharsets(...charsets: string[]): string | false | string[] {
|
|
568
|
+
return charsets.length === 0 ? acceptsCharsetsFn(this) : acceptsCharsetsFn(this, ...charsets)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Best matching language against `Accept-Language` (exact-tag match only). */
|
|
572
|
+
acceptsLanguages(): string[]
|
|
573
|
+
acceptsLanguages(...langs: string[]): string | false
|
|
574
|
+
acceptsLanguages(...langs: string[]): string | false | string[] {
|
|
575
|
+
return langs.length === 0 ? acceptsLanguagesFn(this) : acceptsLanguagesFn(this, ...langs)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Best matching encoding against `Accept-Encoding` (first offered when header absent). */
|
|
579
|
+
acceptsEncodings(): string[]
|
|
580
|
+
acceptsEncodings(...encodings: string[]): string | false
|
|
581
|
+
acceptsEncodings(...encodings: string[]): string | false | string[] {
|
|
582
|
+
return encodings.length === 0 ? acceptsEncodingsFn(this) : acceptsEncodingsFn(this, ...encodings)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ───── Content negotiation (response side) ─────────────────────────────
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Run the handler whose key best matches the request `Accept` header. The
|
|
589
|
+
* matched key is set as `Content-Type`. If no key matches and no `default`
|
|
590
|
+
* handler is provided, throws `IngeniumError(406, 'NOT_ACCEPTABLE')`.
|
|
591
|
+
*/
|
|
592
|
+
format(handlers: FormatHandlers): Promise<void> {
|
|
593
|
+
return formatResponse(this, handlers)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* `true` when the client's `If-None-Match` matches the response `ETag`,
|
|
598
|
+
* or `If-Modified-Since` is at-or-after the response `Last-Modified`.
|
|
599
|
+
* Reads from `_headers` so handlers can set ETag / Last-Modified before checking.
|
|
600
|
+
*/
|
|
601
|
+
get fresh(): boolean {
|
|
602
|
+
return isFresh(
|
|
603
|
+
this.headers as Record<string, string | string[] | undefined>,
|
|
604
|
+
this._headers as Record<string, string | string[] | undefined>,
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** `!fresh`. */
|
|
609
|
+
get stale(): boolean {
|
|
610
|
+
return !this.fresh
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Send a JSON body with an auto-computed weak ETag. If the request's
|
|
615
|
+
* `If-None-Match` matches the computed tag, short-circuits to 304.
|
|
616
|
+
*/
|
|
617
|
+
jsonWithEtag(body: unknown, opts?: JsonEtagOptions): void {
|
|
618
|
+
respondJsonWithEtag(this, body, opts)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ───── Pool lifecycle ──────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Reset all per-request state. Called by the pool before returning the
|
|
625
|
+
* context to the free list. Reassignments preserve the V8 hidden class
|
|
626
|
+
* so subsequent allocations stay monomorphic.
|
|
627
|
+
*/
|
|
628
|
+
reset(): void {
|
|
629
|
+
this.method = 'GET'
|
|
630
|
+
this.url = '/'
|
|
631
|
+
this.path = '/'
|
|
632
|
+
this.rawQuery = ''
|
|
633
|
+
this.params = EMPTY_PARAMS as unknown as Params
|
|
634
|
+
this.headers = {}
|
|
635
|
+
this._query = null
|
|
636
|
+
this._cookies = null
|
|
637
|
+
this.state = Object.create(null) as Record<string, unknown>
|
|
638
|
+
this.remoteAddress = '127.0.0.1'
|
|
639
|
+
this.baseProtocol = 'http'
|
|
640
|
+
this._trustProxy = false
|
|
641
|
+
this._forwarded = null
|
|
642
|
+
this._statusCode = 200
|
|
643
|
+
this._headers = Object.create(null) as Record<string, string | string[]>
|
|
644
|
+
this._body = { kind: 'none' }
|
|
645
|
+
this._written = false
|
|
646
|
+
this._dispatchEpoch = 0
|
|
647
|
+
this._epoch++
|
|
648
|
+
this.body._reset()
|
|
649
|
+
}
|
|
650
|
+
}
|