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,72 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
3
+ import { MemoryStore } from './store.ts'
4
+ import type { RateLimitOptions } from './types.ts'
5
+
6
+ /** Default key generator — see RateLimitOptions.keyGenerator JSDoc. */
7
+ function defaultKeyGenerator(ctx: IngeniumContext): string {
8
+ const xff = ctx.headers['x-forwarded-for']
9
+ if (typeof xff === 'string' && xff.length > 0) {
10
+ const first = xff.split(',')[0]
11
+ const trimmed = first?.trim()
12
+ if (trimmed && trimmed.length > 0) return trimmed
13
+ }
14
+ const xri = ctx.headers['x-real-ip']
15
+ if (typeof xri === 'string' && xri.length > 0) return xri
16
+ return 'unknown'
17
+ }
18
+
19
+ /**
20
+ * Fixed-window rate-limiting middleware. Each key is allowed at most `max`
21
+ * requests per `windowMs`. Over-limit requests get a `429 Too Many
22
+ * Requests` response with `Retry-After` and a JSON body.
23
+ *
24
+ * Every passing response carries `X-RateLimit-Limit`,
25
+ * `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds).
26
+ *
27
+ * @example
28
+ * app.use(rateLimit({ max: 100, windowMs: 60_000 }))
29
+ * app.use('/auth', rateLimit({ max: 5, windowMs: 60_000 }))
30
+ */
31
+ export function rateLimit(opts: RateLimitOptions = {}): IngeniumMiddleware {
32
+ const windowMs = opts.windowMs ?? 60_000
33
+ const max = opts.max ?? 100
34
+ const keyGenerator = opts.keyGenerator ?? defaultKeyGenerator
35
+ const skip = opts.skip
36
+ const store = opts.store ?? new MemoryStore()
37
+
38
+ if (windowMs <= 0) throw new Error('rateLimit: windowMs must be > 0')
39
+ if (max <= 0) throw new Error('rateLimit: max must be > 0')
40
+
41
+ return async (ctx, next) => {
42
+ if (skip && skip(ctx)) {
43
+ return next()
44
+ }
45
+
46
+ const key = keyGenerator(ctx)
47
+ const { count, resetAt } = await store.hit(key, windowMs)
48
+
49
+ const remaining = Math.max(0, max - count)
50
+ const resetSeconds = Math.ceil(resetAt / 1000)
51
+
52
+ ctx.set('x-ratelimit-limit', String(max))
53
+ ctx.set('x-ratelimit-remaining', String(remaining))
54
+ ctx.set('x-ratelimit-reset', String(resetSeconds))
55
+
56
+ if (count > max) {
57
+ const retryAfter = Math.max(1, Math.ceil((resetAt - Date.now()) / 1000))
58
+ ctx.set('retry-after', String(retryAfter))
59
+ ctx.json(
60
+ {
61
+ error: 'Too Many Requests',
62
+ code: 'RATE_LIMITED',
63
+ retryAfter,
64
+ },
65
+ 429,
66
+ )
67
+ return
68
+ }
69
+
70
+ return next()
71
+ }
72
+ }
@@ -0,0 +1,129 @@
1
+ import type { RateLimitStore } from './types.ts'
2
+
3
+ interface Entry {
4
+ count: number
5
+ resetAt: number
6
+ }
7
+
8
+ /** Default cap on the number of distinct keys held in the in-memory store. */
9
+ const DEFAULT_MAX_ENTRIES = 100_000
10
+
11
+ export interface MemoryStoreOptions {
12
+ /**
13
+ * Hard ceiling on the number of distinct keys retained. When exceeded, the
14
+ * **least-recently-touched** entry is evicted to make room. Default
15
+ * `100_000`.
16
+ *
17
+ * The cap exists to bound memory under adversarial conditions: an attacker
18
+ * generating one request per unique IP would otherwise grow the map without
19
+ * bound. With the cap, the worst case is a fixed memory footprint and
20
+ * attackers' counters get evicted (which means they bypass rate-limiting
21
+ * for the exact endpoint they're hammering — a real trade-off, but better
22
+ * than OOM).
23
+ *
24
+ * For genuinely high-cardinality production workloads (millions of distinct
25
+ * users), prefer a Redis-backed store so eviction isn't required.
26
+ */
27
+ maxEntries?: number
28
+ }
29
+
30
+ /**
31
+ * In-process fixed-window counter store. Suitable for single-replica
32
+ * deployments and tests; swap for a Redis-backed store when running
33
+ * multiple replicas behind a load balancer.
34
+ *
35
+ * A periodic sweep removes expired entries every `windowMs` so long-lived
36
+ * processes don't leak memory across forgotten keys. The sweep timer is
37
+ * `.unref()`'d, so it never keeps the Node event loop alive.
38
+ *
39
+ * The `Map` itself is bounded by `maxEntries` (default 100k). When the cap
40
+ * is reached, the least-recently-touched entry is evicted before the new
41
+ * entry is inserted. We rely on the JS `Map` insertion-order guarantee:
42
+ * delete-then-set on an existing key moves it to the end, so the first
43
+ * iteration step always returns the genuine LRU. **This is intentional
44
+ * defense against scanner attacks that would otherwise OOM the process by
45
+ * generating unique keys.**
46
+ */
47
+ export class MemoryStore implements RateLimitStore {
48
+ private readonly map: Map<string, Entry> = new Map()
49
+ private sweeper: NodeJS.Timeout | null = null
50
+ private sweepIntervalMs = 0
51
+ private readonly maxEntries: number
52
+
53
+ constructor(opts: MemoryStoreOptions = {}) {
54
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES
55
+ if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
56
+ throw new RangeError(
57
+ `MemoryStore: maxEntries must be a positive integer, got ${String(opts.maxEntries)}`,
58
+ )
59
+ }
60
+ }
61
+
62
+ hit(key: string, windowMs: number): Promise<{ count: number; resetAt: number }> {
63
+ const now = Date.now()
64
+ const existing = this.map.get(key)
65
+
66
+ let entry: Entry
67
+ if (!existing || now >= existing.resetAt) {
68
+ // New (or expired) key — may need to evict before inserting.
69
+ if (!existing && this.map.size >= this.maxEntries) {
70
+ // Evict the LRU: Map preserves insertion order, so the first key in
71
+ // iteration is the oldest-touched (touch = delete+set on every hit).
72
+ const oldestKey = this.map.keys().next().value
73
+ if (oldestKey !== undefined) this.map.delete(oldestKey)
74
+ }
75
+ entry = { count: 1, resetAt: now + windowMs }
76
+ // Re-insert order: existing-but-expired keys also need delete+set so
77
+ // their order moves to the end (preserves LRU semantics).
78
+ if (existing) this.map.delete(key)
79
+ this.map.set(key, entry)
80
+ } else {
81
+ // Touch — move to end of insertion order so it's NOT the LRU candidate.
82
+ existing.count += 1
83
+ entry = existing
84
+ this.map.delete(key)
85
+ this.map.set(key, entry)
86
+ }
87
+
88
+ this.ensureSweeper(windowMs)
89
+ return Promise.resolve({ count: entry.count, resetAt: entry.resetAt })
90
+ }
91
+
92
+ reset(key: string): Promise<void> {
93
+ this.map.delete(key)
94
+ return Promise.resolve()
95
+ }
96
+
97
+ /**
98
+ * Stop the cleanup interval. Safe to call multiple times. Mostly useful
99
+ * in tests; production usage doesn't need this because the timer is
100
+ * already unref'd.
101
+ */
102
+ destroy(): void {
103
+ if (this.sweeper) {
104
+ clearInterval(this.sweeper)
105
+ this.sweeper = null
106
+ }
107
+ this.map.clear()
108
+ }
109
+
110
+ /** @internal Current entry count — exposed for ops/tests. */
111
+ get size(): number {
112
+ return this.map.size
113
+ }
114
+
115
+ private ensureSweeper(windowMs: number): void {
116
+ if (this.sweeper && this.sweepIntervalMs === windowMs) return
117
+ if (this.sweeper) clearInterval(this.sweeper)
118
+ this.sweepIntervalMs = windowMs
119
+ this.sweeper = setInterval(() => this.sweep(), windowMs)
120
+ if (typeof this.sweeper.unref === 'function') this.sweeper.unref()
121
+ }
122
+
123
+ private sweep(): void {
124
+ const now = Date.now()
125
+ for (const [key, entry] of this.map) {
126
+ if (now >= entry.resetAt) this.map.delete(key)
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,60 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+
3
+ /**
4
+ * Pluggable backing store for the rate-limit middleware. The default
5
+ * in-memory implementation is sync internally but exposes a Promise-based
6
+ * surface so a Redis (or other distributed) store can drop in unchanged.
7
+ */
8
+ export interface RateLimitStore {
9
+ /**
10
+ * Record a hit for `key`. Returns the new count and the unix-millis
11
+ * timestamp at which the current window expires.
12
+ *
13
+ * Implementations MUST roll the window over when `Date.now() >= resetAt`,
14
+ * resetting the count to 1.
15
+ */
16
+ hit(key: string, windowMs: number): Promise<{ count: number; resetAt: number }>
17
+
18
+ /** Clear the counter for `key`. Used by tests and by ops tooling. */
19
+ reset(key: string): Promise<void>
20
+ }
21
+
22
+ /**
23
+ * Options for {@link rateLimit}.
24
+ */
25
+ export interface RateLimitOptions {
26
+ /**
27
+ * Window length in milliseconds. Default: `60_000` (one minute).
28
+ *
29
+ * Each key is allowed at most `max` requests per window. Counts reset
30
+ * sharply at window boundaries (fixed-window algorithm).
31
+ */
32
+ windowMs?: number
33
+
34
+ /** Max requests per `windowMs` per key. Default: `100`. */
35
+ max?: number
36
+
37
+ /**
38
+ * Build the limiter key for a request. Default uses `X-Forwarded-For`
39
+ * (first hop), then `X-Real-IP`, then the literal string `'unknown'`.
40
+ *
41
+ * **Security**: the default trusts `X-Forwarded-For` blindly. Without
42
+ * an upstream that strips client-supplied values, this header is
43
+ * forgeable. Production deployments behind a proxy should validate the
44
+ * proxy chain or supply a custom `keyGenerator`.
45
+ */
46
+ keyGenerator?: (ctx: IngeniumContext) => string
47
+
48
+ /**
49
+ * Skip rate-limiting for a given request. When this returns `true`, no
50
+ * counter hit is recorded and no `X-RateLimit-*` headers are written.
51
+ * Default: never skip.
52
+ */
53
+ skip?: (ctx: IngeniumContext) => boolean
54
+
55
+ /**
56
+ * Backing store. Default: an in-process {@link MemoryStore}. Swap in a
57
+ * shared store (Redis etc.) when running multiple replicas.
58
+ */
59
+ store?: RateLimitStore
60
+ }
@@ -0,0 +1,93 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { Readable } from 'node:stream'
3
+ import type { IngeniumContext } from '../context/context.ts'
4
+
5
+ /**
6
+ * Dev-mode gate. Captured ONCE at module load; in production V8 dead-code-
7
+ * eliminates the branch bodies behind `if (IS_DEV)`. Every dev diagnostic
8
+ * MUST check this first so it pays nothing on the hot path.
9
+ */
10
+ const IS_DEV = process.env.NODE_ENV !== 'production'
11
+
12
+ /**
13
+ * @internal Once-per-process flag for the fetch-style `Response` warning.
14
+ * Exposed via `_resetReflectFootgunWarnings()` for tests.
15
+ */
16
+ let _responseObjectWarned = false
17
+
18
+ /** @internal Test-only — clear the once-flag for the fetch-Response warning. */
19
+ export function _resetReflectFootgunWarnings(): void {
20
+ _responseObjectWarned = false
21
+ }
22
+
23
+ /**
24
+ * Reflect a handler's return value to the response per the contract:
25
+ *
26
+ * | return type | wire output |
27
+ * |------------------------|-----------------------------------|
28
+ * | `undefined` / `null` | 204 (unless ctx wrote) |
29
+ * | string starting w/ `<` | 200 text/html |
30
+ * | other string | 200 text/plain |
31
+ * | `Buffer` / `Uint8Array`| 200 application/octet-stream |
32
+ * | `Readable` | 200 streamed |
33
+ * | any object/array | 200 application/json |
34
+ *
35
+ * If a `ctx.json/text/html/stream/redirect/send` helper has already been
36
+ * called, the return value is ignored.
37
+ */
38
+ export function reflectReturn(ctx: IngeniumContext, value: unknown): void {
39
+ if (ctx._written) return
40
+
41
+ if (value === undefined || value === null) {
42
+ ctx.status(204)
43
+ return
44
+ }
45
+
46
+ // Dev-only — catch the common mistake of returning a fetch-style `Response`
47
+ // object (e.g. `return new Response('hi')`). Ingenium handlers return plain
48
+ // values; interop with the Fetch Response shape is intentionally not
49
+ // supported. Warn once, then fall through to the 204 path so the framework
50
+ // doesn't accidentally JSON-serialize the Response object's enumerable bag.
51
+ if (IS_DEV && typeof Response !== 'undefined' && value instanceof Response) {
52
+ if (!_responseObjectWarned) {
53
+ _responseObjectWarned = true
54
+ try {
55
+ process.emitWarning(
56
+ 'Handler returned a fetch-style Response object. Ingenium handlers return plain values or call ctx.json/text/etc. The Response was ignored.',
57
+ { type: 'IngeniumResponseObjectWarning' },
58
+ )
59
+ } catch {
60
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
61
+ }
62
+ }
63
+ ctx.status(204)
64
+ return
65
+ }
66
+
67
+ if (typeof value === 'string') {
68
+ if (value.length > 0 && value.charCodeAt(0) === 60 /* '<' */) {
69
+ ctx.html(value)
70
+ } else {
71
+ ctx.text(value)
72
+ }
73
+ return
74
+ }
75
+
76
+ if (Buffer.isBuffer(value)) {
77
+ ctx.send(value)
78
+ return
79
+ }
80
+
81
+ if (value instanceof Uint8Array) {
82
+ ctx.send(Buffer.from(value))
83
+ return
84
+ }
85
+
86
+ if (value instanceof Readable) {
87
+ ctx.stream(value)
88
+ return
89
+ }
90
+
91
+ // Default: JSON-serialize anything else (objects, arrays, numbers, booleans).
92
+ ctx.json(value)
93
+ }
@@ -0,0 +1,284 @@
1
+ import type { IngeniumHandler, IngeniumMiddleware } from '../middleware/types.ts'
2
+ import type { ExtractParams, HttpMethod } from './types.ts'
3
+
4
+ /** A journal entry — replayed against the trie when the app composes. */
5
+ export type Registration =
6
+ | { kind: 'use-global'; mw: IngeniumMiddleware }
7
+ | { kind: 'use-prefix'; prefix: string; mw: IngeniumMiddleware }
8
+ | { kind: 'use-router'; prefix: string; router: Router }
9
+ | {
10
+ kind: 'route'
11
+ method: HttpMethod
12
+ path: string
13
+ handler: IngeniumHandler
14
+ /**
15
+ * Inline middleware passed positionally to `app.get(path, mw1, mw2, handler)`
16
+ * (and the equivalent declarative-options form on `IngeniumApp`). Spliced into
17
+ * the composed chain AFTER global + scoped middleware AND BEFORE the handler.
18
+ * `undefined` for the back-compat single-arg form.
19
+ */
20
+ inlineMiddleware?: IngeniumMiddleware[]
21
+ }
22
+
23
+ /**
24
+ * Variadic route-arg shape: zero or more middleware followed by exactly one
25
+ * handler at the tail. The TypeScript trick `[...IngeniumMiddleware[], IngeniumHandler]`
26
+ * forces the tail position to be the handler while everything before it is
27
+ * middleware — preserves Express's `app.get(path, ...mw, handler)` ergonomics.
28
+ */
29
+ export type RouteArgs<P = Record<string, string>> =
30
+ | [IngeniumHandler<P>]
31
+ | [...IngeniumMiddleware[], IngeniumHandler<P>]
32
+
33
+ /**
34
+ * A mountable router. Registrations are journaled, not eagerly composed —
35
+ * mounting via `app.use('/api', router)` replays this journal into the
36
+ * parent's trie with the prefix prepended.
37
+ */
38
+ export class Router {
39
+ /** @internal */ readonly journal: Registration[] = []
40
+
41
+ /** Add middleware that runs for every request below this router. */
42
+ use(mw: IngeniumMiddleware): this
43
+ /** Mount middleware or a sub-router at a path prefix. */
44
+ use(prefix: string, mw: IngeniumMiddleware | Router): this
45
+ use(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware | Router): this {
46
+ if (typeof arg1 === 'string') {
47
+ const prefix = normalizePrefix(arg1)
48
+ if (arg2 instanceof Router) {
49
+ this.journal.push({ kind: 'use-router', prefix, router: arg2 })
50
+ } else if (typeof arg2 === 'function') {
51
+ this.journal.push({ kind: 'use-prefix', prefix, mw: arg2 })
52
+ } else {
53
+ throw new TypeError(`Router.use(prefix, value): value must be a middleware function or a Router`)
54
+ }
55
+ } else if (typeof arg1 === 'function') {
56
+ this.journal.push({ kind: 'use-global', mw: arg1 })
57
+ } else {
58
+ throw new TypeError(`Router.use(): first argument must be a path string or middleware function`)
59
+ }
60
+ return this
61
+ }
62
+
63
+ // ───── Verb registration ──────────────────────────────────────────────
64
+ // Each verb supports the back-compat `(path, handler)` shape AND the
65
+ // variadic `(path, ...inlineMiddleware, handler)` shape Express uses. The
66
+ // overloads keep TypeScript happy with the "handler is always last" rule.
67
+
68
+ get<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
69
+ get<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
70
+ get<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
71
+ return this.method('GET', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
72
+ }
73
+ post<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
74
+ post<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
75
+ post<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
76
+ return this.method('POST', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
77
+ }
78
+ put<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
79
+ put<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
80
+ put<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
81
+ return this.method('PUT', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
82
+ }
83
+ patch<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
84
+ patch<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
85
+ patch<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
86
+ return this.method('PATCH', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
87
+ }
88
+ delete<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
89
+ delete<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
90
+ delete<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
91
+ return this.method('DELETE', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
92
+ }
93
+ head<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
94
+ head<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
95
+ head<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
96
+ return this.method('HEAD', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
97
+ }
98
+ options<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this
99
+ options<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
100
+ options<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this {
101
+ return this.method('OPTIONS', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
102
+ }
103
+
104
+ /**
105
+ * Chainable per-path registration. Returns a builder that holds the path
106
+ * and lets you stack verbs on it without retyping:
107
+ *
108
+ * @example
109
+ * router
110
+ * .route('/users/:id')
111
+ * .get((ctx) => loadUser(ctx.params.id))
112
+ * .put(requireAdmin, (ctx) => updateUser(ctx))
113
+ * .delete(requireAdmin, (ctx) => deleteUser(ctx))
114
+ *
115
+ * Pure registration sugar — every call delegates to `router.method(...)`,
116
+ * so all features (inline middleware, declarative options, typed params
117
+ * via `ExtractParams<P>`) work identically.
118
+ */
119
+ route<P extends string>(path: P): RouteBuilder<P> {
120
+ return new RouteBuilder<P>((method, args) =>
121
+ (this.method as (m: HttpMethod, p: string, ...a: unknown[]) => unknown)(method, path, ...args),
122
+ )
123
+ }
124
+
125
+ /**
126
+ * Internal — register a route under any HTTP method. Accepts the variadic
127
+ * `(...inlineMiddleware, handler)` tail; the LAST positional arg is always
128
+ * the handler.
129
+ */
130
+ method(method: HttpMethod, path: string, handler: IngeniumHandler): this
131
+ method(method: HttpMethod, path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
132
+ method(method: HttpMethod, path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this {
133
+ if (args.length === 0) {
134
+ throw new TypeError(`Router.${method.toLowerCase()}('${path}'): handler is required`)
135
+ }
136
+ const handler = args[args.length - 1] as IngeniumHandler
137
+ if (typeof handler !== 'function') {
138
+ throw new TypeError(
139
+ `Router.${method.toLowerCase()}('${path}'): last argument must be a handler function`,
140
+ )
141
+ }
142
+ const inline = args.slice(0, -1) as IngeniumMiddleware[]
143
+ for (let i = 0; i < inline.length; i++) {
144
+ if (typeof inline[i] !== 'function') {
145
+ throw new TypeError(
146
+ `Router.${method.toLowerCase()}('${path}'): inline middleware at position ${i} is not a function`,
147
+ )
148
+ }
149
+ }
150
+ const entry: Registration = {
151
+ kind: 'route',
152
+ method,
153
+ path: normalizePath(path),
154
+ handler,
155
+ }
156
+ if (inline.length > 0) entry.inlineMiddleware = inline
157
+ this.journal.push(entry)
158
+ return this
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Per-path chainable builder returned by `app.route(path)` and
164
+ * `router.route(path)`. Holds the path and an "emit" callback that registers
165
+ * a route on the underlying host (an `IngeniumApp` or a `Router`); the
166
+ * builder itself is just sugar — no per-request cost, no separate dispatch
167
+ * path. The host's verb method does all the validation, dirty-bit flipping,
168
+ * and journal writes.
169
+ *
170
+ * The generic `P` flows `ExtractParams<P>` into every handler signature so
171
+ * `app.route('/users/:id').get(ctx => ctx.params.id)` narrows `ctx.params`
172
+ * exactly like the bare verb form does.
173
+ */
174
+ export class RouteBuilder<P extends string> {
175
+ constructor(private readonly emit: (method: HttpMethod, args: unknown[]) => void) {}
176
+
177
+ get(handler: IngeniumHandler<ExtractParams<P>>): this
178
+ get(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
179
+ get(...args: unknown[]): this { this.emit('GET', args); return this }
180
+
181
+ post(handler: IngeniumHandler<ExtractParams<P>>): this
182
+ post(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
183
+ post(...args: unknown[]): this { this.emit('POST', args); return this }
184
+
185
+ put(handler: IngeniumHandler<ExtractParams<P>>): this
186
+ put(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
187
+ put(...args: unknown[]): this { this.emit('PUT', args); return this }
188
+
189
+ patch(handler: IngeniumHandler<ExtractParams<P>>): this
190
+ patch(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
191
+ patch(...args: unknown[]): this { this.emit('PATCH', args); return this }
192
+
193
+ delete(handler: IngeniumHandler<ExtractParams<P>>): this
194
+ delete(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
195
+ delete(...args: unknown[]): this { this.emit('DELETE', args); return this }
196
+
197
+ head(handler: IngeniumHandler<ExtractParams<P>>): this
198
+ head(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
199
+ head(...args: unknown[]): this { this.emit('HEAD', args); return this }
200
+
201
+ options(handler: IngeniumHandler<ExtractParams<P>>): this
202
+ options(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this
203
+ options(...args: unknown[]): this { this.emit('OPTIONS', args); return this }
204
+
205
+ /** Register the same handler for all common HTTP methods (GET, POST, PUT, PATCH, DELETE). */
206
+ all(handler: IngeniumHandler<ExtractParams<P>>): this {
207
+ for (const m of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) this.emit(m, [handler])
208
+ return this
209
+ }
210
+ }
211
+
212
+ /** Strip trailing slash; ensure leading slash. Empty string is allowed (means "no prefix"). */
213
+ function normalizePrefix(p: string): string {
214
+ if (p === '' || p === '/') return ''
215
+ let out = p
216
+ if (out[0] !== '/') out = '/' + out
217
+ if (out.length > 1 && out[out.length - 1] === '/') out = out.slice(0, -1)
218
+ return out
219
+ }
220
+
221
+ function normalizePath(p: string): string {
222
+ if (!p) return '/'
223
+ if (p[0] !== '/') return '/' + p
224
+ return p
225
+ }
226
+
227
+ /**
228
+ * Flatten a router's journal into resolved registrations against the parent,
229
+ * applying the given prefix and inheriting any router-scoped middleware.
230
+ *
231
+ * Returns:
232
+ * - global middleware to apply to ALL routes inside the prefix
233
+ * - prefix-scoped middleware (with its own sub-prefix relative to root)
234
+ * - routes with their final composed paths
235
+ *
236
+ * Used by the App at compose time.
237
+ */
238
+ export interface FlatRegistrations {
239
+ globalMiddleware: IngeniumMiddleware[] // unscoped (matches every request)
240
+ scopedMiddleware: { prefix: string; mw: IngeniumMiddleware }[]
241
+ routes: {
242
+ method: HttpMethod
243
+ path: string
244
+ handler: IngeniumHandler
245
+ /** Inline middleware survives the flatten so app.compose() can splice it in. */
246
+ inlineMiddleware?: IngeniumMiddleware[]
247
+ }[]
248
+ }
249
+
250
+ export function flattenRouter(router: Router, prefix: string = ''): FlatRegistrations {
251
+ const out: FlatRegistrations = { globalMiddleware: [], scopedMiddleware: [], routes: [] }
252
+ flattenInto(router, prefix, out)
253
+ return out
254
+ }
255
+
256
+ function flattenInto(router: Router, prefix: string, out: FlatRegistrations): void {
257
+ for (const entry of router.journal) {
258
+ switch (entry.kind) {
259
+ case 'use-global':
260
+ // A "global" registration inside a mounted router is actually scoped to the mount prefix.
261
+ if (prefix === '') out.globalMiddleware.push(entry.mw)
262
+ else out.scopedMiddleware.push({ prefix, mw: entry.mw })
263
+ break
264
+ case 'use-prefix':
265
+ out.scopedMiddleware.push({ prefix: prefix + entry.prefix, mw: entry.mw })
266
+ break
267
+ case 'use-router':
268
+ flattenInto(entry.router, prefix + entry.prefix, out)
269
+ break
270
+ case 'route': {
271
+ const route: FlatRegistrations['routes'][number] = {
272
+ method: entry.method,
273
+ path: prefix + entry.path,
274
+ handler: entry.handler,
275
+ }
276
+ if (entry.inlineMiddleware && entry.inlineMiddleware.length > 0) {
277
+ route.inlineMiddleware = entry.inlineMiddleware
278
+ }
279
+ out.routes.push(route)
280
+ break
281
+ }
282
+ }
283
+ }
284
+ }