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,224 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { Buffer } from 'node:buffer'
3
+ import { IngeniumError } from '../errors.ts'
4
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
5
+ import type { IngeniumContext } from '../context/context.ts'
6
+ import type { CsrfCookieOptions, CsrfOptions, CsrfValueReader } from './types.ts'
7
+
8
+ /** 403 Forbidden — CSRF token missing or mismatched. */
9
+ export class IngeniumCsrfError extends IngeniumError {
10
+ constructor(message = 'CSRF token validation failed') {
11
+ super(403, 'CSRF_FAILED', message)
12
+ }
13
+ }
14
+
15
+ const TOKEN_BYTES = 18
16
+ const SAFE_METHODS_DEFAULT: readonly string[] = ['GET', 'HEAD', 'OPTIONS', 'TRACE']
17
+ const COOKIE_NAME_DEFAULT = 'ingenium.csrf'
18
+ const HEADER_NAMES_DEFAULT: readonly string[] = ['x-csrf-token', 'x-xsrf-token']
19
+
20
+ interface ResolvedOptions {
21
+ secrets: string[] // first signs, all verify (rotation)
22
+ storage: 'cookie' | 'session'
23
+ cookie: Required<CsrfCookieOptions>
24
+ ignoreMethods: Set<string>
25
+ value: CsrfValueReader
26
+ skip: ((ctx: IngeniumContext) => boolean | Promise<boolean>) | null
27
+ }
28
+
29
+ /**
30
+ * CSRF protection middleware. Two modes:
31
+ *
32
+ * - `storage: 'cookie'` (default) — double-submit cookie pattern. A
33
+ * randomly-generated token is HMAC-signed, written to a non-HttpOnly
34
+ * cookie on safe requests, and the client must echo the cookie value
35
+ * back in a header (`X-CSRF-Token`) on unsafe requests. The signature
36
+ * prevents client-side forgery; the same-origin policy prevents
37
+ * cross-origin sites from reading the cookie.
38
+ *
39
+ * - `storage: 'session'` — synchronizer pattern. The token is stored on
40
+ * `ctx.session` and matched against the submitted token. Requires
41
+ * `sessionMiddleware` to run before this middleware.
42
+ *
43
+ * Use `ctx.state.csrfToken` (or call `(ctx as IngeniumContext & { csrfToken(): string }).csrfToken()`)
44
+ * to read the current token to embed in HTML forms or send to a JS client.
45
+ */
46
+ export function csrfMiddleware(opts: CsrfOptions = {}): IngeniumMiddleware {
47
+ const resolved = resolveOptions(opts)
48
+ if (resolved.storage === 'cookie' && resolved.secrets.length === 0) {
49
+ throw new Error("csrfMiddleware: `secret` is required when storage is 'cookie'")
50
+ }
51
+
52
+ return async (ctx, next) => {
53
+ if (resolved.skip && (await resolved.skip(ctx))) {
54
+ await next()
55
+ return
56
+ }
57
+
58
+ // Resolve / mint the expected token for this request.
59
+ let expected = readExpectedToken(ctx, resolved)
60
+ let mintedThisRequest = false
61
+ if (!expected) {
62
+ expected = mintToken(resolved)
63
+ mintedThisRequest = true
64
+ }
65
+
66
+ // Expose token to handlers via ctx.state.csrfToken AND a method.
67
+ ctx.state.csrfToken = expected
68
+ ;(ctx as IngeniumContext & { csrfToken: () => string }).csrfToken = () => expected as string
69
+
70
+ const isUnsafe = !resolved.ignoreMethods.has(ctx.method)
71
+ if (isUnsafe) {
72
+ const submitted = await resolved.value(ctx)
73
+ if (!submitted || !tokenMatches(submitted, expected)) {
74
+ throw new IngeniumCsrfError()
75
+ }
76
+ }
77
+
78
+ await next()
79
+
80
+ // Issue (or refresh) the cookie on cookie-storage mode.
81
+ if (resolved.storage === 'cookie' && (mintedThisRequest || isUnsafe)) {
82
+ writeCookie(ctx, expected, resolved.cookie)
83
+ } else if (resolved.storage === 'session' && mintedThisRequest) {
84
+ writeSession(ctx, expected)
85
+ }
86
+ }
87
+ }
88
+
89
+ // ───── Token mint / verify ─────────────────────────────────────────────────
90
+
91
+ function mintToken(opts: ResolvedOptions): string {
92
+ const raw = randomBytes(TOKEN_BYTES).toString('base64url')
93
+ if (opts.storage === 'session' || opts.secrets.length === 0) return raw
94
+ // Signed for double-submit so a forged cookie value can't pass verification.
95
+ const sig = signToken(raw, opts.secrets[0]!)
96
+ return `${raw}.${sig}`
97
+ }
98
+
99
+ function signToken(raw: string, secret: string): string {
100
+ return createHmac('sha256', secret).update(raw).digest('base64url')
101
+ }
102
+
103
+ function tokenMatches(submitted: string, expected: string): boolean {
104
+ const a = Buffer.from(submitted)
105
+ const b = Buffer.from(expected)
106
+ // Length-mismatch already rules out a match; timingSafeEqual requires equal length.
107
+ if (a.length !== b.length) return false
108
+ return timingSafeEqual(a, b)
109
+ }
110
+
111
+ function verifySignedToken(token: string, secrets: readonly string[]): boolean {
112
+ const dot = token.lastIndexOf('.')
113
+ if (dot <= 0) return false
114
+ const raw = token.slice(0, dot)
115
+ const sig = token.slice(dot + 1)
116
+ for (const secret of secrets) {
117
+ const expected = signToken(raw, secret)
118
+ if (expected.length !== sig.length) continue
119
+ if (timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return true
120
+ }
121
+ return false
122
+ }
123
+
124
+ // ───── Storage ─────────────────────────────────────────────────────────────
125
+
126
+ function readExpectedToken(ctx: IngeniumContext, opts: ResolvedOptions): string | null {
127
+ if (opts.storage === 'cookie') {
128
+ const cookies = parseCookies(ctx.headers['cookie'])
129
+ const token = cookies[opts.cookie.name]
130
+ if (!token) return null
131
+ if (opts.secrets.length > 0 && !verifySignedToken(token, opts.secrets)) return null
132
+ return token
133
+ }
134
+ // Session storage
135
+ const session = (ctx as IngeniumContext & { session?: { get: (k: string) => unknown } }).session
136
+ if (!session) {
137
+ throw new Error("csrfMiddleware: storage='session' requires sessionMiddleware to run first")
138
+ }
139
+ const token = session.get('csrfToken')
140
+ return typeof token === 'string' && token.length > 0 ? token : null
141
+ }
142
+
143
+ // TODO: migrate to ctx.cookies — kept inline because csrf uses the
144
+ // double-submit pattern with a non-HttpOnly cookie + HMAC value, which the
145
+ // generic cookie API doesn't model directly.
146
+ function writeCookie(ctx: IngeniumContext, token: string, cookie: Required<CsrfCookieOptions>): void {
147
+ const parts: string[] = [`${cookie.name}=${encodeURIComponent(token)}`]
148
+ parts.push(`Path=${cookie.path}`)
149
+ if (cookie.domain) parts.push(`Domain=${cookie.domain}`)
150
+ parts.push(`Max-Age=${cookie.maxAgeSeconds}`)
151
+ parts.push(`SameSite=${cookie.sameSite[0]!.toUpperCase() + cookie.sameSite.slice(1)}`)
152
+ if (cookie.secure) parts.push('Secure')
153
+ if (cookie.httpOnly) parts.push('HttpOnly')
154
+ appendSetCookie(ctx, parts.join('; '))
155
+ }
156
+
157
+ function writeSession(ctx: IngeniumContext, token: string): void {
158
+ const session = (ctx as IngeniumContext & { session?: { set: (k: string, v: unknown) => void } }).session
159
+ if (!session) return
160
+ session.set('csrfToken', token)
161
+ }
162
+
163
+ function appendSetCookie(ctx: IngeniumContext, value: string): void {
164
+ const existing = ctx._headers['set-cookie']
165
+ if (!existing) {
166
+ ctx._headers['set-cookie'] = [value]
167
+ } else if (Array.isArray(existing)) {
168
+ existing.push(value)
169
+ } else {
170
+ ctx._headers['set-cookie'] = [existing, value]
171
+ }
172
+ }
173
+
174
+ function parseCookies(header: string | string[] | undefined): Record<string, string> {
175
+ const out: Record<string, string> = {}
176
+ if (!header) return out
177
+ const flat = Array.isArray(header) ? header.join('; ') : header
178
+ for (const piece of flat.split(';')) {
179
+ const eq = piece.indexOf('=')
180
+ if (eq < 0) continue
181
+ const k = piece.slice(0, eq).trim()
182
+ const v = piece.slice(eq + 1).trim()
183
+ if (!k || k in out) continue // first occurrence wins
184
+ try {
185
+ out[k] = decodeURIComponent(v)
186
+ } catch {
187
+ out[k] = v
188
+ }
189
+ }
190
+ return out
191
+ }
192
+
193
+ // ───── Options resolution ──────────────────────────────────────────────────
194
+
195
+ function resolveOptions(opts: CsrfOptions): ResolvedOptions {
196
+ const secrets =
197
+ typeof opts.secret === 'string'
198
+ ? [opts.secret]
199
+ : Array.isArray(opts.secret)
200
+ ? [...opts.secret]
201
+ : []
202
+ const storage = opts.storage ?? 'cookie'
203
+ const cookie: Required<CsrfCookieOptions> = {
204
+ name: opts.cookie?.name ?? COOKIE_NAME_DEFAULT,
205
+ path: opts.cookie?.path ?? '/',
206
+ domain: opts.cookie?.domain ?? '',
207
+ sameSite: opts.cookie?.sameSite ?? 'lax',
208
+ secure: opts.cookie?.secure ?? false,
209
+ httpOnly: opts.cookie?.httpOnly ?? false,
210
+ maxAgeSeconds: opts.cookie?.maxAgeSeconds ?? 7 * 24 * 60 * 60,
211
+ }
212
+ const ignoreMethods = new Set((opts.ignoreMethods ?? SAFE_METHODS_DEFAULT).map((m) => m.toUpperCase()))
213
+ const value = opts.value ?? defaultValueReader
214
+ return { secrets, storage, cookie, ignoreMethods, value, skip: opts.skip ?? null }
215
+ }
216
+
217
+ const defaultValueReader: CsrfValueReader = (ctx) => {
218
+ for (const name of HEADER_NAMES_DEFAULT) {
219
+ const v = ctx.headers[name]
220
+ if (v) return Array.isArray(v) ? v[0] : v
221
+ }
222
+ const q = ctx.query.get('_csrf')
223
+ return q ?? undefined
224
+ }
@@ -0,0 +1,65 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+
3
+ /**
4
+ * Where the CSRF token lives between requests.
5
+ *
6
+ * - `'cookie'` (default): double-submit cookie pattern. Token is generated
7
+ * on safe requests, written to a non-HttpOnly cookie, and the client must
8
+ * echo it back via a header on unsafe requests. No session required.
9
+ * - `'session'`: synchronizer pattern. Token is stored on `ctx.session`
10
+ * and validated against the submitted token on unsafe requests. Requires
11
+ * `sessionMiddleware` to run before this middleware.
12
+ */
13
+ export type CsrfStorage = 'cookie' | 'session'
14
+
15
+ /** How to extract the submitted token from an incoming request. */
16
+ export type CsrfValueReader = (ctx: IngeniumContext) => string | undefined | Promise<string | undefined>
17
+
18
+ export interface CsrfCookieOptions {
19
+ /** Cookie name. Default `ingenium.csrf`. */
20
+ name?: string
21
+ /** Restrict cookie to a single subpath. Default `/`. */
22
+ path?: string
23
+ /** Restrict cookie to a domain. Default unset. */
24
+ domain?: string
25
+ /** SameSite policy. Default `'lax'`. */
26
+ sameSite?: 'lax' | 'strict' | 'none'
27
+ /** Mark cookie Secure. Default `false`; set `true` behind TLS. */
28
+ secure?: boolean
29
+ /**
30
+ * Mark cookie HttpOnly. **Default `false`** — clients must read the cookie
31
+ * to copy the value into the request header. Setting `true` would break the
32
+ * double-submit pattern; only enable with a custom value reader that pulls
33
+ * the token from elsewhere.
34
+ */
35
+ httpOnly?: boolean
36
+ /** Cookie max-age (seconds). Default 7 days. */
37
+ maxAgeSeconds?: number
38
+ }
39
+
40
+ export interface CsrfOptions {
41
+ /**
42
+ * HMAC secret used to sign the token. Required for the cookie storage
43
+ * mode (signed double-submit). For session storage the secret is optional
44
+ * — the session id already authenticates the binding.
45
+ */
46
+ secret?: string | string[]
47
+ /** Token storage strategy. Default `'cookie'`. */
48
+ storage?: CsrfStorage
49
+ /** Cookie options when `storage === 'cookie'`. */
50
+ cookie?: CsrfCookieOptions
51
+ /** Methods that bypass validation. Default `['GET', 'HEAD', 'OPTIONS', 'TRACE']`. */
52
+ ignoreMethods?: readonly string[]
53
+ /**
54
+ * How to extract the submitted token. Default reads (in order):
55
+ * 1. `X-CSRF-Token` header
56
+ * 2. `X-XSRF-Token` header (Angular convention)
57
+ * 3. `_csrf` query string parameter
58
+ */
59
+ value?: CsrfValueReader
60
+ /**
61
+ * Per-request opt-out. Return `true` to skip validation entirely for
62
+ * this request (and skip token issuance).
63
+ */
64
+ skip?: (ctx: IngeniumContext) => boolean | Promise<boolean>
65
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Base error class for all framework-emitted errors. Errors that extend
3
+ * `IngeniumError` are caught by the global error boundary and serialized to the
4
+ * client according to their `statusCode` and `code`.
5
+ */
6
+ export class IngeniumError extends Error {
7
+ /**
8
+ * @param statusCode HTTP status code to send to the client.
9
+ * @param code Machine-readable error code (UPPER_SNAKE_CASE convention).
10
+ * @param message Human-readable error message.
11
+ * @param cause Optional underlying error.
12
+ */
13
+ constructor(
14
+ public readonly statusCode: number,
15
+ public readonly code: string,
16
+ message: string,
17
+ public override readonly cause?: unknown,
18
+ ) {
19
+ super(message)
20
+ this.name = new.target.name
21
+ }
22
+ }
23
+
24
+ /** 404 — no route matched. */
25
+ export class IngeniumNotFoundError extends IngeniumError {
26
+ constructor(message = 'Not Found') {
27
+ super(404, 'NOT_FOUND', message)
28
+ }
29
+ }
30
+
31
+ /** 401 — authentication required or invalid. */
32
+ export class IngeniumUnauthorizedError extends IngeniumError {
33
+ constructor(message = 'Unauthorized') {
34
+ super(401, 'UNAUTHORIZED', message)
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 405 — path matched but method did not. Includes the list of allowed methods,
40
+ * which the framework writes into the `Allow` response header automatically.
41
+ */
42
+ export class IngeniumMethodNotAllowedError extends IngeniumError {
43
+ constructor(public readonly allowed: readonly string[], message = 'Method Not Allowed') {
44
+ super(405, 'METHOD_NOT_ALLOWED', message)
45
+ }
46
+ }
47
+
48
+ /** 413 — request body exceeded the configured `maxBytes` limit. */
49
+ export class IngeniumPayloadTooLargeError extends IngeniumError {
50
+ constructor(message = 'Payload Too Large') {
51
+ super(413, 'PAYLOAD_TOO_LARGE', message)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 422 — request body parsed successfully but failed validation. The `fields`
57
+ * map is serialized into the response body so clients can render field-level
58
+ * error messages.
59
+ */
60
+ export class IngeniumValidationError extends IngeniumError {
61
+ constructor(public readonly fields: Record<string, string>, message = 'Validation Failed') {
62
+ super(422, 'VALIDATION_FAILED', message)
63
+ }
64
+ }
65
+
66
+ /** 400 — request was malformed (bad JSON, invalid content-type, etc). */
67
+ export class IngeniumBadRequestError extends IngeniumError {
68
+ constructor(message = 'Bad Request', cause?: unknown) {
69
+ super(400, 'BAD_REQUEST', message, cause)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 500 — caller attempted to write a header name or value containing CR or
75
+ * LF. Node would eventually reject these at the wire level, but the late
76
+ * throw produces a useless stack — we fail fast at the call site so the
77
+ * offending header (and the route that set it) shows up in the trace.
78
+ */
79
+ export class IngeniumHeaderInjectionError extends IngeniumError {
80
+ constructor(message = 'Header value contains CR/LF (possible header injection)') {
81
+ super(500, 'HEADER_INJECTION', message)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 500 — `ctx.json` (or `respondJsonWithEtag`) was handed a value that
87
+ * `JSON.stringify` cannot serialize: a circular structure, a `BigInt`, or
88
+ * any other unsupported shape. The original `TypeError` is attached as
89
+ * `cause` and emitted via `process.emitWarning` for diagnostics.
90
+ */
91
+ export class IngeniumUnserializableError extends IngeniumError {
92
+ constructor(message: string, cause?: unknown) {
93
+ super(500, 'UNSERIALIZABLE_RESPONSE', message, cause)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Sinatra-style `halt` short-circuit. Thrown by `ctx.halt(status, body?)`;
99
+ * caught by the default error boundary and serialized according to `bodyShape`:
100
+ *
101
+ * - `'none'` → boundary uses default `{ error, code: 'HALT' }` JSON shape.
102
+ * - `'text'` → boundary writes `body` as `text/plain` verbatim.
103
+ * - `'json'` → boundary writes `body` as `application/json`.
104
+ *
105
+ * The body shape is decided at the call site (string ⇒ text, object ⇒ json,
106
+ * undefined ⇒ none) so the boundary can branch without re-inspecting types.
107
+ * Custom `app.onError` handlers still receive the error and can override it
108
+ * (e.g. add a header, reshape the body) by writing the response themselves.
109
+ */
110
+ export class IngeniumHaltError extends IngeniumError {
111
+ /** What the default error boundary should do with `body`. */
112
+ readonly bodyShape: 'none' | 'text' | 'json'
113
+ /** The body argument from `ctx.halt(status, body?)`. */
114
+ readonly body: string | Record<string, unknown> | undefined
115
+
116
+ constructor(statusCode: number, body?: string | Record<string, unknown>) {
117
+ let shape: 'none' | 'text' | 'json'
118
+ let message: string
119
+ if (body === undefined) {
120
+ shape = 'none'
121
+ message = `Halted with status ${statusCode}`
122
+ } else if (typeof body === 'string') {
123
+ shape = 'text'
124
+ message = body
125
+ } else {
126
+ shape = 'json'
127
+ // Best-effort message for ctx.error / logging; the JSON body is the
128
+ // wire-level payload regardless.
129
+ message = typeof body['error'] === 'string' ? (body['error'] as string) : 'HALT'
130
+ }
131
+ super(statusCode, 'HALT', message)
132
+ this.bodyShape = shape
133
+ this.body = body
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 503 — handler exceeded the configured `requestTimeoutMs` ceiling. The
139
+ * orphaned handler is NOT cancelled (JavaScript can't safely cancel a
140
+ * Promise); the framework just stops waiting for it. Late writes from the
141
+ * orphaned handler are guarded by the per-request epoch counter on the
142
+ * context and discarded with a `process.emitWarning`.
143
+ */
144
+ export class IngeniumTimeoutError extends IngeniumError {
145
+ constructor(timeoutMs: number, message?: string) {
146
+ super(503, 'REQUEST_TIMEOUT', message ?? `Request exceeded ${timeoutMs}ms`)
147
+ }
148
+ }
@@ -0,0 +1,197 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import type { IngeniumContext, ResponseBody } from '../context/context.ts'
3
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
4
+ import type { HttpMethod } from '../router/types.ts'
5
+ import { IdempotencyMemoryStore } from './store.ts'
6
+ import type {
7
+ CachedResponse,
8
+ IdempotencyOptions,
9
+ ResolvedIdempotencyOptions,
10
+ } from './types.ts'
11
+
12
+ const DEFAULT_METHODS: readonly HttpMethod[] = ['POST', 'PATCH', 'DELETE']
13
+
14
+ /**
15
+ * Default cacheable predicate: cache 2xx/3xx/4xx, NOT 5xx. Stripe
16
+ * convention — a transient 500 must not be replayed forever.
17
+ */
18
+ const DEFAULT_CACHEABLE = (status: number): boolean => status >= 200 && status < 500
19
+
20
+ /** Authorization-header-derived scope; falls back to `'anon'`. */
21
+ function defaultScope(ctx: IngeniumContext): string {
22
+ const auth = ctx.headers['authorization']
23
+ if (typeof auth === 'string' && auth.length > 0) return auth
24
+ if (Array.isArray(auth) && auth.length > 0 && typeof auth[0] === 'string') return auth[0]
25
+ return 'anon'
26
+ }
27
+
28
+ /** Pull a header value as a single string (first element if it came as an array). */
29
+ function readHeader(ctx: IngeniumContext, lowerName: string): string | undefined {
30
+ const v = ctx.headers[lowerName]
31
+ if (typeof v === 'string') return v
32
+ if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : undefined
33
+ return undefined
34
+ }
35
+
36
+ /**
37
+ * Snapshot whatever the handler wrote to `ctx`. Streams are NOT cached —
38
+ * we cannot rewind a `Readable`, so a streamed response makes the request
39
+ * non-idempotent (the second call will run the handler again).
40
+ *
41
+ * Returns `null` to signal "do not cache" (stream / nothing written).
42
+ */
43
+ function snapshot(ctx: IngeniumContext): CachedResponse | null {
44
+ if (!ctx._written) return null
45
+ const body = ctx._body
46
+ let serialized: string | Buffer | null
47
+ switch (body.kind) {
48
+ case 'none':
49
+ serialized = null
50
+ break
51
+ case 'string':
52
+ serialized = body.data
53
+ break
54
+ case 'buffer':
55
+ // Copy the buffer — caller may reuse the underlying memory.
56
+ serialized = Buffer.from(body.data)
57
+ break
58
+ case 'stream':
59
+ return null
60
+ }
61
+ // Shallow-copy headers; values are strings or string[] (immutable in practice).
62
+ const headersCopy: Record<string, string | string[]> = Object.create(null)
63
+ for (const k of Object.keys(ctx._headers)) {
64
+ const v = ctx._headers[k]
65
+ if (v === undefined) continue
66
+ headersCopy[k] = Array.isArray(v) ? [...v] : v
67
+ }
68
+ return { statusCode: ctx._statusCode, headers: headersCopy, body: serialized }
69
+ }
70
+
71
+ /** Replay a cached response onto a fresh `ctx`. */
72
+ function replay(ctx: IngeniumContext, cached: CachedResponse): void {
73
+ ctx._statusCode = cached.statusCode
74
+ // Replace, not merge — replayed response is authoritative.
75
+ ctx._headers = Object.create(null) as Record<string, string | string[]>
76
+ for (const k of Object.keys(cached.headers)) {
77
+ const v = cached.headers[k]
78
+ if (v === undefined) continue
79
+ ctx._headers[k] = Array.isArray(v) ? [...v] : v
80
+ }
81
+ ctx._headers['idempotent-replayed'] = 'true'
82
+ let nextBody: ResponseBody
83
+ if (cached.body === null) {
84
+ nextBody = { kind: 'none' }
85
+ } else if (typeof cached.body === 'string') {
86
+ nextBody = { kind: 'string', data: cached.body }
87
+ } else {
88
+ nextBody = { kind: 'buffer', data: Buffer.from(cached.body) }
89
+ }
90
+ ctx._body = nextBody
91
+ ctx._written = true
92
+ }
93
+
94
+ /**
95
+ * Idempotency-Key middleware (per Stripe / IETF idempotency-key draft).
96
+ *
97
+ * Behavior:
98
+ * - Non-mutating method or missing header → pass through.
99
+ * - Mutating method WITH header:
100
+ * 1. Build cache key: `<scope>:<method>:<path>:<idempotency-key>`.
101
+ * 2. Cache hit → replay the cached (status, headers, body) and set
102
+ * `Idempotent-Replayed: true`. Handler does NOT run.
103
+ * 3. Cache miss → run handler. If the response is cacheable (i.e. not a
104
+ * stream and something was written), persist it under the key with
105
+ * the configured TTL.
106
+ * 4. Concurrent in-flight requests for the same key are coordinated via
107
+ * an in-process Promise map: the second request awaits the first and
108
+ * replays its result.
109
+ *
110
+ * Note: the cache key intentionally does NOT include the request body —
111
+ * the spec assumes the client guarantees byte-for-byte identical retries,
112
+ * and reading the body at middleware-entry time would defeat lazy parsing.
113
+ *
114
+ * @example
115
+ * app.use(ingenium.idempotency({
116
+ * store: new IdempotencyMemoryStore(),
117
+ * ttlSeconds: 86_400,
118
+ * }))
119
+ */
120
+ export function idempotencyMiddleware(opts: IdempotencyOptions = {}): IngeniumMiddleware {
121
+ const resolved: ResolvedIdempotencyOptions = {
122
+ header: (opts.header ?? 'Idempotency-Key').toLowerCase(),
123
+ store: opts.store ?? new IdempotencyMemoryStore(),
124
+ ttlMs: (opts.ttlSeconds ?? 86_400) * 1000,
125
+ scope: opts.scope ?? defaultScope,
126
+ methodSet: new Set(opts.methods ?? DEFAULT_METHODS),
127
+ cacheable: opts.cacheable ?? DEFAULT_CACHEABLE,
128
+ }
129
+
130
+ if (resolved.ttlMs <= 0) {
131
+ throw new Error('idempotency: ttlSeconds must be > 0')
132
+ }
133
+
134
+ // Per-key in-flight map. The promise resolves once the first handler
135
+ // finishes and its response has been snapshotted (or with `null` if the
136
+ // response wasn't cacheable — second request then runs the handler).
137
+ const inflight: Map<string, Promise<CachedResponse | null>> = new Map()
138
+
139
+ return async (ctx, next) => {
140
+ if (!resolved.methodSet.has(ctx.method)) {
141
+ return next()
142
+ }
143
+
144
+ const headerValue = readHeader(ctx, resolved.header)
145
+ if (!headerValue || headerValue.length === 0) {
146
+ return next()
147
+ }
148
+
149
+ const scope = resolved.scope(ctx)
150
+ const cacheKey = `${scope}:${ctx.method}:${ctx.path}:${headerValue}`
151
+
152
+ // 1. Persisted cache hit?
153
+ const existing = await resolved.store.get(cacheKey)
154
+ if (existing) {
155
+ replay(ctx, existing)
156
+ return
157
+ }
158
+
159
+ // 2. In-flight from a concurrent request?
160
+ const pending = inflight.get(cacheKey)
161
+ if (pending) {
162
+ const result = await pending
163
+ if (result) {
164
+ replay(ctx, result)
165
+ return
166
+ }
167
+ // First request wasn't cacheable — fall through and run the handler.
168
+ }
169
+
170
+ // 3. Cache miss + no in-flight: take ownership.
171
+ let resolveInflight!: (value: CachedResponse | null) => void
172
+ const ownPromise = new Promise<CachedResponse | null>((res) => { resolveInflight = res })
173
+ inflight.set(cacheKey, ownPromise)
174
+
175
+ try {
176
+ await next()
177
+ const captured = snapshot(ctx)
178
+ // Honor the `cacheable` predicate — by default 5xx is NOT cached so a
179
+ // transient failure can't poison the key for the entire TTL. When
180
+ // skipped, resolve the in-flight promise with `null` so any waiter
181
+ // re-runs the handler instead of replaying a stale failure.
182
+ if (captured && resolved.cacheable(captured.statusCode)) {
183
+ await resolved.store.set(cacheKey, captured, resolved.ttlMs)
184
+ resolveInflight(captured)
185
+ } else {
186
+ resolveInflight(null)
187
+ }
188
+ } catch (err) {
189
+ // Don't cache failures — clear the in-flight slot so retries can run
190
+ // the handler fresh, and let the error propagate.
191
+ resolveInflight(null)
192
+ throw err
193
+ } finally {
194
+ inflight.delete(cacheKey)
195
+ }
196
+ }
197
+ }