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,370 @@
1
+ import {
2
+ constants as cryptoConstants,
3
+ createHmac,
4
+ createPublicKey,
5
+ verify as cryptoVerify,
6
+ timingSafeEqual,
7
+ type KeyObject,
8
+ } from 'node:crypto'
9
+ import { Buffer } from 'node:buffer'
10
+ import type {
11
+ JwtAlgorithm,
12
+ JwtHeader,
13
+ JwtVerified,
14
+ JwtVerifyError,
15
+ } from './types.ts'
16
+
17
+ /**
18
+ * Per-algorithm wire descriptor.
19
+ *
20
+ * `family` selects the verifier branch; the digest / openssl-name fields are
21
+ * only consulted by the matching family.
22
+ */
23
+ interface AlgSpec {
24
+ family: 'hmac' | 'rsa' | 'rsa-pss' | 'ecdsa'
25
+ /** node:crypto digest name (`sha256`, `sha384`, `sha512`). HMAC + asymmetric both use it. */
26
+ digest: string
27
+ /** Expected raw signature length for ECDSA (r||s, two equal-sized halves). */
28
+ ecSigLen?: number
29
+ }
30
+
31
+ const ALG_SPEC: Readonly<Record<JwtAlgorithm, AlgSpec>> = {
32
+ HS256: { family: 'hmac', digest: 'sha256' },
33
+ HS384: { family: 'hmac', digest: 'sha384' },
34
+ HS512: { family: 'hmac', digest: 'sha512' },
35
+ RS256: { family: 'rsa', digest: 'sha256' },
36
+ RS384: { family: 'rsa', digest: 'sha384' },
37
+ RS512: { family: 'rsa', digest: 'sha512' },
38
+ PS256: { family: 'rsa-pss', digest: 'sha256' },
39
+ PS384: { family: 'rsa-pss', digest: 'sha384' },
40
+ PS512: { family: 'rsa-pss', digest: 'sha512' },
41
+ // ECDSA: r||s lengths come from the curve order — 32B for P-256,
42
+ // 48B for P-384, 66B for P-521 (the curve is 521 bits, padded to 528 = 66B).
43
+ ES256: { family: 'ecdsa', digest: 'sha256', ecSigLen: 64 },
44
+ ES384: { family: 'ecdsa', digest: 'sha384', ecSigLen: 96 },
45
+ ES512: { family: 'ecdsa', digest: 'sha512', ecSigLen: 132 },
46
+ }
47
+
48
+ /**
49
+ * A verification key as supplied by the caller (post-resolution).
50
+ * - `string` / `Buffer` for HMAC secrets and PEM blobs.
51
+ * - `KeyObject` for pre-built node:crypto keys (and JWKS-derived keys).
52
+ */
53
+ export type VerifyKeyMaterial = string | Buffer | KeyObject
54
+
55
+ /** Optional kid-tagged variant — what middleware passes after kid resolution. */
56
+ export interface KidTaggedKey {
57
+ kid?: string
58
+ key: VerifyKeyMaterial
59
+ }
60
+
61
+ /** Options accepted by {@link verifyJwt}. Mirrors the relevant subset of `JwtOptions`. */
62
+ export interface VerifyOptions {
63
+ algorithms: readonly JwtAlgorithm[]
64
+ audience?: string | readonly string[]
65
+ issuer?: string | readonly string[]
66
+ maxAgeSeconds?: number
67
+ clockSkewSeconds?: number
68
+ /** Override "now" for deterministic tests. Returns seconds since epoch. */
69
+ nowSeconds?: () => number
70
+ }
71
+
72
+ /**
73
+ * Decode a base64url-encoded JSON segment. Returns `null` on malformed input
74
+ * (so callers can fold it into the generic `'malformed'` failure mode).
75
+ */
76
+ function decodeJsonSegment<T = unknown>(segment: string): T | null {
77
+ if (!segment) return null
78
+ try {
79
+ const buf = Buffer.from(segment, 'base64url')
80
+ if (buf.length === 0) return null
81
+ const parsed = JSON.parse(buf.toString('utf8')) as unknown
82
+ if (parsed === null || typeof parsed !== 'object') return null
83
+ return parsed as T
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Constant-time HMAC verification.
91
+ *
92
+ * `timingSafeEqual` requires equal-length buffers — feeding mismatched lengths
93
+ * would itself leak length info via the throw. So we compare `signingInput`'s
94
+ * computed signature against the supplied one only after the explicit length
95
+ * check; both branches return `false` in O(constant) time relative to the
96
+ * caller's view (the throw path never executes).
97
+ */
98
+ function hmacVerifies(
99
+ digest: string,
100
+ secret: VerifyKeyMaterial,
101
+ signingInput: string,
102
+ sig: Buffer,
103
+ ): boolean {
104
+ // HMAC accepts string or Buffer; KeyObject would be unusual but handle it.
105
+ const secretInput: string | Buffer =
106
+ typeof secret === 'string' || Buffer.isBuffer(secret)
107
+ ? secret
108
+ : secret.export({ format: 'buffer' } as never)
109
+ const expected = createHmac(digest, secretInput).update(signingInput).digest()
110
+ if (sig.length !== expected.length) return false
111
+ return timingSafeEqual(sig, expected)
112
+ }
113
+
114
+ /**
115
+ * Asymmetric verification via OpenSSL. The key may be a PEM (string/Buffer)
116
+ * or a pre-built `KeyObject`; we normalise via `createPublicKey` once. RSA
117
+ * algorithms (RSxxx) use PKCS1-v1_5; PS* uses PSS (with MGF1 + salt = digest);
118
+ * ECDSA decodes from raw r||s (per JOSE) rather than DER.
119
+ *
120
+ * `crypto.verify` from OpenSSL is constant-time relative to the key — we
121
+ * don't add an extra timing shield ourselves.
122
+ */
123
+ function asymmetricVerifies(
124
+ spec: AlgSpec,
125
+ keyMaterial: VerifyKeyMaterial,
126
+ signingInput: string,
127
+ sig: Buffer,
128
+ ): boolean {
129
+ let key: KeyObject
130
+ try {
131
+ key =
132
+ typeof keyMaterial === 'string' || Buffer.isBuffer(keyMaterial)
133
+ ? createPublicKey(keyMaterial)
134
+ : keyMaterial
135
+ } catch {
136
+ return false
137
+ }
138
+
139
+ // ECDSA: spec mandates raw r||s (concatenation of two fixed-length integers).
140
+ // node:crypto's default DSA encoding is DER; we have to set 'ieee-p1363' to
141
+ // get the JOSE wire format. Length-check up front so a malformed sig never
142
+ // hits openssl with garbage.
143
+ if (spec.family === 'ecdsa') {
144
+ if (typeof spec.ecSigLen === 'number' && sig.length !== spec.ecSigLen) return false
145
+ try {
146
+ return cryptoVerify(spec.digest, Buffer.from(signingInput, 'utf8'), {
147
+ key,
148
+ dsaEncoding: 'ieee-p1363',
149
+ }, sig)
150
+ } catch {
151
+ return false
152
+ }
153
+ }
154
+
155
+ if (spec.family === 'rsa-pss') {
156
+ try {
157
+ return cryptoVerify(spec.digest, Buffer.from(signingInput, 'utf8'), {
158
+ key,
159
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
160
+ // RFC 7518 §3.5: salt length equals the digest output length.
161
+ saltLength: cryptoConstants.RSA_PSS_SALTLEN_DIGEST,
162
+ }, sig)
163
+ } catch {
164
+ return false
165
+ }
166
+ }
167
+
168
+ // Plain RSA-PKCS1-v1_5.
169
+ try {
170
+ return cryptoVerify(spec.digest, Buffer.from(signingInput, 'utf8'), key, sig)
171
+ } catch {
172
+ return false
173
+ }
174
+ }
175
+
176
+ /** Allowed claim resolution — both single value and array forms are common in spec. */
177
+ function audienceMatches(claim: unknown, expected: string | readonly string[]): boolean {
178
+ const wanted = typeof expected === 'string' ? [expected] : expected
179
+ if (typeof claim === 'string') return wanted.includes(claim)
180
+ if (Array.isArray(claim)) {
181
+ for (const c of claim) {
182
+ if (typeof c === 'string' && wanted.includes(c)) return true
183
+ }
184
+ }
185
+ return false
186
+ }
187
+
188
+ function issuerMatches(claim: unknown, expected: string | readonly string[]): boolean {
189
+ if (typeof claim !== 'string') return false
190
+ return typeof expected === 'string' ? expected === claim : expected.includes(claim)
191
+ }
192
+
193
+ /**
194
+ * Pure JWT verifier. No I/O, no logging — returns either a `JwtVerified` or
195
+ * a tagged failure object. The middleware layer is responsible for collapsing
196
+ * every failure into the same `IngeniumUnauthorizedError('Invalid token')` so
197
+ * the wire never reveals which check tripped.
198
+ *
199
+ * `keys` is a flat array because key-resolution (rotation, kid-lookup, JWKS)
200
+ * is the caller's responsibility; this function just tries them in order.
201
+ * Each entry may carry an optional `kid` — when present AND `header.kid` is
202
+ * set, only matching entries are considered. Without a `header.kid`, every
203
+ * entry is tried.
204
+ *
205
+ * `alg: 'none'` is rejected unconditionally, even if for some reason the
206
+ * allowlist were extended to include it. Defence in depth.
207
+ */
208
+ export function verifyJwt<T = Record<string, unknown>>(
209
+ token: string,
210
+ keys: readonly (VerifyKeyMaterial | KidTaggedKey)[],
211
+ opts: VerifyOptions,
212
+ ): JwtVerified<T> | JwtVerifyError {
213
+ if (typeof token !== 'string' || token.length === 0) return { error: 'malformed' }
214
+
215
+ // Compact serialization: header.payload.signature
216
+ const firstDot = token.indexOf('.')
217
+ if (firstDot <= 0) return { error: 'malformed' }
218
+ const secondDot = token.indexOf('.', firstDot + 1)
219
+ if (secondDot <= firstDot + 1) return { error: 'malformed' }
220
+ if (token.indexOf('.', secondDot + 1) !== -1) return { error: 'malformed' }
221
+ // The signature segment may legally be empty only for 'alg: none', which
222
+ // we reject anyway. Treat empty as malformed.
223
+ if (secondDot === token.length - 1) return { error: 'malformed' }
224
+
225
+ const headerB64 = token.slice(0, firstDot)
226
+ const payloadB64 = token.slice(firstDot + 1, secondDot)
227
+ const sigB64 = token.slice(secondDot + 1)
228
+
229
+ const header = decodeJsonSegment<JwtHeader>(headerB64)
230
+ if (!header || typeof header.alg !== 'string') return { error: 'malformed' }
231
+
232
+ // Hard-reject 'none' BEFORE the allowlist check — even if a buggy caller
233
+ // somehow lets it through. This is the canonical JWT-library footgun.
234
+ if (header.alg === 'none' || header.alg.toLowerCase() === 'none') {
235
+ return { error: 'unsupported_alg' }
236
+ }
237
+
238
+ const alg = header.alg as JwtAlgorithm
239
+ if (!opts.algorithms.includes(alg)) return { error: 'unsupported_alg' }
240
+ const spec = ALG_SPEC[alg]
241
+ if (!spec) return { error: 'unsupported_alg' }
242
+
243
+ const payload = decodeJsonSegment<T & Record<string, unknown>>(payloadB64)
244
+ if (!payload) return { error: 'malformed' }
245
+
246
+ const signingInput = `${headerB64}.${payloadB64}`
247
+
248
+ // Decode signature once.
249
+ let sig: Buffer
250
+ try {
251
+ sig = Buffer.from(sigB64, 'base64url')
252
+ } catch {
253
+ return { error: 'malformed' }
254
+ }
255
+ if (sig.length === 0) return { error: 'malformed' }
256
+
257
+ // Filter keys by kid when both sides advertise one. This both narrows the
258
+ // candidate set (perf) and is required for JWKS — the resolver may have
259
+ // returned the entire keyset.
260
+ const headerKid = typeof header.kid === 'string' ? header.kid : null
261
+ const candidates = selectCandidates(keys, headerKid)
262
+ if (candidates.length === 0) {
263
+ // If the caller supplied kid-tagged keys but none matched, we know
264
+ // nothing will verify — surface this as a distinct (internal) reason
265
+ // so the logger can be precise. The wire still gets 'Invalid token'.
266
+ return { error: headerKid ? 'kid_unknown' : 'bad_signature' }
267
+ }
268
+
269
+ let signatureOk = false
270
+ for (const candidate of candidates) {
271
+ if (spec.family === 'hmac') {
272
+ if (hmacVerifies(spec.digest, candidate, signingInput, sig)) {
273
+ signatureOk = true
274
+ break
275
+ }
276
+ } else {
277
+ if (asymmetricVerifies(spec, candidate, signingInput, sig)) {
278
+ signatureOk = true
279
+ break
280
+ }
281
+ }
282
+ }
283
+ if (!signatureOk) return { error: 'bad_signature' }
284
+
285
+ // Temporal claims.
286
+ const now = (opts.nowSeconds ?? (() => Math.floor(Date.now() / 1000)))()
287
+ const skew = opts.clockSkewSeconds ?? 5
288
+ const claims = payload as Record<string, unknown>
289
+
290
+ if (typeof claims.exp === 'number') {
291
+ if (claims.exp <= now - skew) return { error: 'expired' }
292
+ }
293
+ if (typeof claims.nbf === 'number') {
294
+ if (claims.nbf > now + skew) return { error: 'not_yet_valid' }
295
+ }
296
+ if (typeof opts.maxAgeSeconds === 'number') {
297
+ if (typeof claims.iat !== 'number') return { error: 'too_old' }
298
+ if (claims.iat + opts.maxAgeSeconds <= now - skew) return { error: 'too_old' }
299
+ }
300
+ if (opts.audience !== undefined) {
301
+ if (!audienceMatches(claims.aud, opts.audience)) return { error: 'aud_mismatch' }
302
+ }
303
+ if (opts.issuer !== undefined) {
304
+ if (!issuerMatches(claims.iss, opts.issuer)) return { error: 'iss_mismatch' }
305
+ }
306
+
307
+ return { header, payload, raw: token }
308
+ }
309
+
310
+ /**
311
+ * Reduce the supplied key array to the set worth trying:
312
+ * - If `header.kid` is set, prefer entries whose `kid` matches. If at least
313
+ * one tagged key matches, ONLY those are tried (no fallback to untagged
314
+ * keys — an attacker shouldn't be able to coerce a kid-tagged JWKS into
315
+ * trying an unrelated rotation key).
316
+ * - If `header.kid` is set but no tagged key matches, AND the array contains
317
+ * untagged keys, try the untagged ones (legacy / single-key callers).
318
+ * - If `header.kid` is absent, try every entry's `key`.
319
+ */
320
+ function selectCandidates(
321
+ keys: readonly (VerifyKeyMaterial | KidTaggedKey)[],
322
+ headerKid: string | null,
323
+ ): VerifyKeyMaterial[] {
324
+ if (headerKid) {
325
+ const matched: VerifyKeyMaterial[] = []
326
+ let sawAnyTagged = false
327
+ const untagged: VerifyKeyMaterial[] = []
328
+ for (const k of keys) {
329
+ if (isKidTagged(k)) {
330
+ sawAnyTagged = true
331
+ if (k.kid === headerKid) matched.push(k.key)
332
+ } else {
333
+ untagged.push(k)
334
+ }
335
+ }
336
+ if (matched.length > 0) return matched
337
+ // No tag match — fall back to untagged only when nothing was tagged at
338
+ // all (caller didn't intend kid routing). If they tagged some but the
339
+ // header.kid matches none, refuse.
340
+ if (sawAnyTagged) return []
341
+ return untagged
342
+ }
343
+
344
+ const out: VerifyKeyMaterial[] = []
345
+ for (const k of keys) {
346
+ if (isKidTagged(k)) out.push(k.key)
347
+ else out.push(k)
348
+ }
349
+ return out
350
+ }
351
+
352
+ function isKidTagged(k: VerifyKeyMaterial | KidTaggedKey): k is KidTaggedKey {
353
+ return (
354
+ typeof k === 'object' &&
355
+ k !== null &&
356
+ !Buffer.isBuffer(k) &&
357
+ 'key' in (k as object) &&
358
+ 'kid' in (k as object)
359
+ )
360
+ }
361
+
362
+ /** Internal helper — exported for tests that want a stable digest map. */
363
+ export function digestFor(alg: JwtAlgorithm): string {
364
+ return ALG_SPEC[alg].digest
365
+ }
366
+
367
+ /** Internal helper — surface the alg family for tests / introspection. */
368
+ export function familyFor(alg: JwtAlgorithm): AlgSpec['family'] {
369
+ return ALG_SPEC[alg].family
370
+ }
@@ -0,0 +1,94 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+ import { reflectReturn } from '../response/reflect.ts'
3
+ import type { ComposedHandler, IngeniumMiddleware } from './types.ts'
4
+
5
+ const NOOP: ComposedHandler = async () => {}
6
+
7
+ /**
8
+ * Compose an array of middleware into a single async function. Composition
9
+ * runs ONCE at registration / first-request time; the returned function has
10
+ * no per-request `bind`, no index variable, and no `stack[n]` lookups.
11
+ *
12
+ * The dispatcher chain is pre-built bottom-up: `dispatchers[i]` runs
13
+ * `stack[i]` with a `next` that invokes `dispatchers[i + 1]`. Each
14
+ * middleware-level invocation still allocates one closure to capture `ctx`
15
+ * (unavoidable without dropping concurrency safety).
16
+ *
17
+ * Double-`next()` calls are detected only when `process.env.REX_DEBUG` is
18
+ * truthy, to keep the production hot path free of per-call guard variables.
19
+ */
20
+ export function compose(stack: readonly IngeniumMiddleware[]): ComposedHandler {
21
+ const len = stack.length
22
+ if (len === 0) return NOOP
23
+
24
+ const debug = !!process.env.REX_DEBUG
25
+
26
+ // dispatchers[i] runs middleware[i] and threads control through middleware[i+1..]
27
+ // dispatchers[len] is the terminal noop.
28
+ const dispatchers: ComposedHandler[] = new Array(len + 1)
29
+ dispatchers[len] = NOOP
30
+
31
+ for (let i = len - 1; i >= 0; i--) {
32
+ const fn = stack[i]!
33
+ const nextDispatcher = dispatchers[i + 1]!
34
+ if (debug) {
35
+ dispatchers[i] = async (ctx) => {
36
+ let called = false
37
+ await fn(ctx, () => {
38
+ if (called) {
39
+ throw new Error(`next() called multiple times in middleware at index ${i}`)
40
+ }
41
+ called = true
42
+ return nextDispatcher(ctx)
43
+ })
44
+ }
45
+ } else {
46
+ dispatchers[i] = async (ctx) => {
47
+ await fn(ctx, () => nextDispatcher(ctx))
48
+ }
49
+ }
50
+ }
51
+
52
+ return dispatchers[0] ?? NOOP
53
+ }
54
+
55
+ /**
56
+ * Compose middleware then append a terminal handler that does not receive a
57
+ * `next` (so a route handler can be the leaf of the chain). The handler's
58
+ * return value is reflected to the response per the contract in
59
+ * `response/reflect.ts` — unless the handler called a `ctx.json/...` helper,
60
+ * in which case the return value is ignored.
61
+ *
62
+ * Hot-path optimization: when there are no middleware, we skip the
63
+ * dispatcher chain entirely and return a thin wrapper that calls the
64
+ * handler directly. We also detect synchronous handler return values
65
+ * (non-thenable) and avoid the `await` microtask in that case — measurable
66
+ * on JSON-returning routes that don't touch the body.
67
+ */
68
+ export function composeWithHandler(
69
+ middleware: readonly IngeniumMiddleware[],
70
+ handler: (ctx: IngeniumContext) => unknown | Promise<unknown>,
71
+ ): ComposedHandler {
72
+ if (middleware.length === 0) {
73
+ return makeFastTerminal(handler)
74
+ }
75
+ const terminal: IngeniumMiddleware = async (ctx) => {
76
+ const result = await handler(ctx)
77
+ reflectReturn(ctx, result)
78
+ }
79
+ return compose([...middleware, terminal])
80
+ }
81
+
82
+ /** No-middleware fast path: skip compose, skip await when handler is sync. */
83
+ function makeFastTerminal(
84
+ handler: (ctx: IngeniumContext) => unknown | Promise<unknown>,
85
+ ): ComposedHandler {
86
+ return async (ctx) => {
87
+ const r = handler(ctx)
88
+ if (r !== null && typeof r === 'object' && typeof (r as Promise<unknown>).then === 'function') {
89
+ reflectReturn(ctx, await r)
90
+ } else {
91
+ reflectReturn(ctx, r)
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,37 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+
3
+ /**
4
+ * A function that runs as part of the middleware chain. Call `next()` to
5
+ * continue to the next middleware; omit it to short-circuit.
6
+ *
7
+ * @example
8
+ * const logger: IngeniumMiddleware = async (ctx, next) => {
9
+ * const start = Date.now()
10
+ * await next()
11
+ * ctx.logger?.info(`${ctx.method} ${ctx.path} ${Date.now() - start}ms`)
12
+ * }
13
+ */
14
+ export type IngeniumMiddleware = (ctx: IngeniumContext, next: () => Promise<void>) => unknown | Promise<unknown>
15
+
16
+ /**
17
+ * A composed middleware chain plus terminal handler. Returned by `compose()`.
18
+ * Internally cached on each trie leaf.
19
+ */
20
+ export type ComposedHandler = (ctx: IngeniumContext) => Promise<void>
21
+
22
+ /**
23
+ * A user-facing route handler. Its return value is reflected to the wire by
24
+ * the response-helper dispatcher (see `response/helpers.ts`):
25
+ *
26
+ * - `undefined` → 204 (unless `ctx.json/...` was already called)
27
+ * - `string` → 200 text/plain (or text/html if it starts with `<`)
28
+ * - object → 200 application/json
29
+ * - `Buffer`/`Uint8Array` → 200 application/octet-stream
30
+ * - `Readable` → streamed response
31
+ *
32
+ * For full control, call `ctx.json/text/html/stream/redirect` and return
33
+ * `void` (or any value — return value is ignored once a helper has run).
34
+ */
35
+ export type IngeniumHandler<Params = Record<string, string>> = (
36
+ ctx: IngeniumContext<Params>,
37
+ ) => unknown | Promise<unknown>
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Pure parsers and matchers for HTTP `Accept`-family headers.
3
+ *
4
+ * Implemented from scratch — no `negotiator` / `accepts` runtime dep.
5
+ * Used by `negotiate.ts`, `format.ts`, and downstream context helpers.
6
+ *
7
+ * Spec references:
8
+ * - RFC 9110 §12.5.1 (Accept), §12.5.2 (Accept-Charset),
9
+ * §12.5.4 (Accept-Encoding), §12.5.5 (Accept-Language).
10
+ */
11
+
12
+ /** A single parsed media-range entry from an `Accept` header. */
13
+ export interface ParsedAccept {
14
+ /** The full media-range string (lowercased), e.g. `text/html`, `text/\*`, `\*\/\*`. */
15
+ type: string
16
+ /** Quality factor from `;q=N`, default `1`. Out-of-range values are clamped. */
17
+ quality: number
18
+ /** Any other extension parameters (e.g. `level=1`). */
19
+ params: Record<string, string>
20
+ }
21
+
22
+ /**
23
+ * Express `accepts`-style shorthand → canonical media type.
24
+ * Kept intentionally tiny — covers the 99% case for body responses.
25
+ */
26
+ const SHORTHAND: Readonly<Record<string, string>> = {
27
+ json: 'application/json',
28
+ html: 'text/html',
29
+ text: 'text/plain',
30
+ xml: 'application/xml',
31
+ form: 'application/x-www-form-urlencoded',
32
+ multipart: 'multipart/form-data',
33
+ csv: 'text/csv',
34
+ 'octet-stream': 'application/octet-stream',
35
+ }
36
+
37
+ /** Resolve a shorthand (`'json'`) to its canonical mime, or pass through. */
38
+ export function expandShorthand(token: string): string {
39
+ const lower = token.toLowerCase()
40
+ return SHORTHAND[lower] ?? lower
41
+ }
42
+
43
+ /**
44
+ * Parse a comma-separated `Accept`-family header into a list of entries.
45
+ * Empty / undefined input returns an empty array. Malformed entries are
46
+ * silently dropped (lenient parsing — same as Express).
47
+ *
48
+ * Result is **not** sorted; pass to `sortByPreference` if you need ordering.
49
+ */
50
+ export function parseAcceptHeader(header: string | undefined): ParsedAccept[] {
51
+ if (!header) return []
52
+ const out: ParsedAccept[] = []
53
+ // Split on commas. Header values don't allow quoted commas in this set,
54
+ // so a plain split is safe.
55
+ const parts = header.split(',')
56
+ for (const raw of parts) {
57
+ const trimmed = raw.trim()
58
+ if (trimmed.length === 0) continue
59
+ const segments = trimmed.split(';')
60
+ const typeSeg = segments[0]?.trim().toLowerCase()
61
+ if (!typeSeg) continue
62
+ let quality = 1
63
+ const params: Record<string, string> = {}
64
+ for (let i = 1; i < segments.length; i++) {
65
+ const seg = segments[i]?.trim()
66
+ if (!seg) continue
67
+ const eq = seg.indexOf('=')
68
+ if (eq === -1) continue
69
+ const key = seg.slice(0, eq).trim().toLowerCase()
70
+ let value = seg.slice(eq + 1).trim()
71
+ // Strip surrounding quotes if present.
72
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
73
+ value = value.slice(1, value.length - 1)
74
+ }
75
+ if (key === 'q') {
76
+ const q = Number(value)
77
+ quality = Number.isFinite(q) ? Math.max(0, Math.min(1, q)) : 0
78
+ } else {
79
+ params[key] = value
80
+ }
81
+ }
82
+ out.push({ type: typeSeg, quality, params })
83
+ }
84
+ return out
85
+ }
86
+
87
+ /**
88
+ * Specificity score for a media-range. Higher is more specific.
89
+ * `*​/*` → 0
90
+ * `type/*` → 1
91
+ * `type/sub` → 2 (+ #params for tie-breaking)
92
+ */
93
+ function specificity(entry: ParsedAccept): number {
94
+ if (entry.type === '*/*' || entry.type === '*') return 0
95
+ if (entry.type.endsWith('/*')) return 1
96
+ return 2 + Object.keys(entry.params).length
97
+ }
98
+
99
+ /**
100
+ * Stable sort by RFC preference: highest q first, then most-specific first.
101
+ * Returns a NEW array; does not mutate input.
102
+ */
103
+ export function sortByPreference(entries: readonly ParsedAccept[]): ParsedAccept[] {
104
+ return [...entries]
105
+ .map((e, i) => ({ e, i }))
106
+ .sort((a, b) => {
107
+ if (b.e.quality !== a.e.quality) return b.e.quality - a.e.quality
108
+ const sb = specificity(b.e)
109
+ const sa = specificity(a.e)
110
+ if (sb !== sa) return sb - sa
111
+ return a.i - b.i // stable
112
+ })
113
+ .map((x) => x.e)
114
+ }
115
+
116
+ /** Does an offered concrete type match a parsed Accept entry (incl. wildcards)? */
117
+ function entryMatches(entry: ParsedAccept, offered: string): boolean {
118
+ const offeredLower = offered.toLowerCase()
119
+ if (entry.type === '*/*' || entry.type === '*') return true
120
+ if (entry.type === offeredLower) return true
121
+ if (entry.type.endsWith('/*')) {
122
+ const prefix = entry.type.slice(0, -1) // keep trailing slash
123
+ return offeredLower.startsWith(prefix)
124
+ }
125
+ return false
126
+ }
127
+
128
+ /**
129
+ * Return the best match for `offered` against `acceptHeader`, or `false`.
130
+ *
131
+ * Matching algorithm:
132
+ * 1. If `acceptHeader` is missing/empty → first offered wins (Express behavior).
133
+ * 2. Walk parsed entries sorted by quality + specificity.
134
+ * 3. For each entry (in preference order), pick the first offered that matches.
135
+ * Among ties at the same Accept entry, the offered's listed order wins.
136
+ * 4. Entries with `q=0` reject — never match.
137
+ */
138
+ export function selectBest(
139
+ acceptHeader: string | undefined,
140
+ offered: readonly string[],
141
+ ): string | false {
142
+ if (offered.length === 0) return false
143
+ const expanded = offered.map(expandShorthand)
144
+ if (!acceptHeader || acceptHeader.trim() === '') {
145
+ return offered[0] ?? false
146
+ }
147
+ const sorted = sortByPreference(parseAcceptHeader(acceptHeader))
148
+ if (sorted.length === 0) return offered[0] ?? false
149
+
150
+ for (const entry of sorted) {
151
+ if (entry.quality === 0) continue
152
+ for (let i = 0; i < expanded.length; i++) {
153
+ if (entryMatches(entry, expanded[i] as string)) {
154
+ return offered[i] as string
155
+ }
156
+ }
157
+ }
158
+ return false
159
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `computeEtag(body, weak?)` — sha1-based entity tag for response bodies.
3
+ *
4
+ * Format: `W/"<sha1-base64-without-padding>"` (weak) or `"<sha1-base64-without-padding>"`.
5
+ * Weak is the default — fine for JSON where serialization may legitimately vary
6
+ * (key order, whitespace) without representing a different resource.
7
+ *
8
+ * The empty-body ETag is special-cased to a fixed constant so two empty
9
+ * bodies always compare equal without pumping through the hash.
10
+ */
11
+
12
+ import { createHash } from 'node:crypto'
13
+ import { Buffer } from 'node:buffer'
14
+
15
+ /** Pre-computed sha1("") base64 → `2jmj7l5rSw0yVb/vlWAYkK/YBwk=`. */
16
+ const EMPTY_HASH = '2jmj7l5rSw0yVb/vlWAYkK/YBwk='
17
+
18
+ /**
19
+ * Compute an ETag for the given body. Strings are treated as UTF-8.
20
+ * @param body Response body — usually `JSON.stringify(...)` or a `Buffer`.
21
+ * @param weak Prefix the tag with `W/`. Defaults to `true` for JSON safety.
22
+ */
23
+ export function computeEtag(body: string | Buffer, weak = true): string {
24
+ const len = typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.length
25
+ if (len === 0) return weak ? `W/"${EMPTY_HASH}"` : `"${EMPTY_HASH}"`
26
+ const hash = createHash('sha1').update(body as Buffer | string).digest('base64')
27
+ // Trim trailing `=` padding for compactness — keeps tag URL-safe-ish and shorter.
28
+ const trimmed = hash.replace(/=+$/g, '')
29
+ return weak ? `W/"${trimmed}"` : `"${trimmed}"`
30
+ }