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,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
|
+
}
|