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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4262 @@
|
|
|
1
|
+
import { Buffer as Buffer$1 } from 'node:buffer';
|
|
2
|
+
import * as node_http from 'node:http';
|
|
3
|
+
import { IncomingHttpHeaders, Server as Server$1 } from 'node:http';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { KeyObject } from 'node:crypto';
|
|
6
|
+
import { WebSocket as WebSocket$1 } from 'ws';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Trust-proxy resolution for `X-Forwarded-*` headers.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors Express's `app.set('trust proxy', ...)` semantics:
|
|
12
|
+
* - `false` (default): never trust XFF — `ctx.ip` always reflects the immediate
|
|
13
|
+
* socket peer.
|
|
14
|
+
* - `true`: trust the entire `X-Forwarded-For` chain — last entry wins.
|
|
15
|
+
* - `number n`: trust `n` upstream hops — return chain entry `n` from the right.
|
|
16
|
+
* - `string` (single CIDR/IP/keyword) or `string[]` (list): trust connections
|
|
17
|
+
* from these addresses; walk the chain skipping trusted IPs.
|
|
18
|
+
* - `(ip, hopIdx) => boolean`: custom predicate, called per chain entry.
|
|
19
|
+
*
|
|
20
|
+
* Supported keywords: `'loopback'` (127.0.0.0/8, ::1), `'linklocal'`
|
|
21
|
+
* (169.254.0.0/16, fe80::/10), `'uniquelocal'` (10/8, 172.16/12, 192.168/16,
|
|
22
|
+
* fc00::/7). CIDRs accepted in IPv4 dotted (`10.0.0.0/8`) and IPv6
|
|
23
|
+
* (`fc00::/7`) form. Single addresses without `/` match exactly.
|
|
24
|
+
*/
|
|
25
|
+
type TrustProxy = boolean | number | string | string[] | ((ip: string, hopIdx: number) => boolean);
|
|
26
|
+
interface ForwardedInfo {
|
|
27
|
+
/** The resolved client IP after walking the trusted hop chain. */
|
|
28
|
+
ip: string;
|
|
29
|
+
/** Full forwarded chain, left-to-right (closest to client first), plus the immediate peer at the end. */
|
|
30
|
+
ips: readonly string[];
|
|
31
|
+
/** Best-effort protocol: `http` or `https`. */
|
|
32
|
+
protocol: 'http' | 'https';
|
|
33
|
+
/** Best-effort hostname (no port). */
|
|
34
|
+
hostname: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve forwarded info from raw headers + the immediate socket peer.
|
|
38
|
+
*
|
|
39
|
+
* @param trust The `trustProxy` configuration.
|
|
40
|
+
* @param remoteAddress The socket-level peer address (always present).
|
|
41
|
+
* @param headers Lowercased request headers (Node convention).
|
|
42
|
+
* @param defaultProtocol The protocol of the underlying transport (`http` for `node:http`,
|
|
43
|
+
* `https` for TLS, `http` for h2c, `https` for h2/TLS).
|
|
44
|
+
*/
|
|
45
|
+
declare function resolveForwarded(trust: TrustProxy, remoteAddress: string, headers: Readonly<Record<string, string | string[] | undefined>>, defaultProtocol?: 'http' | 'https'): ForwardedInfo;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Background-job type surface for Ingenium.
|
|
49
|
+
*
|
|
50
|
+
* The core abstraction is a {@link QueueStore} — a pluggable persistence layer
|
|
51
|
+
* for FIFO jobs with retries and a dead-letter list. The default implementation
|
|
52
|
+
* ({@link MemoryQueueStore}) keeps everything in process; a Redis adapter can
|
|
53
|
+
* land later by simply implementing this interface.
|
|
54
|
+
*
|
|
55
|
+
* A {@link IngeniumQueue} wraps a store with a worker pool, retry policy, and
|
|
56
|
+
* pause/resume/drain controls; a {@link QueueRegistry} (held by `IngeniumApp`)
|
|
57
|
+
* indexes named queues so route handlers can enqueue from any code path.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Worker function for a registered queue. Throwing causes a retry per the
|
|
61
|
+
* configured {@link RetryPolicy}; resolving means the job is `ack`'d.
|
|
62
|
+
*/
|
|
63
|
+
type QueueWorker<TData> = (job: {
|
|
64
|
+
/** Stable id assigned by the store at enqueue time. */
|
|
65
|
+
id: string;
|
|
66
|
+
/** Job payload (the value passed to `add`). */
|
|
67
|
+
data: TData;
|
|
68
|
+
/**
|
|
69
|
+
* 1-indexed attempt counter. `1` on first delivery; incremented before each
|
|
70
|
+
* retry. Use this in the worker to back off external calls or short-circuit
|
|
71
|
+
* non-recoverable failures.
|
|
72
|
+
*/
|
|
73
|
+
attempt: number;
|
|
74
|
+
}) => unknown | Promise<unknown>;
|
|
75
|
+
/**
|
|
76
|
+
* Retry policy. The first attempt is included in the count: `attempts: 3`
|
|
77
|
+
* means one initial try + two retries.
|
|
78
|
+
*/
|
|
79
|
+
interface RetryPolicy {
|
|
80
|
+
/** Total tries including the first delivery. Must be `>= 1`. */
|
|
81
|
+
attempts: number;
|
|
82
|
+
/**
|
|
83
|
+
* Delay (ms) before the NEXT attempt, given the attempt that just failed
|
|
84
|
+
* (1-indexed). E.g. for `attempts: 3`, this is called with `1` then `2`.
|
|
85
|
+
*/
|
|
86
|
+
backoffMs: (attempt: number) => number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Options for {@link IngeniumApp.queue}. All fields optional.
|
|
90
|
+
*
|
|
91
|
+
* @typeParam TData - shape of job payloads enqueued onto this queue
|
|
92
|
+
*/
|
|
93
|
+
interface QueueOptions<TData> {
|
|
94
|
+
/**
|
|
95
|
+
* Max concurrent jobs processed in parallel by this queue's worker pool.
|
|
96
|
+
* Default `1` (strict FIFO). Bump this for I/O-bound work.
|
|
97
|
+
*/
|
|
98
|
+
concurrency?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Retry policy on worker throw. Numeric shorthand `n` is equivalent to
|
|
101
|
+
* `{ attempts: n, backoffMs: exponential }`. Default: 3 attempts at
|
|
102
|
+
* 100ms / 400ms / 1.6s.
|
|
103
|
+
*/
|
|
104
|
+
retries?: number | RetryPolicy;
|
|
105
|
+
/**
|
|
106
|
+
* Custom store. Default {@link MemoryQueueStore}. Implement this to back
|
|
107
|
+
* the queue with Redis / Postgres / SQS / etc.
|
|
108
|
+
*/
|
|
109
|
+
store?: QueueStore<TData>;
|
|
110
|
+
/**
|
|
111
|
+
* Called once retries are exhausted, just before the job is moved to the
|
|
112
|
+
* dead-letter list. Throwing here is logged and swallowed — the job is
|
|
113
|
+
* still moved to the DLQ.
|
|
114
|
+
*/
|
|
115
|
+
onFailed?: (job: FailedJob<TData>) => void | Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Pluggable persistence layer. The default {@link MemoryQueueStore} keeps
|
|
119
|
+
* pending and failed jobs in arrays + an in-flight map. A Redis adapter
|
|
120
|
+
* would map this onto LPUSH / BRPOPLPUSH / a processing list / a DLQ list.
|
|
121
|
+
*
|
|
122
|
+
* Implementations MUST guarantee at-least-once delivery (a `next()`-ed
|
|
123
|
+
* job that is neither `ack`'d nor `retry`'d nor `fail`'d is considered
|
|
124
|
+
* stuck and may be re-delivered by the store on its own schedule).
|
|
125
|
+
*/
|
|
126
|
+
interface QueueStore<TData> {
|
|
127
|
+
/** Append a job to the tail. Returns the assigned id. */
|
|
128
|
+
enqueue(data: TData): Promise<{
|
|
129
|
+
id: string;
|
|
130
|
+
}>;
|
|
131
|
+
/**
|
|
132
|
+
* Pop the next pending job and move it to the in-flight set. Returns
|
|
133
|
+
* `null` when the queue is empty. The returned `attempt` reflects how
|
|
134
|
+
* many times this job has been delivered (1 on first delivery).
|
|
135
|
+
*/
|
|
136
|
+
next(): Promise<{
|
|
137
|
+
id: string;
|
|
138
|
+
data: TData;
|
|
139
|
+
attempt: number;
|
|
140
|
+
} | null>;
|
|
141
|
+
/** Mark an in-flight job as completed. Removes it from the store. */
|
|
142
|
+
ack(id: string): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Re-enqueue the in-flight job for another attempt after `delayMs`.
|
|
145
|
+
* The store MUST increment its internal attempt counter so the next
|
|
146
|
+
* `next()` returns it with the bumped count.
|
|
147
|
+
*/
|
|
148
|
+
retry(id: string, delayMs: number): Promise<void>;
|
|
149
|
+
/** Move the in-flight job to the dead-letter list. */
|
|
150
|
+
fail(id: string): Promise<void>;
|
|
151
|
+
/** Number of pending (not in-flight, not failed) jobs. */
|
|
152
|
+
size(): Promise<number>;
|
|
153
|
+
/** Number of jobs in the dead-letter list. */
|
|
154
|
+
failedCount(): Promise<number>;
|
|
155
|
+
}
|
|
156
|
+
/** Payload passed to {@link QueueOptions.onFailed} when retries are exhausted. */
|
|
157
|
+
interface FailedJob<TData> {
|
|
158
|
+
id: string;
|
|
159
|
+
data: TData;
|
|
160
|
+
/** Final attempt number (== `retries.attempts`). */
|
|
161
|
+
attempt: number;
|
|
162
|
+
/** Whatever the worker threw on its last attempt. */
|
|
163
|
+
lastError: unknown;
|
|
164
|
+
}
|
|
165
|
+
/** Per-request handle returned by `ctx.queue(name)`. */
|
|
166
|
+
interface JobHandle<TData = unknown> {
|
|
167
|
+
/** Enqueue `data`. Resolves to the assigned job id. */
|
|
168
|
+
add(data: TData): Promise<{
|
|
169
|
+
id: string;
|
|
170
|
+
}>;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Bookkeeping wrapper a {@link QueueRegistry} keeps for every registered
|
|
174
|
+
* queue. Mostly useful for introspection / tests.
|
|
175
|
+
*/
|
|
176
|
+
interface RegisteredQueue<TData = unknown> {
|
|
177
|
+
name: string;
|
|
178
|
+
options: Required<Pick<QueueOptions<TData>, 'concurrency'>> & {
|
|
179
|
+
retries: RetryPolicy;
|
|
180
|
+
onFailed: ((job: FailedJob<TData>) => void | Promise<void>) | undefined;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Options for `IngeniumBody.multipart()`.
|
|
186
|
+
*
|
|
187
|
+
* All limits are validated mid-parse — exceeding any of them throws before
|
|
188
|
+
* the full body is fully decoded so memory usage stays bounded.
|
|
189
|
+
*/
|
|
190
|
+
interface MultipartOptions {
|
|
191
|
+
/** Total request body cap. Default 100,000 bytes (matches Express's body-parser default). */
|
|
192
|
+
maxBytes?: number;
|
|
193
|
+
/** Per-file size cap. Default 10 * 1024 * 1024 (10 MiB). */
|
|
194
|
+
maxFileSize?: number;
|
|
195
|
+
/** Maximum number of file parts in one request. Default 20. */
|
|
196
|
+
maxFiles?: number;
|
|
197
|
+
/** Maximum number of plain (non-file) field parts. Default 100. */
|
|
198
|
+
maxFields?: number;
|
|
199
|
+
/** Allowed MIME prefixes (e.g. ['image/']). File rejected if no prefix matches. Default: any. */
|
|
200
|
+
allowedMimePrefixes?: string[];
|
|
201
|
+
}
|
|
202
|
+
/** A single uploaded file part, fully buffered. */
|
|
203
|
+
interface MultipartFile {
|
|
204
|
+
/** Original filename as supplied by the client. */
|
|
205
|
+
filename: string;
|
|
206
|
+
/** MIME type from the part's `Content-Type` header (defaults to `application/octet-stream`). */
|
|
207
|
+
mimeType: string;
|
|
208
|
+
/** Byte length of `data`. */
|
|
209
|
+
size: number;
|
|
210
|
+
/** Raw file bytes — fully buffered. For very large uploads, prefer `ctx.body.stream()`. */
|
|
211
|
+
data: Buffer$1;
|
|
212
|
+
}
|
|
213
|
+
/** Result of parsing a `multipart/form-data` body. */
|
|
214
|
+
interface MultipartResult {
|
|
215
|
+
/** Plain-text form fields keyed by name. Repeated names collapse into an array. */
|
|
216
|
+
fields: Record<string, string | string[]>;
|
|
217
|
+
/** File parts keyed by name. Repeated names collapse into an array. */
|
|
218
|
+
files: Record<string, MultipartFile | MultipartFile[]>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Local, zero-dependency type definitions for the
|
|
223
|
+
* [Standard Schema](https://standardschema.dev) v1 spec.
|
|
224
|
+
*
|
|
225
|
+
* Ingenium detects schemas implementing this contract on
|
|
226
|
+
* `IngeniumBody.json(schema)` and runs their `validate` function, mapping
|
|
227
|
+
* `issues` into a `IngeniumValidationError` with field-level messages.
|
|
228
|
+
*
|
|
229
|
+
* We intentionally do NOT import `@standard-schema/spec` to keep the
|
|
230
|
+
* core dependency-free. These types mirror the spec exactly.
|
|
231
|
+
*/
|
|
232
|
+
/** A successful validation result: parsed/transformed value. */
|
|
233
|
+
interface StandardSuccessResult<TOut> {
|
|
234
|
+
readonly value: TOut;
|
|
235
|
+
readonly issues?: undefined;
|
|
236
|
+
}
|
|
237
|
+
/** A single issue describing why validation failed at a particular path. */
|
|
238
|
+
interface StandardIssue {
|
|
239
|
+
readonly message: string;
|
|
240
|
+
readonly path?: ReadonlyArray<PropertyKey | StandardPathSegment> | undefined;
|
|
241
|
+
}
|
|
242
|
+
/** A path segment may be a bare key OR an object with a `key` property. */
|
|
243
|
+
interface StandardPathSegment {
|
|
244
|
+
readonly key: PropertyKey;
|
|
245
|
+
}
|
|
246
|
+
/** A failed validation result: one or more issues. */
|
|
247
|
+
interface StandardFailureResult {
|
|
248
|
+
readonly issues: ReadonlyArray<StandardIssue>;
|
|
249
|
+
readonly value?: undefined;
|
|
250
|
+
}
|
|
251
|
+
/** Standard Schema validation result: success XOR failure. */
|
|
252
|
+
type StandardResult<TOut> = StandardSuccessResult<TOut> | StandardFailureResult;
|
|
253
|
+
/** The properties living under the `~standard` key. */
|
|
254
|
+
interface StandardSchemaV1Props<TIn = unknown, TOut = TIn> {
|
|
255
|
+
readonly version: 1;
|
|
256
|
+
readonly vendor: string;
|
|
257
|
+
readonly validate: (input: unknown) => StandardResult<TOut> | Promise<StandardResult<TOut>>;
|
|
258
|
+
readonly types?: {
|
|
259
|
+
readonly input: TIn;
|
|
260
|
+
readonly output: TOut;
|
|
261
|
+
} | undefined;
|
|
262
|
+
}
|
|
263
|
+
/** The Standard Schema v1 interface — anything with a `~standard` property. */
|
|
264
|
+
interface StandardSchemaV1<TIn = unknown, TOut = TIn> {
|
|
265
|
+
readonly '~standard': StandardSchemaV1Props<TIn, TOut>;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Type guard: is `x` a Standard Schema v1?
|
|
269
|
+
*
|
|
270
|
+
* Checks for the `~standard` property and that its `version` is `1` and
|
|
271
|
+
* `validate` is a function. Cheap enough to call on every body.json() call.
|
|
272
|
+
*/
|
|
273
|
+
declare function isStandardSchema(x: unknown): x is StandardSchemaV1;
|
|
274
|
+
|
|
275
|
+
/** Minimal duck-type for any validation library that accepts unknown and returns a typed value. */
|
|
276
|
+
interface ParseSchema<T> {
|
|
277
|
+
parse(input: unknown): T;
|
|
278
|
+
}
|
|
279
|
+
/** Optional Zod-like schema: success/failure object output (used internally for friendlier errors). */
|
|
280
|
+
interface SafeParseSchema<T> {
|
|
281
|
+
safeParse(input: unknown): {
|
|
282
|
+
success: true;
|
|
283
|
+
data: T;
|
|
284
|
+
} | {
|
|
285
|
+
success: false;
|
|
286
|
+
error: {
|
|
287
|
+
issues: ZodLikeIssue[];
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
interface ZodLikeIssue {
|
|
292
|
+
path: ReadonlyArray<string | number>;
|
|
293
|
+
message: string;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Lazy body accessor. Bytes are not read until one of the consume methods
|
|
297
|
+
* (`json`, `text`, `urlencoded`, `buffer`, `stream`) is called.
|
|
298
|
+
*
|
|
299
|
+
* One instance is allocated per `IngeniumContext` (pool-bound), so per-request
|
|
300
|
+
* cost is just a `reset()`.
|
|
301
|
+
*/
|
|
302
|
+
declare class IngeniumBody {
|
|
303
|
+
/** @internal */ _source: Readable | null;
|
|
304
|
+
/** @internal */ _consumed: boolean;
|
|
305
|
+
/** @internal */ _contentType: string | undefined;
|
|
306
|
+
/** @internal */ _contentLength: number | undefined;
|
|
307
|
+
/**
|
|
308
|
+
* @internal
|
|
309
|
+
* Parse cache. Stores the raw body bytes after the first successful
|
|
310
|
+
* `buffer()` (or `text()` / `json()` / `urlencoded()`, which all go
|
|
311
|
+
* through `buffer()`). Subsequent buffer-producing consumers reuse
|
|
312
|
+
* these bytes instead of throwing "already consumed".
|
|
313
|
+
*
|
|
314
|
+
* Caches the RAW Buffer (not parsed objects) so different callers can
|
|
315
|
+
* apply different schemas / decoders against the same bytes — a
|
|
316
|
+
* common pattern when an audit middleware reads the body before the
|
|
317
|
+
* handler does. Re-parsing JSON from a cached buffer is cheap; mixing
|
|
318
|
+
* schemas against a cached parsed object would be incorrect.
|
|
319
|
+
*
|
|
320
|
+
* `stream()` opts out (it hands the caller ownership of the raw
|
|
321
|
+
* Readable) and `multipart()` opts out (its result is bespoke and
|
|
322
|
+
* re-parsing with different options would be ambiguous).
|
|
323
|
+
*
|
|
324
|
+
* Checked with `!== null` rather than truthiness so an empty body
|
|
325
|
+
* (`Buffer.alloc(0)`) still hits the cache on subsequent reads.
|
|
326
|
+
*/
|
|
327
|
+
/** @internal */ _cached: Buffer$1 | null;
|
|
328
|
+
/** @internal Adapter calls this on each request before dispatch. */
|
|
329
|
+
_attach(source: Readable | null, contentType: string | undefined, contentLength: number | undefined): void;
|
|
330
|
+
/** @internal Pool reset. */
|
|
331
|
+
_reset(): void;
|
|
332
|
+
/**
|
|
333
|
+
* Returns the raw request body stream. Throws if already consumed OR
|
|
334
|
+
* if the body has already been buffered (cached) — once we hold the
|
|
335
|
+
* bytes, we can't hand the caller back a fresh Readable to own.
|
|
336
|
+
*/
|
|
337
|
+
stream(): Readable;
|
|
338
|
+
/**
|
|
339
|
+
* Buffers the entire body into a `Buffer`. Honors `maxBytes` (default 100KB).
|
|
340
|
+
*
|
|
341
|
+
* If the body has already been buffered once (by any prior `buffer()`,
|
|
342
|
+
* `text()`, `json()`, or `urlencoded()` call), returns the cached bytes
|
|
343
|
+
* — `maxBytes` is still enforced against `cached.length`, so a caller
|
|
344
|
+
* passing a tighter cap than the original still gets a 413.
|
|
345
|
+
*/
|
|
346
|
+
buffer(maxBytes?: number): Promise<Buffer$1>;
|
|
347
|
+
/** Buffers the body and decodes as UTF-8 text. */
|
|
348
|
+
text(maxBytes?: number): Promise<string>;
|
|
349
|
+
/**
|
|
350
|
+
* Parses the body as JSON. If a schema is provided, the parsed value is
|
|
351
|
+
* validated. Detection order:
|
|
352
|
+
*
|
|
353
|
+
* 1. Standard Schema v1 (`["~standard"]`) — async-aware, multi-issue
|
|
354
|
+
* 2. Zod-like `safeParse(input)` — multi-issue
|
|
355
|
+
* 3. Plain `parse(input): T` — throws on failure
|
|
356
|
+
*
|
|
357
|
+
* Validation failures are normalized into `IngeniumValidationError` with a
|
|
358
|
+
* field-level `fields` map (dot-joined paths; empty path → `_`).
|
|
359
|
+
*/
|
|
360
|
+
json<T = unknown>(schema?: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>, maxBytes?: number): Promise<T>;
|
|
361
|
+
/** Parses the body as `application/x-www-form-urlencoded`. */
|
|
362
|
+
urlencoded(maxBytes?: number): Promise<Record<string, string>>;
|
|
363
|
+
/**
|
|
364
|
+
* Parses the body as `multipart/form-data` (RFC 7578).
|
|
365
|
+
*
|
|
366
|
+
* Returns plain-text fields and fully buffered file parts. For very large
|
|
367
|
+
* uploads prefer `stream()` and parse manually — this method holds every
|
|
368
|
+
* file in memory.
|
|
369
|
+
*
|
|
370
|
+
* Failure modes:
|
|
371
|
+
* - Body exceeds `maxBytes` → `IngeniumPayloadTooLargeError`
|
|
372
|
+
* - Single file exceeds `maxFileSize` → `IngeniumPayloadTooLargeError`
|
|
373
|
+
* - Too many files / fields → `IngeniumBadRequestError`
|
|
374
|
+
* - Disallowed mime type → `IngeniumBadRequestError`
|
|
375
|
+
* - Content-Type isn't `multipart/form-data` or boundary missing → `IngeniumBadRequestError`
|
|
376
|
+
* - Malformed body → `IngeniumBadRequestError`
|
|
377
|
+
*/
|
|
378
|
+
multipart(opts?: MultipartOptions): Promise<MultipartResult>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Options accepted by {@link IngeniumCookies.set}. Maps 1:1 to RFC 6265 cookie
|
|
383
|
+
* attributes plus a few modern extensions (`Priority`, `Partitioned`).
|
|
384
|
+
*
|
|
385
|
+
* `sameSite: true` is normalized to `'strict'` (Express compatibility);
|
|
386
|
+
* `sameSite: false` omits the attribute entirely so the browser falls back
|
|
387
|
+
* to its default policy.
|
|
388
|
+
*/
|
|
389
|
+
interface CookieSetOptions {
|
|
390
|
+
/** `Domain=` attribute. Omitted when undefined. */
|
|
391
|
+
domain?: string;
|
|
392
|
+
/** `Path=` attribute. Defaults to `'/'`. */
|
|
393
|
+
path?: string;
|
|
394
|
+
/** `Expires=` attribute. Serialized via `Date.toUTCString()`. */
|
|
395
|
+
expires?: Date;
|
|
396
|
+
/** `Max-Age=` (seconds). Floored to an integer. */
|
|
397
|
+
maxAge?: number;
|
|
398
|
+
/** `HttpOnly` flag. */
|
|
399
|
+
httpOnly?: boolean;
|
|
400
|
+
/** `Secure` flag. */
|
|
401
|
+
secure?: boolean;
|
|
402
|
+
/** `SameSite=` attribute. `true` → `'strict'`; `false`/omitted → no attr. */
|
|
403
|
+
sameSite?: 'strict' | 'lax' | 'none' | true | false;
|
|
404
|
+
/** `Priority=` attribute (CHIPS / RFC 9220). Capitalized on the wire. */
|
|
405
|
+
priority?: 'low' | 'medium' | 'high';
|
|
406
|
+
/** `Partitioned` flag (CHIPS). */
|
|
407
|
+
partitioned?: boolean;
|
|
408
|
+
/**
|
|
409
|
+
* When `true`, the cookie value is HMAC-SHA-256 signed with the app's
|
|
410
|
+
* `cookieSecrets[0]`. On the wire: `name=value.signature`. Throws
|
|
411
|
+
* `IngeniumError(500, 'COOKIE_SECRET_MISSING')` if no secrets are configured.
|
|
412
|
+
*/
|
|
413
|
+
signed?: boolean;
|
|
414
|
+
}
|
|
415
|
+
/** Options accepted by {@link IngeniumCookies.get}. */
|
|
416
|
+
interface CookieGetOptions {
|
|
417
|
+
/**
|
|
418
|
+
* When `true`, the cookie value is treated as `value.signature` and the
|
|
419
|
+
* HMAC is verified against every configured secret (rotation-safe).
|
|
420
|
+
* Returns `null` on tamper, missing signature, or no configured secrets.
|
|
421
|
+
*/
|
|
422
|
+
signed?: boolean;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* First-class cookie API exposed via `ctx.cookies`. Pool-bound and lazy —
|
|
426
|
+
* the holder is allocated on first access and dropped to `null` on context
|
|
427
|
+
* reset, so routes that never touch cookies pay zero overhead.
|
|
428
|
+
*
|
|
429
|
+
* Read side parses `ctx.headers.cookie` once and caches the resulting record.
|
|
430
|
+
* Write side appends to the response `set-cookie` header, preserving prior
|
|
431
|
+
* values (a single response may carry multiple `Set-Cookie` headers).
|
|
432
|
+
*/
|
|
433
|
+
interface IngeniumCookies {
|
|
434
|
+
/**
|
|
435
|
+
* Read a cookie by name. With `{ signed: true }`, verifies the HMAC
|
|
436
|
+
* suffix and returns `null` on mismatch. Returns `null` when the cookie
|
|
437
|
+
* is absent.
|
|
438
|
+
*/
|
|
439
|
+
get(name: string, opts?: CookieGetOptions): string | null;
|
|
440
|
+
/**
|
|
441
|
+
* Snapshot of all parsed cookies. Signed cookies appear with their raw
|
|
442
|
+
* `value.signature` suffix — call `.get(name, { signed: true })` to verify.
|
|
443
|
+
*/
|
|
444
|
+
all(): Record<string, string>;
|
|
445
|
+
/**
|
|
446
|
+
* Write a `Set-Cookie` header. Multiple calls accumulate (the response
|
|
447
|
+
* carries one `Set-Cookie` header per call). With `{ signed: true }`,
|
|
448
|
+
* the value is HMAC-SHA-256 signed.
|
|
449
|
+
*/
|
|
450
|
+
set(name: string, value: string, opts?: CookieSetOptions): void;
|
|
451
|
+
/**
|
|
452
|
+
* Expire a cookie. Emits `Max-Age=0` plus an `Expires` in the past, and
|
|
453
|
+
* mirrors `path` / `domain` so the browser actually removes the right
|
|
454
|
+
* cookie (a `Set-Cookie` only matches the existing cookie on those attrs).
|
|
455
|
+
*/
|
|
456
|
+
clear(name: string, opts?: Pick<CookieSetOptions, 'domain' | 'path'>): void;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** HTTP methods supported by the router. */
|
|
460
|
+
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
|
461
|
+
declare const HTTP_METHODS: readonly HttpMethod[];
|
|
462
|
+
/**
|
|
463
|
+
* Recursively extracts named params from a path string at the type level.
|
|
464
|
+
*
|
|
465
|
+
* - `:name` → required string
|
|
466
|
+
* - `:name?` → optional string (becomes `string | undefined`)
|
|
467
|
+
* - `:name(regex)` → required string. The regex is type-stripped here, but
|
|
468
|
+
* the constraint IS enforced at runtime by the trie
|
|
469
|
+
* (`RouterTrie.find` tests the segment against the
|
|
470
|
+
* compiled, fully-anchored pattern), so the `string`
|
|
471
|
+
* type is honest about the matched shape.
|
|
472
|
+
* Note: number-narrowing (typing `:id(\d+)` as `number`)
|
|
473
|
+
* remains deferred — constrained params stay `string`.
|
|
474
|
+
* - `*name` → required string (greedy wildcard tail)
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* type P = ExtractParams<'/users/:id(\\d+)/posts/:slug?'>
|
|
478
|
+
* // { id: string; slug?: string | undefined }
|
|
479
|
+
*/
|
|
480
|
+
type ExtractParams<Path extends string> = Path extends `${string}:${infer Param}/${infer Rest}` ? ParamRecord<Param> & ExtractParams<`/${Rest}`> : Path extends `${string}:${infer Param}` ? ParamRecord<Param> : Path extends `${string}*${infer Wild}` ? {
|
|
481
|
+
[K in Wild]: string;
|
|
482
|
+
} : EmptyParams;
|
|
483
|
+
type EmptyParams = Record<string, never>;
|
|
484
|
+
/**
|
|
485
|
+
* Drop a single parenthesized constraint group from a param name.
|
|
486
|
+
* `id(\\d+)` → `id`
|
|
487
|
+
* `id(\\d+)?` → `id?` (optionality marker preserved for ParamRecord)
|
|
488
|
+
* `id` → `id` (no-op when no constraint present)
|
|
489
|
+
*/
|
|
490
|
+
type StripConstraint<P extends string> = P extends `${infer Head}(${string})${infer Tail}` ? `${Head}${Tail}` : P;
|
|
491
|
+
type ParamRecord<P extends string> = StripConstraint<P> extends `${infer Name}?` ? {
|
|
492
|
+
[K in Name]?: string;
|
|
493
|
+
} : {
|
|
494
|
+
[K in StripConstraint<P>]: string;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Higher-level `accepts*` helpers, parameterized over a context-like object
|
|
499
|
+
* with a `headers` map. Kept context-agnostic so they're trivially testable
|
|
500
|
+
* with a plain `{ headers: {...} }` stub.
|
|
501
|
+
*/
|
|
502
|
+
|
|
503
|
+
/** Minimal shape we depend on — `IngeniumContext` satisfies it. */
|
|
504
|
+
interface NegotiableCtx {
|
|
505
|
+
headers: IncomingHttpHeaders;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* `accepts(ctx)` → list of accepted media types in preference order
|
|
509
|
+
* (after expanding shorthand inputs is a no-op here — it returns the raw
|
|
510
|
+
* mime strings the client sent).
|
|
511
|
+
*
|
|
512
|
+
* `accepts(ctx, ...types)` → best matching offered type, or `false`.
|
|
513
|
+
* Each `type` may be a shorthand (`'json'`, `'html'`) or full mime
|
|
514
|
+
* (`'application/json'`).
|
|
515
|
+
*/
|
|
516
|
+
declare function accepts(ctx: NegotiableCtx): string[];
|
|
517
|
+
declare function accepts(ctx: NegotiableCtx, ...types: string[]): string | false;
|
|
518
|
+
/**
|
|
519
|
+
* `acceptsCharsets(ctx)` → all charsets in preference order.
|
|
520
|
+
* `acceptsCharsets(ctx, ...charsets)` → best match or `false`.
|
|
521
|
+
*/
|
|
522
|
+
declare function acceptsCharsets(ctx: NegotiableCtx): string[];
|
|
523
|
+
declare function acceptsCharsets(ctx: NegotiableCtx, ...charsets: string[]): string | false;
|
|
524
|
+
/**
|
|
525
|
+
* `acceptsLanguages(ctx)` → all languages in preference order.
|
|
526
|
+
* `acceptsLanguages(ctx, ...langs)` → best match or `false`.
|
|
527
|
+
*
|
|
528
|
+
* Language matching is treated like opaque tokens with `*` as wildcard;
|
|
529
|
+
* partial-tag matching (e.g. `en` matching `en-US`) is **not** performed —
|
|
530
|
+
* use exact tags for predictable behavior, mirroring Express's default.
|
|
531
|
+
*/
|
|
532
|
+
declare function acceptsLanguages(ctx: NegotiableCtx): string[];
|
|
533
|
+
declare function acceptsLanguages(ctx: NegotiableCtx, ...langs: string[]): string | false;
|
|
534
|
+
/**
|
|
535
|
+
* `acceptsEncodings(ctx)` → all encodings in preference order.
|
|
536
|
+
* `acceptsEncodings(ctx, ...encodings)` → best match or `false`.
|
|
537
|
+
*
|
|
538
|
+
* Per RFC 9110 §12.5.4, when `Accept-Encoding` is absent, the server
|
|
539
|
+
* MAY assume the client accepts any encoding — we follow Express and
|
|
540
|
+
* return the first offered.
|
|
541
|
+
*/
|
|
542
|
+
declare function acceptsEncodings(ctx: NegotiableCtx): string[];
|
|
543
|
+
declare function acceptsEncodings(ctx: NegotiableCtx, ...encodings: string[]): string | false;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* `formatResponse(ctx, handlers)` — Express's `res.format` for Ingenium.
|
|
547
|
+
*
|
|
548
|
+
* Picks the best handler key against the request `Accept` header, runs it,
|
|
549
|
+
* sets `Content-Type` to the matched key, and writes the result as the
|
|
550
|
+
* response body. If no handler matches and no `default` key is provided,
|
|
551
|
+
* throws a `IngeniumError(406, 'NOT_ACCEPTABLE')`.
|
|
552
|
+
*
|
|
553
|
+
* Handlers may be sync or async — `formatResponse` always awaits.
|
|
554
|
+
*/
|
|
555
|
+
|
|
556
|
+
/** Minimal context shape required by `formatResponse` — narrower than full `IngeniumContext`. */
|
|
557
|
+
interface FormattableCtx extends NegotiableCtx {
|
|
558
|
+
set(name: string, value: string | string[]): unknown;
|
|
559
|
+
json(body: unknown, status?: number): void;
|
|
560
|
+
send(body: Buffer$1 | string, status?: number): void;
|
|
561
|
+
}
|
|
562
|
+
/** Map of `mime → handler`. The reserved key `default` is the no-match fallback. */
|
|
563
|
+
type FormatHandlers = Record<string, () => unknown | Promise<unknown>>;
|
|
564
|
+
/**
|
|
565
|
+
* Pick the best handler key for `Accept` and run it.
|
|
566
|
+
*
|
|
567
|
+
* - JSON-shaped result objects are written via `ctx.json`.
|
|
568
|
+
* - String / Buffer results are written via `ctx.send` with the matched
|
|
569
|
+
* content-type preserved (instead of `send`'s default text/plain inference).
|
|
570
|
+
* - `default` handler is used when no explicit key matches.
|
|
571
|
+
* - No match + no default → throws `IngeniumError(406, 'NOT_ACCEPTABLE')`.
|
|
572
|
+
*/
|
|
573
|
+
declare function formatResponse(ctx: FormattableCtx, handlers: FormatHandlers): Promise<void>;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* `respondJsonWithEtag(ctx, body, opts)` — JSON response with auto ETag and
|
|
577
|
+
* 304 short-circuit when `If-None-Match` matches.
|
|
578
|
+
*
|
|
579
|
+
* Behavior:
|
|
580
|
+
* 1. Stringify `body` to JSON exactly once.
|
|
581
|
+
* 2. Compute weak ETag (default) over the stringified bytes.
|
|
582
|
+
* 3. If `If-None-Match` (after weak normalization) matches → set 304,
|
|
583
|
+
* clear body, mark written. Skip writing the JSON.
|
|
584
|
+
* 4. Otherwise: set `ETag` + `Content-Type` headers, write the body via
|
|
585
|
+
* the same internal shape `ctx.json` uses, and mark written.
|
|
586
|
+
*
|
|
587
|
+
* Uses the lower-level shape from `IngeniumContext` directly (rather than
|
|
588
|
+
* calling `ctx.json`) so the JSON.stringify result can be reused without
|
|
589
|
+
* a second pass.
|
|
590
|
+
*/
|
|
591
|
+
|
|
592
|
+
/** Options for `respondJsonWithEtag`. */
|
|
593
|
+
interface JsonEtagOptions {
|
|
594
|
+
/** Prefix the ETag with `W/`. Defaults to `true`. */
|
|
595
|
+
weak?: boolean;
|
|
596
|
+
/** HTTP status to use for the success path. Defaults to `200`. */
|
|
597
|
+
status?: number;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Minimal context shape required by `respondJsonWithEtag` — keeps the
|
|
601
|
+
* helper testable with a plain stub and avoids a hard import cycle on
|
|
602
|
+
* the full `IngeniumContext` class.
|
|
603
|
+
*/
|
|
604
|
+
interface JsonEtagCtx {
|
|
605
|
+
headers: IncomingHttpHeaders;
|
|
606
|
+
_statusCode: number;
|
|
607
|
+
_headers: Record<string, string | string[]>;
|
|
608
|
+
_body: ResponseBody;
|
|
609
|
+
_written: boolean;
|
|
610
|
+
}
|
|
611
|
+
declare function respondJsonWithEtag(ctx: JsonEtagCtx, body: unknown, opts?: JsonEtagOptions): void;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* `URLSearchParams` augmented with a `parse(schema)` method that runs the
|
|
615
|
+
* query through the same schema-detection pipeline as `ctx.body.json(schema)`.
|
|
616
|
+
*
|
|
617
|
+
* The shape passed to the schema is a **shallow array-aware** object:
|
|
618
|
+
*
|
|
619
|
+
* `?id=42&tag=a&tag=b&active=true`
|
|
620
|
+
* → `{ id: '42', tag: ['a','b'], active: 'true' }`
|
|
621
|
+
*
|
|
622
|
+
* Single-occurrence keys → `string`. Repeated keys → `string[]`. Everything is
|
|
623
|
+
* a string on the wire, so the user's schema is responsible for coercing
|
|
624
|
+
* numbers/booleans (Zod: use `z.coerce.number()`; ArkType: use `'string.numeric.parse'`).
|
|
625
|
+
*
|
|
626
|
+
* Rationale for picking THIS coercion over alternatives:
|
|
627
|
+
* - "raw strings only" loses repeated-key fidelity (qs/Express-style arrays)
|
|
628
|
+
* - "pre-coerced booleans/numbers" surprises users when "12foo" silently
|
|
629
|
+
* becomes a string or "true" becomes a boolean against their schema
|
|
630
|
+
* - Shallow-array matches `Object.fromEntries` semantics PLUS the most
|
|
631
|
+
* common ergonomic ask (tag=a&tag=b → tag: string[])
|
|
632
|
+
*/
|
|
633
|
+
interface IngeniumQuery extends URLSearchParams {
|
|
634
|
+
parse<T = unknown>(schema: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>): T;
|
|
635
|
+
}
|
|
636
|
+
/** Internal response body shape — adapter writes one of these to the wire. */
|
|
637
|
+
type ResponseBody = {
|
|
638
|
+
kind: 'none';
|
|
639
|
+
} | {
|
|
640
|
+
kind: 'buffer';
|
|
641
|
+
data: Buffer$1;
|
|
642
|
+
} | {
|
|
643
|
+
kind: 'string';
|
|
644
|
+
data: string;
|
|
645
|
+
} | {
|
|
646
|
+
kind: 'stream';
|
|
647
|
+
data: Readable;
|
|
648
|
+
};
|
|
649
|
+
/**
|
|
650
|
+
* Per-request context. Pool-bound: one instance per pool slot, reused
|
|
651
|
+
* across thousands of requests. All mutable fields are reset between uses.
|
|
652
|
+
*
|
|
653
|
+
* The `Params` generic is a phantom — it narrows `ctx.params` for typed
|
|
654
|
+
* route handlers but is `Record<string, string>` at runtime.
|
|
655
|
+
*/
|
|
656
|
+
declare class IngeniumContext<Params = Record<string, string>> {
|
|
657
|
+
/** HTTP method, uppercase. */
|
|
658
|
+
method: HttpMethod;
|
|
659
|
+
/** Full request URL including query string (e.g. `/users/42?expand=posts`). */
|
|
660
|
+
url: string;
|
|
661
|
+
/** Path portion of the URL (no query string). Set by the adapter. */
|
|
662
|
+
path: string;
|
|
663
|
+
/** Raw query string (no leading `?`). Use `query` for parsed access. */
|
|
664
|
+
rawQuery: string;
|
|
665
|
+
/** Route params, written at trie-match time. */
|
|
666
|
+
params: Params;
|
|
667
|
+
/** Lowercased request headers (Node convention). */
|
|
668
|
+
headers: IncomingHttpHeaders;
|
|
669
|
+
/** Lazy body accessor. */
|
|
670
|
+
readonly body: IngeniumBody;
|
|
671
|
+
/** Free-form per-request state for plugins/middleware (e.g. `ctx.user = ...`). */
|
|
672
|
+
state: Record<string, unknown>;
|
|
673
|
+
/**
|
|
674
|
+
* Per-request handle to enqueue background jobs onto a registered queue.
|
|
675
|
+
* Wired by `IngeniumApp` as a lazy decorator (declared with `!` because the
|
|
676
|
+
* runtime value is installed by the decorator registry, not the class
|
|
677
|
+
* initializer). Throws if the named queue isn't registered.
|
|
678
|
+
*
|
|
679
|
+
* @example
|
|
680
|
+
* await ctx.queue<{ to: string }>('emails').add({ to: 'a@b.com' })
|
|
681
|
+
*/
|
|
682
|
+
queue: <TData = unknown>(name: string) => JobHandle<TData>;
|
|
683
|
+
/** Lazy-parsed query. First access caches the URLSearchParams. */
|
|
684
|
+
private _query;
|
|
685
|
+
get query(): IngeniumQuery;
|
|
686
|
+
/**
|
|
687
|
+
* @internal Lazy cookie holder. `null` until first read of `ctx.cookies`.
|
|
688
|
+
* Reset to `null` in `reset()` so a context returned to the pool drops the
|
|
689
|
+
* parsed-cookie cache (and any closed-over write state — though writes go
|
|
690
|
+
* straight to `_headers`, which is itself reset by reassignment).
|
|
691
|
+
*/
|
|
692
|
+
_cookies: IngeniumCookies | null;
|
|
693
|
+
/**
|
|
694
|
+
* First-class cookie API. Lazy: the holder is allocated on first access so
|
|
695
|
+
* apps that never touch cookies pay zero per-request overhead. See
|
|
696
|
+
* `cookies.ts` for the read/write contract and signing rules.
|
|
697
|
+
*
|
|
698
|
+
* @example
|
|
699
|
+
* const sid = ctx.cookies.get('sid', { signed: true })
|
|
700
|
+
* ctx.cookies.set('theme', 'dark', { httpOnly: true, sameSite: 'lax' })
|
|
701
|
+
* ctx.cookies.clear('legacy')
|
|
702
|
+
*/
|
|
703
|
+
get cookies(): IngeniumCookies;
|
|
704
|
+
/**
|
|
705
|
+
* @internal App-wide cookie-signing secrets. Stamped by `IngeniumApp.handle`
|
|
706
|
+
* on dispatch entry when configured (mirrors `_trustProxy`). First secret
|
|
707
|
+
* signs new cookies; all entries verify reads (supports rotation). Empty
|
|
708
|
+
* means signed cookies will throw `IngeniumError(500, 'COOKIE_SECRET_MISSING')`.
|
|
709
|
+
*
|
|
710
|
+
* NOT cleared in `reset()` — this is app-wide config, not per-request state,
|
|
711
|
+
* and the cost of re-stamping every request is wasteful when the value is
|
|
712
|
+
* stable across the app's lifetime. The first call to `handle()` after a
|
|
713
|
+
* compose sets it; subsequent requests reuse the same array reference.
|
|
714
|
+
*/
|
|
715
|
+
_cookieSecrets: readonly string[];
|
|
716
|
+
/** Immediate socket peer address — populated by the adapter. */
|
|
717
|
+
remoteAddress: string;
|
|
718
|
+
/** Underlying transport protocol — populated by the adapter (http for node:http, https for TLS). */
|
|
719
|
+
baseProtocol: 'http' | 'https';
|
|
720
|
+
/** @internal `trustProxy` config carried in from the app. */ _trustProxy: TrustProxy;
|
|
721
|
+
/** @internal Cached forwarded resolution; computed lazily from headers. */
|
|
722
|
+
private _forwarded;
|
|
723
|
+
private resolveForwarded;
|
|
724
|
+
/**
|
|
725
|
+
* Best-effort client IP. With `trustProxy: false` this is the immediate
|
|
726
|
+
* socket peer; with trust-proxy enabled the X-Forwarded-For chain is
|
|
727
|
+
* walked according to the configured trust policy.
|
|
728
|
+
*/
|
|
729
|
+
get ip(): string;
|
|
730
|
+
/** Full forwarded chain (left-to-right, immediate peer last). */
|
|
731
|
+
get ips(): readonly string[];
|
|
732
|
+
/** Best-effort protocol — honors `X-Forwarded-Proto` when trust-proxy is enabled. */
|
|
733
|
+
get protocol(): 'http' | 'https';
|
|
734
|
+
/** Convenience: `protocol === 'https'`. */
|
|
735
|
+
get secure(): boolean;
|
|
736
|
+
/** Best-effort hostname (no port) — honors `X-Forwarded-Host` when trust-proxy is enabled. */
|
|
737
|
+
get hostname(): string;
|
|
738
|
+
/** @internal */ _statusCode: number;
|
|
739
|
+
/** @internal */ _headers: Record<string, string | string[]>;
|
|
740
|
+
/** @internal */ _body: ResponseBody;
|
|
741
|
+
/** @internal Whether a response helper has been called. */
|
|
742
|
+
_written: boolean;
|
|
743
|
+
/**
|
|
744
|
+
* @internal Per-request generation counter. Incremented every time the
|
|
745
|
+
* pool resets this context (and also bumped by `IngeniumApp.handle` when a
|
|
746
|
+
* request times out, so writes from the orphaned handler can be detected
|
|
747
|
+
* as stale). Compared against `_dispatchEpoch` by every response writer.
|
|
748
|
+
*/
|
|
749
|
+
_epoch: number;
|
|
750
|
+
/**
|
|
751
|
+
* @internal Last `_epoch` value captured by `IngeniumApp.withEpochGuard`.
|
|
752
|
+
* Set on dispatch entry; the per-dispatch wrappers installed around the
|
|
753
|
+
* response writers close over this value to detect late writes from an
|
|
754
|
+
* orphaned (timed-out) handler. The wrappers compare `_epoch` against
|
|
755
|
+
* the captured value at call time — mismatch ⇒ orphan ⇒ swallow.
|
|
756
|
+
*
|
|
757
|
+
* `0` means no guard is active (no `requestTimeoutMs` configured, or
|
|
758
|
+
* the dispatch already resolved naturally).
|
|
759
|
+
*/
|
|
760
|
+
_dispatchEpoch: number;
|
|
761
|
+
/**
|
|
762
|
+
* @internal Dev-only — emit `IngeniumDoubleWriteWarning` when a writer is
|
|
763
|
+
* called after `_written` is already true. No-op in production: V8
|
|
764
|
+
* eliminates the branch body behind the `IS_DEV` gate.
|
|
765
|
+
*/
|
|
766
|
+
private _warnDoubleWrite;
|
|
767
|
+
/** Set the HTTP status code. Returns `this` for chaining. */
|
|
768
|
+
status(code: number): this;
|
|
769
|
+
/**
|
|
770
|
+
* Set a response header (case-insensitive). Returns `this` for chaining.
|
|
771
|
+
*
|
|
772
|
+
* Throws `IngeniumHeaderInjectionError` if `name` or `value` contains CR
|
|
773
|
+
* or LF — these would otherwise enable header-injection / response-
|
|
774
|
+
* splitting attacks if a caller forwards untrusted user input directly.
|
|
775
|
+
*/
|
|
776
|
+
set(name: string, value: string | string[]): this;
|
|
777
|
+
/** Alias for `set` — matches Express's `res.setHeader`. */
|
|
778
|
+
setHeader(name: string, value: string | string[]): this;
|
|
779
|
+
/** Get a previously-set response header (lowercase lookup). */
|
|
780
|
+
getHeader(name: string): string | string[] | undefined;
|
|
781
|
+
/**
|
|
782
|
+
* Send a JSON response.
|
|
783
|
+
*
|
|
784
|
+
* Throws `IngeniumUnserializableError` if `body` cannot be encoded
|
|
785
|
+
* (circular structure, `BigInt`, etc.) — surfaces a clean 500 from the
|
|
786
|
+
* framework error boundary instead of a deep `TypeError`.
|
|
787
|
+
*/
|
|
788
|
+
json(body: unknown, status?: number): void;
|
|
789
|
+
/** Send a `text/plain` response. */
|
|
790
|
+
text(body: string, status?: number): void;
|
|
791
|
+
/** Send a `text/html` response. */
|
|
792
|
+
html(body: string, status?: number): void;
|
|
793
|
+
/** Send a redirect (default 302). */
|
|
794
|
+
redirect(location: string, status?: number): void;
|
|
795
|
+
/** Stream a `Readable` to the client. Sets content-type if not already set. */
|
|
796
|
+
stream(readable: Readable, contentType?: string): void;
|
|
797
|
+
/**
|
|
798
|
+
* Sinatra-style short-circuit. Throws `IngeniumHaltError(status, body?)`
|
|
799
|
+
* — the framework error boundary catches it and serializes per `bodyShape`:
|
|
800
|
+
*
|
|
801
|
+
* - `ctx.halt(401)` → 401 with default JSON `{ error, code: 'HALT' }`.
|
|
802
|
+
* - `ctx.halt(404, 'Not Found')` → 404 `text/plain` body verbatim.
|
|
803
|
+
* - `ctx.halt(422, { fields })` → 422 `application/json` body verbatim.
|
|
804
|
+
*
|
|
805
|
+
* The TypeScript `never` return type lets `if (!found) ctx.halt(404)`
|
|
806
|
+
* narrow the rest of the function — code after the call is unreachable.
|
|
807
|
+
*
|
|
808
|
+
* To bypass the error boundary entirely (write the response without
|
|
809
|
+
* throwing) call `ctx.json(body, status)` and `return` from the handler.
|
|
810
|
+
*
|
|
811
|
+
* @example
|
|
812
|
+
* if (!authorized(ctx)) ctx.halt(401, 'Unauthorized')
|
|
813
|
+
* if (!user) ctx.halt(404, { error: 'Not Found', id })
|
|
814
|
+
*/
|
|
815
|
+
halt(status: number, body?: string | Record<string, unknown>): never;
|
|
816
|
+
/** Send a `Buffer` body verbatim. */
|
|
817
|
+
send(body: Buffer$1 | string, status?: number): void;
|
|
818
|
+
/**
|
|
819
|
+
* Return the best mime type the client accepts from the offered list, or
|
|
820
|
+
* `false` if none are acceptable. With no arguments, returns the parsed
|
|
821
|
+
* preference-ordered list of accepted types from `Accept`.
|
|
822
|
+
*
|
|
823
|
+
* Each `type` may be a shorthand (`'json'`, `'html'`, `'csv'`, …) or a full
|
|
824
|
+
* mime (`'application/json'`). Quality factors are honored.
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* if (ctx.accepts('json')) ctx.json({ ok: true })
|
|
828
|
+
* else ctx.status(406).text('Not Acceptable')
|
|
829
|
+
*/
|
|
830
|
+
accepts(): string[];
|
|
831
|
+
accepts(...types: string[]): string | false;
|
|
832
|
+
/** Best matching charset from the offered list against `Accept-Charset`. */
|
|
833
|
+
acceptsCharsets(): string[];
|
|
834
|
+
acceptsCharsets(...charsets: string[]): string | false;
|
|
835
|
+
/** Best matching language against `Accept-Language` (exact-tag match only). */
|
|
836
|
+
acceptsLanguages(): string[];
|
|
837
|
+
acceptsLanguages(...langs: string[]): string | false;
|
|
838
|
+
/** Best matching encoding against `Accept-Encoding` (first offered when header absent). */
|
|
839
|
+
acceptsEncodings(): string[];
|
|
840
|
+
acceptsEncodings(...encodings: string[]): string | false;
|
|
841
|
+
/**
|
|
842
|
+
* Run the handler whose key best matches the request `Accept` header. The
|
|
843
|
+
* matched key is set as `Content-Type`. If no key matches and no `default`
|
|
844
|
+
* handler is provided, throws `IngeniumError(406, 'NOT_ACCEPTABLE')`.
|
|
845
|
+
*/
|
|
846
|
+
format(handlers: FormatHandlers): Promise<void>;
|
|
847
|
+
/**
|
|
848
|
+
* `true` when the client's `If-None-Match` matches the response `ETag`,
|
|
849
|
+
* or `If-Modified-Since` is at-or-after the response `Last-Modified`.
|
|
850
|
+
* Reads from `_headers` so handlers can set ETag / Last-Modified before checking.
|
|
851
|
+
*/
|
|
852
|
+
get fresh(): boolean;
|
|
853
|
+
/** `!fresh`. */
|
|
854
|
+
get stale(): boolean;
|
|
855
|
+
/**
|
|
856
|
+
* Send a JSON body with an auto-computed weak ETag. If the request's
|
|
857
|
+
* `If-None-Match` matches the computed tag, short-circuits to 304.
|
|
858
|
+
*/
|
|
859
|
+
jsonWithEtag(body: unknown, opts?: JsonEtagOptions): void;
|
|
860
|
+
/**
|
|
861
|
+
* Reset all per-request state. Called by the pool before returning the
|
|
862
|
+
* context to the free list. Reassignments preserve the V8 hidden class
|
|
863
|
+
* so subsequent allocations stay monomorphic.
|
|
864
|
+
*/
|
|
865
|
+
reset(): void;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* A function that runs as part of the middleware chain. Call `next()` to
|
|
870
|
+
* continue to the next middleware; omit it to short-circuit.
|
|
871
|
+
*
|
|
872
|
+
* @example
|
|
873
|
+
* const logger: IngeniumMiddleware = async (ctx, next) => {
|
|
874
|
+
* const start = Date.now()
|
|
875
|
+
* await next()
|
|
876
|
+
* ctx.logger?.info(`${ctx.method} ${ctx.path} ${Date.now() - start}ms`)
|
|
877
|
+
* }
|
|
878
|
+
*/
|
|
879
|
+
type IngeniumMiddleware = (ctx: IngeniumContext, next: () => Promise<void>) => unknown | Promise<unknown>;
|
|
880
|
+
/**
|
|
881
|
+
* A composed middleware chain plus terminal handler. Returned by `compose()`.
|
|
882
|
+
* Internally cached on each trie leaf.
|
|
883
|
+
*/
|
|
884
|
+
type ComposedHandler = (ctx: IngeniumContext) => Promise<void>;
|
|
885
|
+
/**
|
|
886
|
+
* A user-facing route handler. Its return value is reflected to the wire by
|
|
887
|
+
* the response-helper dispatcher (see `response/helpers.ts`):
|
|
888
|
+
*
|
|
889
|
+
* - `undefined` → 204 (unless `ctx.json/...` was already called)
|
|
890
|
+
* - `string` → 200 text/plain (or text/html if it starts with `<`)
|
|
891
|
+
* - object → 200 application/json
|
|
892
|
+
* - `Buffer`/`Uint8Array` → 200 application/octet-stream
|
|
893
|
+
* - `Readable` → streamed response
|
|
894
|
+
*
|
|
895
|
+
* For full control, call `ctx.json/text/html/stream/redirect` and return
|
|
896
|
+
* `void` (or any value — return value is ignored once a helper has run).
|
|
897
|
+
*/
|
|
898
|
+
type IngeniumHandler<Params = Record<string, string>> = (ctx: IngeniumContext<Params>) => unknown | Promise<unknown>;
|
|
899
|
+
|
|
900
|
+
/** A journal entry — replayed against the trie when the app composes. */
|
|
901
|
+
type Registration = {
|
|
902
|
+
kind: 'use-global';
|
|
903
|
+
mw: IngeniumMiddleware;
|
|
904
|
+
} | {
|
|
905
|
+
kind: 'use-prefix';
|
|
906
|
+
prefix: string;
|
|
907
|
+
mw: IngeniumMiddleware;
|
|
908
|
+
} | {
|
|
909
|
+
kind: 'use-router';
|
|
910
|
+
prefix: string;
|
|
911
|
+
router: Router;
|
|
912
|
+
} | {
|
|
913
|
+
kind: 'route';
|
|
914
|
+
method: HttpMethod;
|
|
915
|
+
path: string;
|
|
916
|
+
handler: IngeniumHandler;
|
|
917
|
+
/**
|
|
918
|
+
* Inline middleware passed positionally to `app.get(path, mw1, mw2, handler)`
|
|
919
|
+
* (and the equivalent declarative-options form on `IngeniumApp`). Spliced into
|
|
920
|
+
* the composed chain AFTER global + scoped middleware AND BEFORE the handler.
|
|
921
|
+
* `undefined` for the back-compat single-arg form.
|
|
922
|
+
*/
|
|
923
|
+
inlineMiddleware?: IngeniumMiddleware[];
|
|
924
|
+
};
|
|
925
|
+
/**
|
|
926
|
+
* A mountable router. Registrations are journaled, not eagerly composed —
|
|
927
|
+
* mounting via `app.use('/api', router)` replays this journal into the
|
|
928
|
+
* parent's trie with the prefix prepended.
|
|
929
|
+
*/
|
|
930
|
+
declare class Router {
|
|
931
|
+
/** @internal */ readonly journal: Registration[];
|
|
932
|
+
/** Add middleware that runs for every request below this router. */
|
|
933
|
+
use(mw: IngeniumMiddleware): this;
|
|
934
|
+
/** Mount middleware or a sub-router at a path prefix. */
|
|
935
|
+
use(prefix: string, mw: IngeniumMiddleware | Router): this;
|
|
936
|
+
get<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
937
|
+
get<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
938
|
+
post<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
939
|
+
post<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
940
|
+
put<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
941
|
+
put<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
942
|
+
patch<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
943
|
+
patch<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
944
|
+
delete<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
945
|
+
delete<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
946
|
+
head<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
947
|
+
head<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
948
|
+
options<P extends string>(path: P, handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
949
|
+
options<P extends string>(path: P, ...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
950
|
+
/**
|
|
951
|
+
* Chainable per-path registration. Returns a builder that holds the path
|
|
952
|
+
* and lets you stack verbs on it without retyping:
|
|
953
|
+
*
|
|
954
|
+
* @example
|
|
955
|
+
* router
|
|
956
|
+
* .route('/users/:id')
|
|
957
|
+
* .get((ctx) => loadUser(ctx.params.id))
|
|
958
|
+
* .put(requireAdmin, (ctx) => updateUser(ctx))
|
|
959
|
+
* .delete(requireAdmin, (ctx) => deleteUser(ctx))
|
|
960
|
+
*
|
|
961
|
+
* Pure registration sugar — every call delegates to `router.method(...)`,
|
|
962
|
+
* so all features (inline middleware, declarative options, typed params
|
|
963
|
+
* via `ExtractParams<P>`) work identically.
|
|
964
|
+
*/
|
|
965
|
+
route<P extends string>(path: P): RouteBuilder<P>;
|
|
966
|
+
/**
|
|
967
|
+
* Internal — register a route under any HTTP method. Accepts the variadic
|
|
968
|
+
* `(...inlineMiddleware, handler)` tail; the LAST positional arg is always
|
|
969
|
+
* the handler.
|
|
970
|
+
*/
|
|
971
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): this;
|
|
972
|
+
method(method: HttpMethod, path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Per-path chainable builder returned by `app.route(path)` and
|
|
976
|
+
* `router.route(path)`. Holds the path and an "emit" callback that registers
|
|
977
|
+
* a route on the underlying host (an `IngeniumApp` or a `Router`); the
|
|
978
|
+
* builder itself is just sugar — no per-request cost, no separate dispatch
|
|
979
|
+
* path. The host's verb method does all the validation, dirty-bit flipping,
|
|
980
|
+
* and journal writes.
|
|
981
|
+
*
|
|
982
|
+
* The generic `P` flows `ExtractParams<P>` into every handler signature so
|
|
983
|
+
* `app.route('/users/:id').get(ctx => ctx.params.id)` narrows `ctx.params`
|
|
984
|
+
* exactly like the bare verb form does.
|
|
985
|
+
*/
|
|
986
|
+
declare class RouteBuilder<P extends string> {
|
|
987
|
+
private readonly emit;
|
|
988
|
+
constructor(emit: (method: HttpMethod, args: unknown[]) => void);
|
|
989
|
+
get(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
990
|
+
get(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
991
|
+
post(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
992
|
+
post(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
993
|
+
put(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
994
|
+
put(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
995
|
+
patch(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
996
|
+
patch(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
997
|
+
delete(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
998
|
+
delete(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
999
|
+
head(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
1000
|
+
head(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
1001
|
+
options(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
1002
|
+
options(...args: [...IngeniumMiddleware[], IngeniumHandler<ExtractParams<P>>]): this;
|
|
1003
|
+
/** Register the same handler for all common HTTP methods (GET, POST, PUT, PATCH, DELETE). */
|
|
1004
|
+
all(handler: IngeniumHandler<ExtractParams<P>>): this;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Payload fired to `onRoute` hooks each time a route is registered into the
|
|
1009
|
+
* trie during composition. Plugins can observe — they MUST NOT mutate.
|
|
1010
|
+
*/
|
|
1011
|
+
interface RegistrationEvent {
|
|
1012
|
+
/** HTTP method (uppercase). */
|
|
1013
|
+
readonly method: HttpMethod;
|
|
1014
|
+
/** Final composed route path (after all prefixes). */
|
|
1015
|
+
readonly path: string;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* The shape a plugin can rely on regardless of whether it's registered onto
|
|
1019
|
+
* the root `IngeniumApp` or onto a `ScopedApp` (via `app.scope(...).register(...)`).
|
|
1020
|
+
*
|
|
1021
|
+
* Both root and scoped registration targets implement this interface. Plugins
|
|
1022
|
+
* that previously took `(app: IngeniumApp, opts)` are source-compatible:
|
|
1023
|
+
* `IngeniumApp` implements every member of `PluginTarget`. The only practical
|
|
1024
|
+
* difference at the call site is that `target.scope(...)`, `target.use(mw)`,
|
|
1025
|
+
* and the verb methods become prefix-relative when `target` is a `ScopedApp`.
|
|
1026
|
+
*
|
|
1027
|
+
* # Scoping semantics for plugin authors
|
|
1028
|
+
*
|
|
1029
|
+
* - `target.use(mw)` / `target.use(subprefix, mw)` — middleware is scoped to
|
|
1030
|
+
* the target's prefix at compose time. On the root, this is "global". In a
|
|
1031
|
+
* scope, it's "applies only to paths under the scope's prefix".
|
|
1032
|
+
* - `target.get/post/...` and `target.method(...)` — paths are prefix-
|
|
1033
|
+
* relative; the scope prepends its absolute prefix at registration time.
|
|
1034
|
+
* - `target.register(plugin, opts)` — runs the plugin against the SAME
|
|
1035
|
+
* target. Nested scopes compose as expected.
|
|
1036
|
+
* - `target.hooks` — lifecycle hooks are GLOBAL even when called inside a
|
|
1037
|
+
* scope. Hooks fire per request, before route dispatch; making them
|
|
1038
|
+
* scope-aware would require runtime path-prefix checks on every request.
|
|
1039
|
+
* If a plugin needs scope-aware behavior, it should inspect `ctx.path`
|
|
1040
|
+
* inside the hook body.
|
|
1041
|
+
* - `target.decorate(...)` / `target.decorateRequest(...)` — decorators are
|
|
1042
|
+
* GLOBAL even when called inside a scope (see {@link IngeniumPlugin} JSDoc
|
|
1043
|
+
* for the rationale). `ScopedApp.decorate` emits a one-shot
|
|
1044
|
+
* `process.emitWarning` in non-production environments to surface this
|
|
1045
|
+
* footgun.
|
|
1046
|
+
*/
|
|
1047
|
+
interface PluginTarget {
|
|
1048
|
+
/** Lifecycle hooks (global — see interface JSDoc). */
|
|
1049
|
+
readonly hooks: Hooks;
|
|
1050
|
+
/** Add middleware that runs for every request below this target. */
|
|
1051
|
+
use(mw: IngeniumMiddleware): this;
|
|
1052
|
+
/** Mount middleware or a sub-router at a path prefix (relative to this target). */
|
|
1053
|
+
use(prefix: string, mw: IngeniumMiddleware | Router): this;
|
|
1054
|
+
/** Register a route under any HTTP method (path is relative to this target). */
|
|
1055
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): this;
|
|
1056
|
+
method(method: HttpMethod, path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1057
|
+
/**
|
|
1058
|
+
* Chainable per-path builder. Same path-joining rules as the bare verbs —
|
|
1059
|
+
* inside a `ScopedApp`, the builder's emitted routes are prefix-relative.
|
|
1060
|
+
*/
|
|
1061
|
+
route<P extends string>(path: P): RouteBuilder<P>;
|
|
1062
|
+
/** Convenience verb shortcuts (paths are relative to this target). */
|
|
1063
|
+
get(path: string, handler: IngeniumHandler): this;
|
|
1064
|
+
get(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1065
|
+
post(path: string, handler: IngeniumHandler): this;
|
|
1066
|
+
post(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1067
|
+
put(path: string, handler: IngeniumHandler): this;
|
|
1068
|
+
put(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1069
|
+
patch(path: string, handler: IngeniumHandler): this;
|
|
1070
|
+
patch(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1071
|
+
delete(path: string, handler: IngeniumHandler): this;
|
|
1072
|
+
delete(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1073
|
+
head(path: string, handler: IngeniumHandler): this;
|
|
1074
|
+
head(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1075
|
+
options(path: string, handler: IngeniumHandler): this;
|
|
1076
|
+
options(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1077
|
+
/**
|
|
1078
|
+
* Pre-handler / post-handler middleware (paths are relative to this target).
|
|
1079
|
+
* Inside a `ScopedApp` these are confined to the scope prefix — `s.before(h)`
|
|
1080
|
+
* only fires for requests under the scope, mirroring `s.use`.
|
|
1081
|
+
*/
|
|
1082
|
+
before(handler: IngeniumMiddleware): this;
|
|
1083
|
+
before(pattern: string, handler: IngeniumMiddleware): this;
|
|
1084
|
+
after(handler: IngeniumMiddleware): this;
|
|
1085
|
+
after(pattern: string, handler: IngeniumMiddleware): this;
|
|
1086
|
+
/** Decorator registration. NOTE: GLOBAL even when called inside a scope. */
|
|
1087
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): this;
|
|
1088
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): this;
|
|
1089
|
+
/**
|
|
1090
|
+
* Register a plugin against this target. Plugins may be async and the
|
|
1091
|
+
* caller should `await` the returned promise.
|
|
1092
|
+
*/
|
|
1093
|
+
register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>;
|
|
1094
|
+
register(plugin: IngeniumPlugin<void>): Promise<this>;
|
|
1095
|
+
/**
|
|
1096
|
+
* Open a nested registration scope. All registrations inside `registrar`
|
|
1097
|
+
* are prefix-relative to `prefix` (and inherit any outer scope prefix).
|
|
1098
|
+
*/
|
|
1099
|
+
scope(prefix: string, registrar: (scope: PluginTarget) => void): this | Promise<this>;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* A plugin is a function that mutates a registration target: it can register
|
|
1103
|
+
* routes, middleware, decorators, and hook handlers. Plugins are registered
|
|
1104
|
+
* before `compose()` runs; they may be async.
|
|
1105
|
+
*
|
|
1106
|
+
* The `target` parameter is `PluginTarget` — implemented by both `IngeniumApp`
|
|
1107
|
+
* (the root) and `ScopedApp` (created by `app.scope(prefix, ...)`). When a
|
|
1108
|
+
* plugin is registered inside a scope, its `target.use(...)` / `target.get(...)`
|
|
1109
|
+
* are automatically prefix-scoped at compose time.
|
|
1110
|
+
*
|
|
1111
|
+
* # Scoped-decorator caveat (V1)
|
|
1112
|
+
*
|
|
1113
|
+
* Decorators (`target.decorate`, `target.decorateRequest`) install onto the
|
|
1114
|
+
* pooled `IngeniumContext` at request start; the registry is per-app, not
|
|
1115
|
+
* per-path. That means a plugin registered inside `app.scope('/api', ...)`
|
|
1116
|
+
* that calls `target.decorate('user', ...)` will decorate EVERY request,
|
|
1117
|
+
* not just `/api/*` requests. The first such call inside a scope emits a
|
|
1118
|
+
* `process.emitWarning` in non-production environments. Plugin authors who
|
|
1119
|
+
* want per-scope decorator behavior should make the decorator's factory
|
|
1120
|
+
* inspect `ctx.path` and return a sentinel for out-of-scope requests.
|
|
1121
|
+
*
|
|
1122
|
+
* @example
|
|
1123
|
+
* const myPlugin: IngeniumPlugin<{ secret: string }> = async (target, opts) => {
|
|
1124
|
+
* target.hooks.onRequest((ctx) => { ... })
|
|
1125
|
+
* target.use((ctx, next) => next()) // scoped if target is a ScopedApp
|
|
1126
|
+
* target.get('/whoami', (ctx) => ...) // path is relative to scope
|
|
1127
|
+
* }
|
|
1128
|
+
*
|
|
1129
|
+
* await app.register(myPlugin, { secret: 'shh' })
|
|
1130
|
+
* app.scope('/api', (s) => s.register(myPlugin, { secret: 'shh' }))
|
|
1131
|
+
*/
|
|
1132
|
+
type IngeniumPlugin<O = void> = (target: PluginTarget, opts: O) => void | Promise<void>;
|
|
1133
|
+
/** Fires once per route as the trie is built (during `compose()`). */
|
|
1134
|
+
type OnRouteHook = (registration: RegistrationEvent) => void;
|
|
1135
|
+
/** Fires before composition runs. May be async. */
|
|
1136
|
+
type OnComposeHook = () => void | Promise<void>;
|
|
1137
|
+
/** Fires at the start of every request, before middleware dispatch. */
|
|
1138
|
+
type OnRequestHook = (ctx: IngeniumContext) => void | Promise<void>;
|
|
1139
|
+
/** Fires after the handler resolves successfully. */
|
|
1140
|
+
type OnResponseHook = (ctx: IngeniumContext) => void | Promise<void>;
|
|
1141
|
+
/**
|
|
1142
|
+
* Fires when the handler chain throws. OBSERVATION ONLY — the framework's
|
|
1143
|
+
* error boundary still owns the response. Throwing inside an `onError` hook
|
|
1144
|
+
* is swallowed; this is by design so observers can't mask the original error.
|
|
1145
|
+
*/
|
|
1146
|
+
type OnErrorHook = (err: unknown, ctx: IngeniumContext) => void | Promise<void>;
|
|
1147
|
+
/**
|
|
1148
|
+
* Public hooks API exposed on `app.hooks`. Each method appends a listener;
|
|
1149
|
+
* listeners are invoked in registration order, sequentially (`await`-ed in
|
|
1150
|
+
* a loop) for predictable ordering.
|
|
1151
|
+
*/
|
|
1152
|
+
interface Hooks {
|
|
1153
|
+
onRoute(fn: OnRouteHook): void;
|
|
1154
|
+
onCompose(fn: OnComposeHook): void;
|
|
1155
|
+
onRequest(fn: OnRequestHook): void;
|
|
1156
|
+
onResponse(fn: OnResponseHook): void;
|
|
1157
|
+
onError(fn: OnErrorHook): void;
|
|
1158
|
+
}
|
|
1159
|
+
/** Lazy decorator — computed on first access, then cached on the ctx. */
|
|
1160
|
+
type LazyDecorator<T = unknown> = (ctx: IngeniumContext) => T;
|
|
1161
|
+
/** Eager decorator — evaluated at request start, value assigned directly. */
|
|
1162
|
+
type EagerDecorator<T = unknown> = (ctx: IngeniumContext) => T;
|
|
1163
|
+
/** Generic decorator factory shape (covers both lazy and eager). */
|
|
1164
|
+
type Decorator<T = unknown> = (ctx: IngeniumContext) => T;
|
|
1165
|
+
|
|
1166
|
+
/** A function the framework hands to the transport — call it per request. */
|
|
1167
|
+
type TransportDispatch = (ctx: IngeniumContext) => Promise<void>;
|
|
1168
|
+
/** A function the transport calls to acquire a context from the pool. */
|
|
1169
|
+
type TransportAcquire = () => IngeniumContext;
|
|
1170
|
+
/** A function the transport calls to release a context back to the pool. */
|
|
1171
|
+
type TransportRelease = (ctx: IngeniumContext) => void;
|
|
1172
|
+
/**
|
|
1173
|
+
* The hooks a transport uses to interact with the framework. The transport
|
|
1174
|
+
* owns the request/response objects from its underlying server (node:http,
|
|
1175
|
+
* Bun.serve, etc.), populates a `IngeniumContext` from each request, awaits the
|
|
1176
|
+
* `dispatch` callback, then writes the context's response state to the wire.
|
|
1177
|
+
*/
|
|
1178
|
+
interface TransportHooks {
|
|
1179
|
+
acquire: TransportAcquire;
|
|
1180
|
+
release: TransportRelease;
|
|
1181
|
+
dispatch: TransportDispatch;
|
|
1182
|
+
/**
|
|
1183
|
+
* Hard ceiling (bytes) on the total request body. Adapters SHOULD wrap the
|
|
1184
|
+
* inbound body stream in `createByteLimit(maxRequestBytes)` before handing
|
|
1185
|
+
* it to `ctx.body._attach(...)`, AND reject with a 413 immediately when
|
|
1186
|
+
* the request advertises a `Content-Length` greater than this value (no
|
|
1187
|
+
* need to read the body). `Number.POSITIVE_INFINITY` disables the cap.
|
|
1188
|
+
*
|
|
1189
|
+
* Optional for backward compatibility with adapters / test fixtures that
|
|
1190
|
+
* predate this hook. The framework's `app.listen()` always populates the
|
|
1191
|
+
* field (default 2 MiB); consumers that read it should treat `undefined`
|
|
1192
|
+
* as "no cap" (`Number.POSITIVE_INFINITY`).
|
|
1193
|
+
*/
|
|
1194
|
+
maxRequestBytes?: number;
|
|
1195
|
+
}
|
|
1196
|
+
/** Options accepted by {@link ListeningServer.close}. */
|
|
1197
|
+
interface CloseOptions {
|
|
1198
|
+
/**
|
|
1199
|
+
* Maximum time (ms) to wait for keep-alive sockets to drain naturally
|
|
1200
|
+
* before they are forcibly destroyed. When omitted (or undefined), no
|
|
1201
|
+
* force-close occurs and `close()` waits indefinitely for sockets to
|
|
1202
|
+
* finish — this matches the historical Node `server.close()` behavior.
|
|
1203
|
+
*/
|
|
1204
|
+
gracefulTimeoutMs?: number;
|
|
1205
|
+
}
|
|
1206
|
+
/** A transport-agnostic listening server handle. */
|
|
1207
|
+
interface ListeningServer {
|
|
1208
|
+
/** Bound port (resolved if `port: 0` was passed). */
|
|
1209
|
+
port: number;
|
|
1210
|
+
/** The bound host. */
|
|
1211
|
+
host: string;
|
|
1212
|
+
/**
|
|
1213
|
+
* Stop accepting new connections; resolves when in-flight requests
|
|
1214
|
+
* finish. If `gracefulTimeoutMs` is provided, idle keep-alive sockets
|
|
1215
|
+
* still open after that many milliseconds are forcibly destroyed.
|
|
1216
|
+
*/
|
|
1217
|
+
close(opts?: CloseOptions): Promise<void>;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* A transport binds the Ingenium dispatch loop to a concrete server
|
|
1221
|
+
* runtime (Node's `node:http`, Bun.serve, etc).
|
|
1222
|
+
*/
|
|
1223
|
+
interface Transport {
|
|
1224
|
+
/** Wire up the transport with framework-side hooks. Called once by `app.listen()`. */
|
|
1225
|
+
attach(hooks: TransportHooks): void;
|
|
1226
|
+
/** Bind to a port and start accepting requests. */
|
|
1227
|
+
listen(port: number, host?: string): Promise<ListeningServer>;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Minimal OpenAPI 3.1 type surface — just enough for what Ingenium
|
|
1232
|
+
* generates today. Not a full mirror of the spec; we keep it intentionally
|
|
1233
|
+
* narrow so the generator's outputs typecheck without dragging in a
|
|
1234
|
+
* 4000-line ambient module.
|
|
1235
|
+
*
|
|
1236
|
+
* Spec reference: https://spec.openapis.org/oas/v3.1.0
|
|
1237
|
+
*
|
|
1238
|
+
* Intentional gaps (out of scope for v0.0.1, document-as-TODO):
|
|
1239
|
+
* - `callbacks`, `links`, `webhooks` — none of these have a registration
|
|
1240
|
+
* surface in Ingenium yet.
|
|
1241
|
+
* - `discriminator` / `xml` — schema is passed through verbatim, so callers
|
|
1242
|
+
* can include these themselves if they want to.
|
|
1243
|
+
* - `pathItems` under `components` — we only emit operations under `paths`.
|
|
1244
|
+
*/
|
|
1245
|
+
/** Permissive `$ref`-or-inline union used in many slots. */
|
|
1246
|
+
type Ref<T> = T | {
|
|
1247
|
+
$ref: string;
|
|
1248
|
+
};
|
|
1249
|
+
/** A JSON Schema fragment (per OpenAPI 3.1 = full JSON Schema 2020-12). */
|
|
1250
|
+
type Schema = Record<string, unknown>;
|
|
1251
|
+
/** Where a parameter lives. Ingenium only emits `path` from route syntax. */
|
|
1252
|
+
type ParameterLocation = 'query' | 'header' | 'path' | 'cookie';
|
|
1253
|
+
interface Parameter {
|
|
1254
|
+
name: string;
|
|
1255
|
+
in: ParameterLocation;
|
|
1256
|
+
description?: string;
|
|
1257
|
+
required?: boolean;
|
|
1258
|
+
deprecated?: boolean;
|
|
1259
|
+
schema?: Schema;
|
|
1260
|
+
example?: unknown;
|
|
1261
|
+
examples?: Record<string, Example>;
|
|
1262
|
+
/** Free-form passthrough so callers can stamp `x-*` extensions. */
|
|
1263
|
+
[extension: `x-${string}`]: unknown;
|
|
1264
|
+
}
|
|
1265
|
+
interface Example {
|
|
1266
|
+
summary?: string;
|
|
1267
|
+
description?: string;
|
|
1268
|
+
value?: unknown;
|
|
1269
|
+
externalValue?: string;
|
|
1270
|
+
}
|
|
1271
|
+
interface MediaType {
|
|
1272
|
+
schema?: Schema;
|
|
1273
|
+
example?: unknown;
|
|
1274
|
+
examples?: Record<string, Example>;
|
|
1275
|
+
}
|
|
1276
|
+
interface RequestBody {
|
|
1277
|
+
description?: string;
|
|
1278
|
+
required?: boolean;
|
|
1279
|
+
content: Record<string, MediaType>;
|
|
1280
|
+
}
|
|
1281
|
+
interface Response {
|
|
1282
|
+
description: string;
|
|
1283
|
+
headers?: Record<string, Ref<Header>>;
|
|
1284
|
+
content?: Record<string, MediaType>;
|
|
1285
|
+
}
|
|
1286
|
+
interface Header {
|
|
1287
|
+
description?: string;
|
|
1288
|
+
required?: boolean;
|
|
1289
|
+
deprecated?: boolean;
|
|
1290
|
+
schema?: Schema;
|
|
1291
|
+
}
|
|
1292
|
+
interface SecurityRequirement {
|
|
1293
|
+
[name: string]: string[];
|
|
1294
|
+
}
|
|
1295
|
+
interface Operation {
|
|
1296
|
+
tags?: string[];
|
|
1297
|
+
summary?: string;
|
|
1298
|
+
description?: string;
|
|
1299
|
+
operationId?: string;
|
|
1300
|
+
parameters?: Parameter[];
|
|
1301
|
+
requestBody?: RequestBody;
|
|
1302
|
+
responses?: Record<string, Response>;
|
|
1303
|
+
deprecated?: boolean;
|
|
1304
|
+
security?: SecurityRequirement[];
|
|
1305
|
+
/** Free-form passthrough so callers can stamp `x-*` extensions. */
|
|
1306
|
+
[extension: `x-${string}`]: unknown;
|
|
1307
|
+
}
|
|
1308
|
+
type PathItem = Partial<Record<'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace', Operation>> & {
|
|
1309
|
+
summary?: string;
|
|
1310
|
+
description?: string;
|
|
1311
|
+
parameters?: Parameter[];
|
|
1312
|
+
};
|
|
1313
|
+
interface Server {
|
|
1314
|
+
url: string;
|
|
1315
|
+
description?: string;
|
|
1316
|
+
variables?: Record<string, {
|
|
1317
|
+
default: string;
|
|
1318
|
+
enum?: string[];
|
|
1319
|
+
description?: string;
|
|
1320
|
+
}>;
|
|
1321
|
+
}
|
|
1322
|
+
interface Tag {
|
|
1323
|
+
name: string;
|
|
1324
|
+
description?: string;
|
|
1325
|
+
}
|
|
1326
|
+
interface Info {
|
|
1327
|
+
title: string;
|
|
1328
|
+
version: string;
|
|
1329
|
+
description?: string;
|
|
1330
|
+
termsOfService?: string;
|
|
1331
|
+
contact?: {
|
|
1332
|
+
name?: string;
|
|
1333
|
+
url?: string;
|
|
1334
|
+
email?: string;
|
|
1335
|
+
};
|
|
1336
|
+
license?: {
|
|
1337
|
+
name: string;
|
|
1338
|
+
url?: string;
|
|
1339
|
+
identifier?: string;
|
|
1340
|
+
};
|
|
1341
|
+
summary?: string;
|
|
1342
|
+
}
|
|
1343
|
+
interface Components {
|
|
1344
|
+
schemas?: Record<string, Schema>;
|
|
1345
|
+
responses?: Record<string, Response>;
|
|
1346
|
+
parameters?: Record<string, Parameter>;
|
|
1347
|
+
examples?: Record<string, Example>;
|
|
1348
|
+
requestBodies?: Record<string, RequestBody>;
|
|
1349
|
+
headers?: Record<string, Header>;
|
|
1350
|
+
securitySchemes?: Record<string, SecurityScheme>;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Loose security-scheme type — we do not interpret this, we pass it through
|
|
1354
|
+
* verbatim to `components.securitySchemes`. Use the OpenAPI spec's full
|
|
1355
|
+
* shape (apiKey / http / oauth2 / openIdConnect / mutualTLS).
|
|
1356
|
+
*/
|
|
1357
|
+
type SecurityScheme = Record<string, unknown>;
|
|
1358
|
+
interface OpenApiSpec {
|
|
1359
|
+
openapi: '3.1.0';
|
|
1360
|
+
info: Info;
|
|
1361
|
+
servers?: Server[];
|
|
1362
|
+
paths: Record<string, PathItem>;
|
|
1363
|
+
components?: Components;
|
|
1364
|
+
security?: SecurityRequirement[];
|
|
1365
|
+
tags?: Tag[];
|
|
1366
|
+
externalDocs?: {
|
|
1367
|
+
url: string;
|
|
1368
|
+
description?: string;
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Per-route metadata supplied via `app.describe('METHOD', '/path', meta)`.
|
|
1374
|
+
* Merged into the generated Operation by `generateOpenApi`.
|
|
1375
|
+
*
|
|
1376
|
+
* Anything you put here ends up on the operation object verbatim, except:
|
|
1377
|
+
* - `hidden: true` skips the route entirely (won't appear in the spec).
|
|
1378
|
+
* - `parameters` are *appended* to the path-param parameters extracted from
|
|
1379
|
+
* the route syntax, so you typically only put `query`, `header`, or
|
|
1380
|
+
* `cookie` parameters here.
|
|
1381
|
+
*/
|
|
1382
|
+
interface RouteDescriptor {
|
|
1383
|
+
summary?: string;
|
|
1384
|
+
description?: string;
|
|
1385
|
+
operationId?: string;
|
|
1386
|
+
tags?: string[];
|
|
1387
|
+
deprecated?: boolean;
|
|
1388
|
+
hidden?: boolean;
|
|
1389
|
+
parameters?: Parameter[];
|
|
1390
|
+
requestBody?: RequestBody;
|
|
1391
|
+
responses?: Record<string | number, Response>;
|
|
1392
|
+
security?: SecurityRequirement[];
|
|
1393
|
+
/** Extension passthrough — anything starting with `x-` is preserved. */
|
|
1394
|
+
[extension: `x-${string}`]: unknown;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* A single named background queue. Wraps a {@link QueueStore} with a worker
|
|
1399
|
+
* pool, retry/backoff logic, pause/resume controls, and a `drain()` for
|
|
1400
|
+
* graceful shutdown.
|
|
1401
|
+
*
|
|
1402
|
+
* The pool is event-driven: when a slot frees up, the queue immediately
|
|
1403
|
+
* tries to pull another job; if none is ready (empty or all delayed), the
|
|
1404
|
+
* pool sleeps until either:
|
|
1405
|
+
* - a new `add()` call wakes it, or
|
|
1406
|
+
* - the earliest delayed job's `notBefore` elapses (timer-based wake).
|
|
1407
|
+
*
|
|
1408
|
+
* All timers are `unref()`'d so the queue alone never keeps the event loop alive.
|
|
1409
|
+
*/
|
|
1410
|
+
declare class IngeniumQueue<TData = unknown> {
|
|
1411
|
+
readonly name: string;
|
|
1412
|
+
private readonly store;
|
|
1413
|
+
private readonly worker;
|
|
1414
|
+
private readonly retries;
|
|
1415
|
+
private readonly concurrency;
|
|
1416
|
+
private readonly onFailed;
|
|
1417
|
+
/** Active worker slot count. When `< concurrency`, pull more work. */
|
|
1418
|
+
private active;
|
|
1419
|
+
/** Whether `pause()` has been called and `resume()` not yet. */
|
|
1420
|
+
private paused;
|
|
1421
|
+
/** Whether `drain()`/close has been called. No new jobs accepted. */
|
|
1422
|
+
private closed;
|
|
1423
|
+
/** Timer for waking the pool when a delayed retry becomes due. */
|
|
1424
|
+
private wakeTimer;
|
|
1425
|
+
/** Resolvers waiting for `active` to hit 0 (used by `drain()`). */
|
|
1426
|
+
private idleWaiters;
|
|
1427
|
+
/** Set when the pool is started — protects against double-start. */
|
|
1428
|
+
private started;
|
|
1429
|
+
constructor(name: string, opts: QueueOptions<TData>, worker: QueueWorker<TData>);
|
|
1430
|
+
/**
|
|
1431
|
+
* Start the worker pool. Idempotent — safe to call multiple times. The
|
|
1432
|
+
* pool is also implicitly started by the first `add()` call so direct
|
|
1433
|
+
* invocation is optional.
|
|
1434
|
+
*/
|
|
1435
|
+
start(): void;
|
|
1436
|
+
/** Enqueue a job. Returns the assigned id. */
|
|
1437
|
+
add(data: TData): Promise<{
|
|
1438
|
+
id: string;
|
|
1439
|
+
}>;
|
|
1440
|
+
/** Approximate number of pending jobs. */
|
|
1441
|
+
size(): Promise<number>;
|
|
1442
|
+
/** Number of jobs in the dead-letter list. */
|
|
1443
|
+
failedCount(): Promise<number>;
|
|
1444
|
+
/**
|
|
1445
|
+
* Empty the dead-letter list. Only effective when the underlying store
|
|
1446
|
+
* is the default {@link MemoryQueueStore}; custom stores should provide
|
|
1447
|
+
* their own clearing surface.
|
|
1448
|
+
*/
|
|
1449
|
+
clearFailed(): void;
|
|
1450
|
+
/**
|
|
1451
|
+
* Stop pulling new jobs from the store. In-flight jobs continue to run
|
|
1452
|
+
* until they complete. Idempotent.
|
|
1453
|
+
*/
|
|
1454
|
+
pause(): void;
|
|
1455
|
+
/** Resume pulling jobs. Wakes the pool. */
|
|
1456
|
+
resume(): void;
|
|
1457
|
+
/**
|
|
1458
|
+
* Wait for all in-flight jobs to complete, then stop accepting new ones.
|
|
1459
|
+
* If `timeoutMs` elapses first, resolve anyway — the orphaned jobs keep
|
|
1460
|
+
* running until they naturally finish (JS can't cancel a Promise), but
|
|
1461
|
+
* the framework stops waiting.
|
|
1462
|
+
*
|
|
1463
|
+
* Returns `true` if the queue drained cleanly; `false` on timeout.
|
|
1464
|
+
*/
|
|
1465
|
+
drain(timeoutMs?: number): Promise<boolean>;
|
|
1466
|
+
/**
|
|
1467
|
+
* Pump the pool: while we have free slots and we're not paused, pull jobs
|
|
1468
|
+
* and dispatch them. No-op when paused / saturated. Re-entrant: every
|
|
1469
|
+
* job completion calls `pump()` again to fill the slot.
|
|
1470
|
+
*/
|
|
1471
|
+
private pump;
|
|
1472
|
+
/**
|
|
1473
|
+
* Attempts to take one job from the store and run it. Returns `false` if
|
|
1474
|
+
* the store is empty (or all-delayed) so the caller stops looping.
|
|
1475
|
+
*
|
|
1476
|
+
* NOTE: we increment `active` BEFORE the async `next()` resolves so a
|
|
1477
|
+
* burst of synchronous `pump()` calls doesn't over-subscribe the pool.
|
|
1478
|
+
* We decrement on the `null` path.
|
|
1479
|
+
*/
|
|
1480
|
+
private tryFillSlot;
|
|
1481
|
+
private runOne;
|
|
1482
|
+
private safeFail;
|
|
1483
|
+
/**
|
|
1484
|
+
* If the store has only delayed entries (e.g. just-retried jobs whose
|
|
1485
|
+
* backoff hasn't elapsed), schedule a one-shot wake when the earliest
|
|
1486
|
+
* delay fires so we don't spin or sleep forever.
|
|
1487
|
+
*
|
|
1488
|
+
* Only the default in-memory store exposes `earliestPendingAt`; for
|
|
1489
|
+
* custom stores we don't poll — we rely on the next `add()` to wake us.
|
|
1490
|
+
*/
|
|
1491
|
+
private scheduleDelayedWake;
|
|
1492
|
+
private clearWakeTimer;
|
|
1493
|
+
private checkIdle;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Maps queue names → {@link IngeniumQueue} instances. Held by `IngeniumApp`,
|
|
1498
|
+
* mirroring the shape of {@link CronRegistry}.
|
|
1499
|
+
*
|
|
1500
|
+
* Lookup is O(1). Names must be unique within an app — re-registering the
|
|
1501
|
+
* same name throws (mirrors how `app.get('/users')` would conflict if you
|
|
1502
|
+
* registered the same path twice with the same method).
|
|
1503
|
+
*/
|
|
1504
|
+
declare class QueueRegistry {
|
|
1505
|
+
private readonly queues;
|
|
1506
|
+
/**
|
|
1507
|
+
* Register a new queue. Returns the created instance. Throws if a queue
|
|
1508
|
+
* with `name` is already registered.
|
|
1509
|
+
*/
|
|
1510
|
+
register<TData>(name: string, opts: QueueOptions<TData>, worker: QueueWorker<TData>): IngeniumQueue<TData>;
|
|
1511
|
+
/** Look up a queue. Throws if not registered (typos surface immediately). */
|
|
1512
|
+
get<TData = unknown>(name: string): IngeniumQueue<TData>;
|
|
1513
|
+
/** Has a queue with this name been registered? */
|
|
1514
|
+
has(name: string): boolean;
|
|
1515
|
+
/** Number of registered queues. */
|
|
1516
|
+
count(): number;
|
|
1517
|
+
/** All registered queue names (insertion order). */
|
|
1518
|
+
names(): string[];
|
|
1519
|
+
/**
|
|
1520
|
+
* Start the worker pool of every registered queue. Called by the app's
|
|
1521
|
+
* composition step so workers don't process jobs before the app is ready.
|
|
1522
|
+
*/
|
|
1523
|
+
startAll(): void;
|
|
1524
|
+
/**
|
|
1525
|
+
* Drain every queue concurrently. Resolves when all queues either finish
|
|
1526
|
+
* their in-flight work or hit `timeoutMs`. The returned object reports
|
|
1527
|
+
* which queues drained cleanly vs timed out — useful for shutdown logs.
|
|
1528
|
+
*/
|
|
1529
|
+
drainAll(timeoutMs?: number): Promise<{
|
|
1530
|
+
clean: string[];
|
|
1531
|
+
timedOut: string[];
|
|
1532
|
+
}>;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Handler signature for `app.cron(...)` jobs. Receives the scheduled fire
|
|
1537
|
+
* time AND a fresh `now` so handlers can detect drift / late starts.
|
|
1538
|
+
*/
|
|
1539
|
+
type CronHandler = (ctx: {
|
|
1540
|
+
now: Date;
|
|
1541
|
+
firedAt: Date;
|
|
1542
|
+
}) => unknown | Promise<unknown>;
|
|
1543
|
+
/** Options for {@link IngeniumCronJob}. */
|
|
1544
|
+
interface CronOptions {
|
|
1545
|
+
/** IANA timezone for the spec. Default `'UTC'`. */
|
|
1546
|
+
timezone?: string;
|
|
1547
|
+
/**
|
|
1548
|
+
* If `true`, fire once at `start()` BEFORE waiting for the next scheduled
|
|
1549
|
+
* slot. The synthetic `firedAt` for this immediate run is `now`. Default `false`.
|
|
1550
|
+
*/
|
|
1551
|
+
runOnStart?: boolean;
|
|
1552
|
+
/**
|
|
1553
|
+
* Behavior when a previous run is still in flight at the next fire time.
|
|
1554
|
+
* - `'skip'` → drop the new tick (default).
|
|
1555
|
+
* - `'queue'` → queue ONE pending run; subsequent ticks during the same
|
|
1556
|
+
* pile-up still drop. (We don't do unbounded queuing; that's an
|
|
1557
|
+
* anti-pattern that hides bugs.)
|
|
1558
|
+
*/
|
|
1559
|
+
overlap?: 'skip' | 'queue';
|
|
1560
|
+
/** Optional name for logs / introspection. Defaults to the original spec. */
|
|
1561
|
+
name?: string;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* A single scheduled cron job. Owns its parsed spec, a `setTimeout`-based
|
|
1565
|
+
* one-shot rescheduler, and bookkeeping for in-flight runs.
|
|
1566
|
+
*
|
|
1567
|
+
* Lifecycle: `start()` arms the first timer; `stop()` cancels it. The
|
|
1568
|
+
* timer is `unref()`'d so a registered cron alone never keeps the event
|
|
1569
|
+
* loop alive — production apps that have an HTTP listener will keep
|
|
1570
|
+
* running normally; standalone scripts will exit when other work finishes.
|
|
1571
|
+
*/
|
|
1572
|
+
declare class IngeniumCronJob {
|
|
1573
|
+
readonly name: string;
|
|
1574
|
+
readonly spec: string;
|
|
1575
|
+
readonly timezone: string;
|
|
1576
|
+
readonly overlap: 'skip' | 'queue';
|
|
1577
|
+
private readonly match;
|
|
1578
|
+
private readonly handler;
|
|
1579
|
+
private readonly runOnStart;
|
|
1580
|
+
private timer;
|
|
1581
|
+
private inFlight;
|
|
1582
|
+
private queuedRun;
|
|
1583
|
+
private nextAt;
|
|
1584
|
+
private started;
|
|
1585
|
+
private stopped;
|
|
1586
|
+
constructor(spec: string, opts: CronOptions, handler: CronHandler);
|
|
1587
|
+
/** Arm the scheduler. Idempotent. */
|
|
1588
|
+
start(): void;
|
|
1589
|
+
/** Cancel the scheduler. In-flight runs continue until they naturally finish. */
|
|
1590
|
+
stop(): void;
|
|
1591
|
+
/** Next scheduled fire time, or `null` if not started / stopped. */
|
|
1592
|
+
nextRunAt(): Date | null;
|
|
1593
|
+
/** Are there currently any in-flight invocations of the handler? */
|
|
1594
|
+
isRunning(): boolean;
|
|
1595
|
+
/** @internal Test helper — does this job have its wake timer armed? */
|
|
1596
|
+
hasArmedTimer(): boolean;
|
|
1597
|
+
private scheduleNext;
|
|
1598
|
+
private dispatch;
|
|
1599
|
+
private runOnce;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Holds every registered {@link IngeniumCronJob} for an app. Mirrors the shape
|
|
1604
|
+
* of `QueueRegistry` so the integration in `IngeniumApp` is symmetric.
|
|
1605
|
+
*
|
|
1606
|
+
* Cron jobs are NOT auto-started on registration — `startAll()` runs at
|
|
1607
|
+
* compose time so handlers don't fire before the app is ready (e.g. before
|
|
1608
|
+
* `app.decorate()` plugins have wired up `ctx`-style state the handler may
|
|
1609
|
+
* inspect via the registry from another code path).
|
|
1610
|
+
*/
|
|
1611
|
+
declare class CronRegistry {
|
|
1612
|
+
private readonly jobs;
|
|
1613
|
+
private started;
|
|
1614
|
+
/** Register a new cron job. Returns the job for advanced introspection. */
|
|
1615
|
+
register(spec: string, opts: CronOptions, handler: CronHandler): IngeniumCronJob;
|
|
1616
|
+
/** Number of registered jobs. */
|
|
1617
|
+
count(): number;
|
|
1618
|
+
/** All registered job names (insertion order). */
|
|
1619
|
+
names(): string[];
|
|
1620
|
+
/** Start every registered job. Idempotent. */
|
|
1621
|
+
startAll(): void;
|
|
1622
|
+
/** Stop every registered job. In-flight handlers continue until they finish. */
|
|
1623
|
+
stopAll(): void;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** Options accepted by `ingenium(...)` and `new IngeniumApp(...)`. */
|
|
1627
|
+
interface IngeniumAppOptions {
|
|
1628
|
+
/** Max number of pooled `IngeniumContext` instances kept in the free list. Default 1024. */
|
|
1629
|
+
poolSize?: number;
|
|
1630
|
+
/** Inject a custom transport (e.g. for tests). Default: `NodeAdapter`. */
|
|
1631
|
+
transport?: Transport;
|
|
1632
|
+
/**
|
|
1633
|
+
* Trust-proxy configuration — controls whether `X-Forwarded-For`,
|
|
1634
|
+
* `X-Forwarded-Proto`, `X-Forwarded-Host` are honored when computing
|
|
1635
|
+
* `ctx.ip`, `ctx.protocol`, `ctx.hostname`. Mirrors Express's
|
|
1636
|
+
* `app.set('trust proxy', ...)` semantics. Default `false` (never trust).
|
|
1637
|
+
* See `proxy/trust.ts` for the full type. Set to `true` only when running
|
|
1638
|
+
* behind a reverse proxy you control.
|
|
1639
|
+
*/
|
|
1640
|
+
trustProxy?: TrustProxy;
|
|
1641
|
+
/**
|
|
1642
|
+
* Maximum wall-clock time (ms) for a single request to complete from
|
|
1643
|
+
* dispatch to response. When exceeded, throws `IngeniumTimeoutError(503)`
|
|
1644
|
+
* and the response becomes 503 Service Unavailable.
|
|
1645
|
+
*
|
|
1646
|
+
* The handler that timed out is NOT cancelled — JavaScript can't safely
|
|
1647
|
+
* cancel a Promise. The framework just stops waiting for it; the in-flight
|
|
1648
|
+
* work continues until it naturally completes or the process exits. This
|
|
1649
|
+
* means a slow handler can still leak compute (but not connections or
|
|
1650
|
+
* pool slots, since the response is sent and the context is released).
|
|
1651
|
+
*
|
|
1652
|
+
* Scoped to HTTP request handling only — does NOT apply to upgraded
|
|
1653
|
+
* connections (WebSocket, SSE) which are explicitly long-lived.
|
|
1654
|
+
*
|
|
1655
|
+
* Default: undefined (no timeout). Production deploys SHOULD set this.
|
|
1656
|
+
*/
|
|
1657
|
+
requestTimeoutMs?: number;
|
|
1658
|
+
/**
|
|
1659
|
+
* Hard ceiling (bytes) on the total request body, enforced at the
|
|
1660
|
+
* transport layer — applies regardless of which `ctx.body.*` consumer
|
|
1661
|
+
* reads the body, including `ctx.body.stream()`. Defaults to **2 MiB**
|
|
1662
|
+
* (2_097_152) — high enough for typical JSON / form payloads,
|
|
1663
|
+
* low enough that an unauthenticated attacker can't exhaust memory.
|
|
1664
|
+
*
|
|
1665
|
+
* Per-call limits on `ctx.body.json(schema, maxBytes)` etc. are still
|
|
1666
|
+
* honored and apply WITHIN this ceiling. To allow larger uploads on a
|
|
1667
|
+
* specific route, raise this AND use `ctx.body.stream()` with your own
|
|
1668
|
+
* size accounting.
|
|
1669
|
+
*
|
|
1670
|
+
* Set to `Infinity` to disable (NOT recommended outside controlled deploys).
|
|
1671
|
+
*/
|
|
1672
|
+
maxRequestBytes?: number;
|
|
1673
|
+
/**
|
|
1674
|
+
* Default queue-drain timeout (ms) used when the listener closes. Per-queue
|
|
1675
|
+
* timeouts can also be passed to `app.queues.drainAll(timeoutMs)`. Default
|
|
1676
|
+
* `10_000`ms — matches `gracefulShutdown`'s default `gracefulTimeoutMs`.
|
|
1677
|
+
*/
|
|
1678
|
+
queueDrainTimeoutMs?: number;
|
|
1679
|
+
/**
|
|
1680
|
+
* Secret(s) used to HMAC-sign cookies written via
|
|
1681
|
+
* `ctx.cookies.set(name, value, { signed: true })`.
|
|
1682
|
+
*
|
|
1683
|
+
* Accepts a single string or an array — the FIRST entry signs new cookies,
|
|
1684
|
+
* ALL entries verify reads (so rotating a secret is: prepend the new key,
|
|
1685
|
+
* keep the old key, deploy; remove the old key on the next deploy once all
|
|
1686
|
+
* outstanding signed cookies have expired).
|
|
1687
|
+
*
|
|
1688
|
+
* If omitted, calling `ctx.cookies.set(..., { signed: true })` or
|
|
1689
|
+
* `ctx.cookies.get(..., { signed: true })` throws
|
|
1690
|
+
* `IngeniumError(500, 'COOKIE_SECRET_MISSING')`. The unsigned cookie API
|
|
1691
|
+
* still works without any secrets configured.
|
|
1692
|
+
*/
|
|
1693
|
+
cookieSecrets?: string | string[];
|
|
1694
|
+
}
|
|
1695
|
+
/** A user-supplied error handler. Return a non-error or call a `ctx` writer to recover. */
|
|
1696
|
+
type IngeniumErrorHandler = (err: unknown, ctx: IngeniumContext) => unknown | Promise<unknown>;
|
|
1697
|
+
/**
|
|
1698
|
+
* Options for {@link IngeniumApp.inject} — the in-process test client. Mirrors
|
|
1699
|
+
* the shape a real request would carry, minus a socket. `url` is the only
|
|
1700
|
+
* required field; everything else defaults to a bare GET.
|
|
1701
|
+
*/
|
|
1702
|
+
interface InjectRequest {
|
|
1703
|
+
/** HTTP method. Defaults to `GET`. */
|
|
1704
|
+
method?: HttpMethod;
|
|
1705
|
+
/** Request URL including any query string (e.g. `/users/42?expand=posts`). */
|
|
1706
|
+
url: string;
|
|
1707
|
+
/**
|
|
1708
|
+
* Request headers. Names are lowercased before they reach the handler so
|
|
1709
|
+
* `ctx.headers['x-custom']` works regardless of the casing passed here
|
|
1710
|
+
* (matching node:http's lowercasing of inbound headers).
|
|
1711
|
+
*/
|
|
1712
|
+
headers?: Record<string, string | string[]>;
|
|
1713
|
+
/**
|
|
1714
|
+
* Request body. A `string` / `Buffer` / `Uint8Array` is sent verbatim; a
|
|
1715
|
+
* plain object is JSON-serialized and (unless the caller set a
|
|
1716
|
+
* `content-type`) tagged `application/json`.
|
|
1717
|
+
*/
|
|
1718
|
+
body?: string | Buffer$1 | Uint8Array | Record<string, unknown> | unknown[];
|
|
1719
|
+
/** Socket peer address surfaced as `ctx.remoteAddress`. Defaults to `127.0.0.1`. */
|
|
1720
|
+
remoteAddress?: string;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Result of {@link IngeniumApp.inject}. A fully-materialized snapshot of the
|
|
1724
|
+
* response — captured BEFORE the pooled context is released, so reading any
|
|
1725
|
+
* field is safe even though the underlying context has been recycled.
|
|
1726
|
+
*/
|
|
1727
|
+
interface InjectResponse {
|
|
1728
|
+
/** Final HTTP status code. */
|
|
1729
|
+
status: number;
|
|
1730
|
+
/**
|
|
1731
|
+
* Response headers, lowercased. A deep-ish copy of the context's header bag
|
|
1732
|
+
* (the array values are cloned) so sequential `inject()` calls never share
|
|
1733
|
+
* or bleed header state.
|
|
1734
|
+
*/
|
|
1735
|
+
headers: Record<string, string | string[]>;
|
|
1736
|
+
/**
|
|
1737
|
+
* Response body decoded as a UTF-8 string. Stream responses are drained to
|
|
1738
|
+
* completion first; a bodyless response (`{ kind: 'none' }`) yields `''`.
|
|
1739
|
+
*/
|
|
1740
|
+
body: string;
|
|
1741
|
+
/** Parse {@link InjectResponse.body} as JSON. Throws on invalid JSON, like `JSON.parse`. */
|
|
1742
|
+
json<T = unknown>(): T;
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Per-route options object accepted as the second positional arg to a verb
|
|
1746
|
+
* registration (`app.get(path, { auth: ['admin'] }, handler)`). Each key must
|
|
1747
|
+
* match a registered declarator (see `app.declare(...)`); the value is passed
|
|
1748
|
+
* to the declarator's factory at REGISTRATION time and the resulting
|
|
1749
|
+
* middleware is prepended to the route's chain.
|
|
1750
|
+
*/
|
|
1751
|
+
type RouteOptions = Record<string, unknown>;
|
|
1752
|
+
/**
|
|
1753
|
+
* The Ingenium application. Combines a `Router` (registration journal),
|
|
1754
|
+
* a `RouterTrie` (matched at request time), a context pool, and a
|
|
1755
|
+
* transport. Composition is lazy: the trie's composed handlers are built
|
|
1756
|
+
* on first request (or when `compose()` is called explicitly), and a dirty
|
|
1757
|
+
* bit triggers recomposition if registrations are added later.
|
|
1758
|
+
*/
|
|
1759
|
+
declare class IngeniumApp implements PluginTarget {
|
|
1760
|
+
private readonly pool;
|
|
1761
|
+
private readonly transport;
|
|
1762
|
+
private readonly router;
|
|
1763
|
+
private trie;
|
|
1764
|
+
private dirty;
|
|
1765
|
+
private errorHandler;
|
|
1766
|
+
private readonly _hooks;
|
|
1767
|
+
private readonly _decorators;
|
|
1768
|
+
/** @internal Per-route OpenAPI metadata. Keyed by `${method} ${path}`. */
|
|
1769
|
+
private readonly _routeDescriptors;
|
|
1770
|
+
/** @internal Bumped on every `describe()` call so the OpenAPI handler's cache invalidates. */
|
|
1771
|
+
private _routeDescriptorVersion;
|
|
1772
|
+
/** @internal Carried onto each `IngeniumContext` so its `ip`/`protocol`/`hostname` getters can resolve. */
|
|
1773
|
+
private readonly _trustProxy;
|
|
1774
|
+
/** @internal Wall-clock per-request ceiling. `undefined` disables the race entirely. */
|
|
1775
|
+
private readonly _requestTimeoutMs;
|
|
1776
|
+
/**
|
|
1777
|
+
* @internal Hard transport-layer ceiling on request body bytes. Passed to
|
|
1778
|
+
* the transport via `TransportHooks.maxRequestBytes`. `Infinity` disables.
|
|
1779
|
+
*/
|
|
1780
|
+
private readonly _maxRequestBytes;
|
|
1781
|
+
/** @internal Background job registry. Workers start at compose() / first request. */
|
|
1782
|
+
private readonly _queues;
|
|
1783
|
+
/** @internal Cron job registry. Timers start at compose() / first request. */
|
|
1784
|
+
private readonly _crons;
|
|
1785
|
+
/** @internal Default queue-drain timeout used when the listener closes. */
|
|
1786
|
+
private readonly _queueDrainTimeoutMs;
|
|
1787
|
+
/**
|
|
1788
|
+
* @internal Frozen list of cookie-signing secrets, normalized from the
|
|
1789
|
+
* scalar/array input. Empty array when not configured. Stamped onto each
|
|
1790
|
+
* dispatched context only when non-empty (per-request field write avoided
|
|
1791
|
+
* for the common case of "no cookie secrets configured").
|
|
1792
|
+
*/
|
|
1793
|
+
private readonly _cookieSecrets;
|
|
1794
|
+
/** @internal Whether `cookieSecrets` was configured — caches the truthy check. */
|
|
1795
|
+
private readonly _hasCookieSecrets;
|
|
1796
|
+
/**
|
|
1797
|
+
* @internal Per-request dispatch booleans, recomputed at every `compose()`.
|
|
1798
|
+
* Cached because their underlying `.hasAny()` / `.hasOnXxx()` calls walk
|
|
1799
|
+
* arrays and we don't want to pay that on every request — the registries
|
|
1800
|
+
* are frozen between composes.
|
|
1801
|
+
*
|
|
1802
|
+
* `_handleFast` is the hot-path closure: when ALL of these are off
|
|
1803
|
+
* (`!_hasHooks && !_hasDecorators && !_hasTimeout && !_hasTrustProxy`)
|
|
1804
|
+
* we route through a stripped dispatch that skips every conditional.
|
|
1805
|
+
*/
|
|
1806
|
+
private _hasHooks;
|
|
1807
|
+
private _hasOnRequest;
|
|
1808
|
+
private _hasOnResponse;
|
|
1809
|
+
private _hasOnError;
|
|
1810
|
+
private _hasDecorators;
|
|
1811
|
+
private readonly _hasTimeout;
|
|
1812
|
+
private readonly _hasTrustProxy;
|
|
1813
|
+
private _useFastPath;
|
|
1814
|
+
/**
|
|
1815
|
+
* @internal Whether `listen()` has bound a server that hasn't been closed
|
|
1816
|
+
* yet. Guards against the double-listen footgun: a second `listen()` on the
|
|
1817
|
+
* same app would silently bind a second server (two ports dispatching to one
|
|
1818
|
+
* pool), almost always a copy-paste mistake. Cleared when the returned
|
|
1819
|
+
* handle's `close()` resolves, so re-listening after a clean shutdown works.
|
|
1820
|
+
*/
|
|
1821
|
+
private _listening;
|
|
1822
|
+
constructor(options?: IngeniumAppOptions);
|
|
1823
|
+
/**
|
|
1824
|
+
* @internal Recompute the cached dispatch booleans. Called at the end of
|
|
1825
|
+
* `compose()` (and `composeAsync()`) so per-request reads are O(1) field
|
|
1826
|
+
* loads instead of `.hasAny()` array scans.
|
|
1827
|
+
*
|
|
1828
|
+
* The fast path is taken when an app uses zero opt-in features beyond the
|
|
1829
|
+
* router itself: no plugins, no decorators, no request timeout, no
|
|
1830
|
+
* trust-proxy. That's the typical "Sinatra-shape" / "Express-shape"
|
|
1831
|
+
* register-routes-and-go app — the case we want to be the absolute fastest.
|
|
1832
|
+
* Note `ctx.queue` registers a decorator at construction, so any app that
|
|
1833
|
+
* uses background jobs is OFF the fast path. That's the correct semantics:
|
|
1834
|
+
* if you want the queue ergonomic, you accept the per-request decorator
|
|
1835
|
+
* apply cost.
|
|
1836
|
+
*/
|
|
1837
|
+
private _recomputeDispatchFlags;
|
|
1838
|
+
/** Lifecycle hooks API — plugins call `app.hooks.onRequest(...)` etc. */
|
|
1839
|
+
get hooks(): Hooks;
|
|
1840
|
+
/**
|
|
1841
|
+
* Register a plugin. Plugins are invoked immediately and may be async;
|
|
1842
|
+
* callers should `await app.register(...)` if the plugin returns a Promise.
|
|
1843
|
+
* Plugins must be registered BEFORE `compose()` runs (i.e. before the
|
|
1844
|
+
* first request); registering a plugin sets the dirty bit so the next
|
|
1845
|
+
* request will recompose.
|
|
1846
|
+
*/
|
|
1847
|
+
register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>;
|
|
1848
|
+
register(plugin: IngeniumPlugin<void>): Promise<this>;
|
|
1849
|
+
/**
|
|
1850
|
+
* Open a registration scope rooted at `prefix`. Routes, middleware, and
|
|
1851
|
+
* sub-plugins registered through the `scope` parameter inside `registrar`
|
|
1852
|
+
* are automatically prefix-qualified, so a plugin's `use(mw)` only applies
|
|
1853
|
+
* to requests under `prefix` — not the whole app.
|
|
1854
|
+
*
|
|
1855
|
+
* This is the killer feature for sub-app affinity: today, plugins registered
|
|
1856
|
+
* via `app.register(...)` decorate the WHOLE app (their `app.use(mw)` calls
|
|
1857
|
+
* become global). Wrapping a `register(...)` call inside `app.scope(...)`
|
|
1858
|
+
* confines the plugin's middleware to the scope's subtree, without forcing
|
|
1859
|
+
* the user to manually prefix every route.
|
|
1860
|
+
*
|
|
1861
|
+
* The actual prefix-matching happens at compose time: scope translates
|
|
1862
|
+
* `s.use(mw)` into `app.use(prefix, mw)` on the underlying router, which
|
|
1863
|
+
* the existing `flattenRouter` / `RouterTrie` machinery already handles.
|
|
1864
|
+
* Per-request dispatch sees no new work.
|
|
1865
|
+
*
|
|
1866
|
+
* @example
|
|
1867
|
+
* app.scope('/api/v2', (s) => {
|
|
1868
|
+
* s.use(authPlugin) // only runs for /api/v2/*
|
|
1869
|
+
* s.get('/users', listUsers) // registers at /api/v2/users
|
|
1870
|
+
* })
|
|
1871
|
+
*
|
|
1872
|
+
* Nested scopes compose:
|
|
1873
|
+
*
|
|
1874
|
+
* @example
|
|
1875
|
+
* app.scope('/api', (s) => {
|
|
1876
|
+
* s.scope('/v2', (s2) => {
|
|
1877
|
+
* s2.get('/users', listUsers) // /api/v2/users
|
|
1878
|
+
* })
|
|
1879
|
+
* })
|
|
1880
|
+
*
|
|
1881
|
+
* # Caveat — decorators (V1)
|
|
1882
|
+
*
|
|
1883
|
+
* `scope.decorate(...)` decorates EVERY request, not just requests under
|
|
1884
|
+
* `prefix`. Decorators install onto the pooled context at request start,
|
|
1885
|
+
* before the route is matched. The first call from inside any scope emits
|
|
1886
|
+
* a `process.emitWarning` in non-production environments to surface this.
|
|
1887
|
+
* See `ScopedApp` for details.
|
|
1888
|
+
*
|
|
1889
|
+
* @returns `this` for chaining. If `registrar` is async, the returned
|
|
1890
|
+
* promise is NOT awaited — callers who need async plugin registration
|
|
1891
|
+
* inside a scope should await the `registrar` themselves (or use
|
|
1892
|
+
* `scope.register(asyncPlugin)`, which returns a Promise).
|
|
1893
|
+
*/
|
|
1894
|
+
scope(prefix: string, registrar: (scope: PluginTarget) => void): this;
|
|
1895
|
+
/**
|
|
1896
|
+
* @internal Mark the compose-cache dirty. Used by `ScopedApp` to invalidate
|
|
1897
|
+
* after registering routes/middleware/plugins inside a scope. External
|
|
1898
|
+
* callers should not depend on this — it's a friend-access seam for the
|
|
1899
|
+
* scope facade.
|
|
1900
|
+
*/
|
|
1901
|
+
_markDirty(): void;
|
|
1902
|
+
/**
|
|
1903
|
+
* Add a lazy decorator. The factory is invoked the first time `ctx[name]`
|
|
1904
|
+
* is read; the result is cached on the context for the rest of the request.
|
|
1905
|
+
*
|
|
1906
|
+
* @example
|
|
1907
|
+
* app.decorate('user', async (ctx) => loadUser(ctx.headers.authorization))
|
|
1908
|
+
*/
|
|
1909
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): this;
|
|
1910
|
+
/**
|
|
1911
|
+
* Add an eager decorator. The factory runs at the start of every request,
|
|
1912
|
+
* and the value is assigned directly to the context.
|
|
1913
|
+
*
|
|
1914
|
+
* @example
|
|
1915
|
+
* app.decorateRequest('startedAt', () => Date.now())
|
|
1916
|
+
*/
|
|
1917
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): this;
|
|
1918
|
+
use(mw: IngeniumMiddleware): this;
|
|
1919
|
+
use(prefix: string, mw: IngeniumMiddleware | Router): this;
|
|
1920
|
+
get(path: string, handler: IngeniumHandler): this;
|
|
1921
|
+
get(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1922
|
+
post(path: string, handler: IngeniumHandler): this;
|
|
1923
|
+
post(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1924
|
+
put(path: string, handler: IngeniumHandler): this;
|
|
1925
|
+
put(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1926
|
+
patch(path: string, handler: IngeniumHandler): this;
|
|
1927
|
+
patch(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1928
|
+
delete(path: string, handler: IngeniumHandler): this;
|
|
1929
|
+
delete(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1930
|
+
head(path: string, handler: IngeniumHandler): this;
|
|
1931
|
+
head(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1932
|
+
options(path: string, handler: IngeniumHandler): this;
|
|
1933
|
+
options(path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1934
|
+
/**
|
|
1935
|
+
* Chainable per-path registration. Returns a `RouteBuilder` that stacks
|
|
1936
|
+
* verb registrations on the same path without retyping it:
|
|
1937
|
+
*
|
|
1938
|
+
* @example
|
|
1939
|
+
* app
|
|
1940
|
+
* .route('/users/:id')
|
|
1941
|
+
* .get((ctx) => loadUser(ctx.params.id))
|
|
1942
|
+
* .put(requireAdmin, (ctx) => updateUser(ctx))
|
|
1943
|
+
* .delete(requireAdmin, (ctx) => deleteUser(ctx))
|
|
1944
|
+
*
|
|
1945
|
+
* Pure registration sugar — every call delegates to `app.method(...)`, so
|
|
1946
|
+
* declarative options, inline middleware, and typed params via
|
|
1947
|
+
* `ExtractParams<P>` work exactly as they do on the bare verb form.
|
|
1948
|
+
*/
|
|
1949
|
+
route<P extends string>(path: P): RouteBuilder<P>;
|
|
1950
|
+
/**
|
|
1951
|
+
* Register a route under any HTTP method. Accepts the variadic shape with
|
|
1952
|
+
* an optional declarative-options object as the first arg (after `path`).
|
|
1953
|
+
*/
|
|
1954
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): this;
|
|
1955
|
+
method(method: HttpMethod, path: string, optsOrFirst: RouteOptions | IngeniumMiddleware, ...rest: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
1956
|
+
/**
|
|
1957
|
+
* @internal Registry of declarators. Looked up at REGISTRATION time, not
|
|
1958
|
+
* request time — so unknown-declarator errors fire eagerly and the per-
|
|
1959
|
+
* request hot path stays clean.
|
|
1960
|
+
*/
|
|
1961
|
+
private readonly _declarators;
|
|
1962
|
+
/**
|
|
1963
|
+
* Register a declarator: a name → middleware-factory mapping. When a route
|
|
1964
|
+
* registration includes an options object with that name as a key, the value
|
|
1965
|
+
* is passed to the factory and the resulting middleware is composed into
|
|
1966
|
+
* the route's chain (in the same position as positional inline middleware,
|
|
1967
|
+
* but BEFORE any positional middleware on the same call).
|
|
1968
|
+
*
|
|
1969
|
+
* Declarators are global to the app and survive across all subsequent route
|
|
1970
|
+
* registrations until overridden by a second `declare(name, ...)` call.
|
|
1971
|
+
* Lookup is REGISTRATION-time: a route registered before the matching
|
|
1972
|
+
* `declare(...)` call throws at registration with a clear hint, not at
|
|
1973
|
+
* request time. This trades flexibility for debuggability — the error is
|
|
1974
|
+
* caught at boot, not under load.
|
|
1975
|
+
*
|
|
1976
|
+
* @example
|
|
1977
|
+
* app.declare('auth', (roles: string[]) => requireRoles(roles))
|
|
1978
|
+
* app.declare('rateLimit', (spec: string) => parseRateLimitSpec(spec))
|
|
1979
|
+
* app.get('/admin', { auth: ['admin'], rateLimit: '10/min' }, handler)
|
|
1980
|
+
*/
|
|
1981
|
+
declare<O>(name: string, factory: (opts: O) => IngeniumMiddleware): this;
|
|
1982
|
+
/**
|
|
1983
|
+
* @internal Resolve a route's options object into a list of middleware by
|
|
1984
|
+
* looking up each key in the declarator registry. Iterates keys in object
|
|
1985
|
+
* insertion order (ES2015+ guarantee for string keys). Throws at REG TIME
|
|
1986
|
+
* with a contextual message when a key has no registered declarator.
|
|
1987
|
+
*/
|
|
1988
|
+
private translateRouteOptions;
|
|
1989
|
+
/** Register a global error handler. Re-throw to delegate to the default boundary. */
|
|
1990
|
+
onError(handler: IngeniumErrorHandler): this;
|
|
1991
|
+
/**
|
|
1992
|
+
* Register a `before` filter — runs BEFORE the route handler resolves.
|
|
1993
|
+
* The user writes only the body; `await next()` is called automatically
|
|
1994
|
+
* after it returns. If the body writes a response (e.g. `ctx.json(...)`)
|
|
1995
|
+
* the chain short-circuits and the route handler does not run.
|
|
1996
|
+
*
|
|
1997
|
+
* - `before(handler)` — runs for every request (global).
|
|
1998
|
+
* - `before(pattern, handler)` — boundary-respecting prefix match
|
|
1999
|
+
* (`/admin/*` and `/admin` both match `/admin` and `/admin/x`, neither
|
|
2000
|
+
* matches `/administrator`). See `sinatra/filters.ts` for details.
|
|
2001
|
+
*/
|
|
2002
|
+
before(handler: IngeniumMiddleware): this;
|
|
2003
|
+
before(pattern: string, handler: IngeniumMiddleware): this;
|
|
2004
|
+
/**
|
|
2005
|
+
* Register an `after` filter — runs AFTER the route handler resolves but
|
|
2006
|
+
* BEFORE the adapter writes the response to the wire. The filter sees the
|
|
2007
|
+
* final ctx state (status, headers, body buffer) and may inspect or
|
|
2008
|
+
* augment it. Errors thrown by the filter propagate to the error boundary.
|
|
2009
|
+
*
|
|
2010
|
+
* - `after(handler)` — runs for every request (global).
|
|
2011
|
+
* - `after(pattern, handler)` — same prefix semantics as `before`.
|
|
2012
|
+
*/
|
|
2013
|
+
after(handler: IngeniumMiddleware): this;
|
|
2014
|
+
after(pattern: string, handler: IngeniumMiddleware): this;
|
|
2015
|
+
/**
|
|
2016
|
+
* Attach OpenAPI metadata to a route. The route must be registered separately
|
|
2017
|
+
* via `app.get/post/...`. Multiple calls MERGE shallowly onto the existing
|
|
2018
|
+
* descriptor for the same `(method, path)` pair — later keys overwrite
|
|
2019
|
+
* earlier ones, but keys absent from `meta` are preserved. This is what lets
|
|
2020
|
+
* the inline-options form (`app.get(p, { tags: [...] }, h)`) coexist with a
|
|
2021
|
+
* later explicit `app.describe(method, p, { summary })` without one wiping
|
|
2022
|
+
* the other. Reads via `generateOpenApi(app)`.
|
|
2023
|
+
*/
|
|
2024
|
+
describe(method: HttpMethod, path: string, meta: RouteDescriptor): this;
|
|
2025
|
+
/** @internal Read-only view of route descriptors — used by the OpenAPI generator. */
|
|
2026
|
+
get routeDescriptors(): ReadonlyMap<string, RouteDescriptor>;
|
|
2027
|
+
/** @internal Bumps on every `describe()` call so the OpenAPI handler can cache-bust. */
|
|
2028
|
+
get routeDescriptorVersion(): number;
|
|
2029
|
+
/** @internal Read-only view of the registration journal — used by the OpenAPI generator. */
|
|
2030
|
+
get routerJournal(): Router;
|
|
2031
|
+
/**
|
|
2032
|
+
* Register a background queue with a worker. The worker pool starts when
|
|
2033
|
+
* the app is composed (first request, `app.compose()`, or `app.listen()`).
|
|
2034
|
+
*
|
|
2035
|
+
* @example
|
|
2036
|
+
* app.queue<{ to: string; body: string }>('emails',
|
|
2037
|
+
* { concurrency: 4, retries: 5 },
|
|
2038
|
+
* async (job) => sendEmail(job.data),
|
|
2039
|
+
* )
|
|
2040
|
+
*
|
|
2041
|
+
* // From a route handler:
|
|
2042
|
+
* app.post('/signup', async (ctx) => {
|
|
2043
|
+
* const u = await createUser(await ctx.body.json())
|
|
2044
|
+
* await ctx.queue('emails').add({ to: u.email, body: 'Welcome!' })
|
|
2045
|
+
* return { ok: true }
|
|
2046
|
+
* })
|
|
2047
|
+
*/
|
|
2048
|
+
queue<TData>(name: string, opts: QueueOptions<TData>, worker: QueueWorker<TData>): this;
|
|
2049
|
+
queue<TData>(name: string, worker: QueueWorker<TData>): this;
|
|
2050
|
+
/**
|
|
2051
|
+
* Register a cron job. Spec is a 5-field crontab string. See
|
|
2052
|
+
* `src/cron/parser.ts` for the supported grammar.
|
|
2053
|
+
*
|
|
2054
|
+
* @example
|
|
2055
|
+
* app.cron('0 *\/15 * * *', () => refreshCaches()) // every 15 min
|
|
2056
|
+
* app.cron('0 0 * * 0', { timezone: 'America/Los_Angeles' }, weeklyReport)
|
|
2057
|
+
*/
|
|
2058
|
+
cron(spec: string, handler: CronHandler): this;
|
|
2059
|
+
cron(spec: string, opts: CronOptions, handler: CronHandler): this;
|
|
2060
|
+
/** @internal Read-only access to the queue registry for ops/test introspection. */
|
|
2061
|
+
get queues(): QueueRegistry;
|
|
2062
|
+
/** @internal Read-only access to the cron registry for ops/test introspection. */
|
|
2063
|
+
get crons(): CronRegistry;
|
|
2064
|
+
/**
|
|
2065
|
+
* Walk the registration journal and rebuild the trie with composed
|
|
2066
|
+
* handlers at every leaf. Auto-runs on first request; safe to call
|
|
2067
|
+
* explicitly to pre-warm.
|
|
2068
|
+
*/
|
|
2069
|
+
/** Cached flat registrations — used to build the on-miss fallback chain. */
|
|
2070
|
+
private _flat;
|
|
2071
|
+
compose(): void;
|
|
2072
|
+
/**
|
|
2073
|
+
* Async composition entry — runs `onCompose` hooks first, then composes.
|
|
2074
|
+
* Used by `handle()` and `listen()` when there are async pre-compose
|
|
2075
|
+
* listeners. Sync-only composition still works via `compose()` above.
|
|
2076
|
+
*/
|
|
2077
|
+
private composeAsync;
|
|
2078
|
+
/**
|
|
2079
|
+
* Dispatch a single context through the framework. Handles route lookup,
|
|
2080
|
+
* 404/405 generation, and the error boundary. The transport is responsible
|
|
2081
|
+
* for populating the request side of the context and writing the response
|
|
2082
|
+
* side after this resolves.
|
|
2083
|
+
*/
|
|
2084
|
+
handle(ctx: IngeniumContext): Promise<void>;
|
|
2085
|
+
/**
|
|
2086
|
+
* Run global + path-matching scoped middleware as a fallback chain when the
|
|
2087
|
+
* trie has no matching route. The terminal handler re-throws the original
|
|
2088
|
+
* trie miss so the error boundary still produces 404/405 if no middleware
|
|
2089
|
+
* wrote the response. Composed per-request — misses are exceptional.
|
|
2090
|
+
*/
|
|
2091
|
+
private runFallback;
|
|
2092
|
+
private handleError;
|
|
2093
|
+
/**
|
|
2094
|
+
* Dispatch a synthetic request through the SAME path as the wire — no
|
|
2095
|
+
* socket, no transport. Acquires a pooled context, populates it exactly as
|
|
2096
|
+
* the Node adapter would from an `IncomingMessage`, runs `handle()` (so
|
|
2097
|
+
* routing, 404/405, middleware, hooks, and the error boundary all behave
|
|
2098
|
+
* identically), then materializes the response into a plain {@link InjectResponse}.
|
|
2099
|
+
*
|
|
2100
|
+
* Crucially the context is extracted FULLY and only THEN released back to
|
|
2101
|
+
* the pool: releasing bumps `_epoch` and reassigns `_headers`/`_body`, so
|
|
2102
|
+
* reading them after release would surface the next request's state (or
|
|
2103
|
+
* empty). The returned object holds copies, so it survives the recycle and
|
|
2104
|
+
* sequential `inject()` calls never bleed header/body state into each other.
|
|
2105
|
+
*
|
|
2106
|
+
* @example
|
|
2107
|
+
* const res = await app.inject({ method: 'POST', url: '/users', body: { name: 'a' } })
|
|
2108
|
+
* expect(res.status).toBe(201)
|
|
2109
|
+
* expect(res.json<{ id: string }>().id).toBeDefined()
|
|
2110
|
+
*/
|
|
2111
|
+
inject(opts: InjectRequest): Promise<InjectResponse>;
|
|
2112
|
+
/** Bind a port and accept requests. Returns a handle for graceful shutdown. */
|
|
2113
|
+
listen(port: number, host?: string): Promise<ListeningServer>;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Factory function. Mirrors the `express()` ergonomics — `ingenium(...)` returns
|
|
2117
|
+
* a new app, and the function carries the body-parser middleware factories
|
|
2118
|
+
* plus a `Router` constructor as static properties.
|
|
2119
|
+
*/
|
|
2120
|
+
interface IngeniumFactory {
|
|
2121
|
+
(options?: IngeniumAppOptions): IngeniumApp;
|
|
2122
|
+
Router: () => Router;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Express compatibility shim. In Ingenium, body parsing is lazy via
|
|
2127
|
+
* `ctx.body.json()` / `ctx.body.urlencoded()` / `ctx.body.text()` — there is
|
|
2128
|
+
* no parse-on-every-request middleware to register. This factory exists so
|
|
2129
|
+
* existing `app.use(express.json())` migration patterns keep compiling and
|
|
2130
|
+
* reading naturally; the returned middleware is a zero-cost no-op.
|
|
2131
|
+
*
|
|
2132
|
+
* If you need to enforce a default `maxBytes` across all body access, set it
|
|
2133
|
+
* via the `limit` option here and read it inside your handlers when calling
|
|
2134
|
+
* `ctx.body.json({ limit })` — Ingenium doesn't store it implicitly.
|
|
2135
|
+
*
|
|
2136
|
+
* @returns a no-op middleware
|
|
2137
|
+
*/
|
|
2138
|
+
declare function jsonMiddleware(_opts?: {
|
|
2139
|
+
limit?: number;
|
|
2140
|
+
}): IngeniumMiddleware;
|
|
2141
|
+
/**
|
|
2142
|
+
* See `jsonMiddleware` — same rationale. URL-encoded parsing is lazy via
|
|
2143
|
+
* `ctx.body.urlencoded()`.
|
|
2144
|
+
*/
|
|
2145
|
+
declare function urlencodedMiddleware(_opts?: {
|
|
2146
|
+
limit?: number;
|
|
2147
|
+
}): IngeniumMiddleware;
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Options for the `ingenium.static` middleware.
|
|
2151
|
+
*/
|
|
2152
|
+
interface StaticOptions {
|
|
2153
|
+
/**
|
|
2154
|
+
* The file to serve when a directory is requested. Set to `false` to
|
|
2155
|
+
* disable directory-index resolution. Default: `'index.html'`.
|
|
2156
|
+
*/
|
|
2157
|
+
index?: string | false;
|
|
2158
|
+
/**
|
|
2159
|
+
* `Cache-Control: max-age=<seconds>` to set on served files, in
|
|
2160
|
+
* MILLISECONDS (Express convention). Default: `0` (no caching).
|
|
2161
|
+
*/
|
|
2162
|
+
maxAge?: number;
|
|
2163
|
+
/**
|
|
2164
|
+
* Extensions to try (in order) when the requested path doesn't exist.
|
|
2165
|
+
* For example, `['html']` lets `/about` resolve to `/about.html`.
|
|
2166
|
+
* Default: `[]` (off).
|
|
2167
|
+
*/
|
|
2168
|
+
extensions?: string[];
|
|
2169
|
+
/**
|
|
2170
|
+
* How to treat files / directories whose name starts with `.`:
|
|
2171
|
+
* - `'allow'` — serve normally
|
|
2172
|
+
* - `'deny'` — respond with 403
|
|
2173
|
+
* - `'ignore'` — call `next()` (let routes 404 it). DEFAULT.
|
|
2174
|
+
*/
|
|
2175
|
+
dotfiles?: 'allow' | 'deny' | 'ignore';
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* Static-file middleware. Serves files from `root`, supporting directory
|
|
2180
|
+
* indexes, weak ETags, `If-None-Match`, byte-range requests, and basic
|
|
2181
|
+
* dotfile policy. Misses (file not found) call `next()` — they do NOT
|
|
2182
|
+
* write 404 themselves, so downstream routes still get a chance.
|
|
2183
|
+
*
|
|
2184
|
+
* @example
|
|
2185
|
+
* app.use(ingenium.static('./public', { maxAge: 60_000 }))
|
|
2186
|
+
*/
|
|
2187
|
+
declare function staticMiddleware(root: string, opts?: StaticOptions): IngeniumMiddleware;
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* Function form of the `origin` option. Receives the request's `Origin`
|
|
2191
|
+
* header value (always a string — never called when no `Origin` is present)
|
|
2192
|
+
* and the active `IngeniumContext`. May return:
|
|
2193
|
+
*
|
|
2194
|
+
* - `true` — allow the request, reflect the request's `Origin` back.
|
|
2195
|
+
* - `false` — deny the request (no `Access-Control-Allow-Origin` header set).
|
|
2196
|
+
* - `string` — allow the request, use this exact value as the
|
|
2197
|
+
* `Access-Control-Allow-Origin` header (use `'*'` for the wildcard).
|
|
2198
|
+
*
|
|
2199
|
+
* May be sync or async.
|
|
2200
|
+
*/
|
|
2201
|
+
type CorsOriginFn = (origin: string, ctx: IngeniumContext) => boolean | string | Promise<boolean | string>;
|
|
2202
|
+
/**
|
|
2203
|
+
* Spec for the `origin` option.
|
|
2204
|
+
*
|
|
2205
|
+
* - `boolean` — `true` reflects any request `Origin`; `false` disables CORS.
|
|
2206
|
+
* - `'*'` — wildcard: `Access-Control-Allow-Origin: *`.
|
|
2207
|
+
* - any other `string` — exact match against the request's `Origin`.
|
|
2208
|
+
* - `string[]` — allowlist; matched exactly.
|
|
2209
|
+
* - `RegExp` — tested against the request's `Origin`.
|
|
2210
|
+
* - `CorsOriginFn` — fully custom predicate (see above).
|
|
2211
|
+
*/
|
|
2212
|
+
type CorsOrigin = boolean | string | string[] | RegExp | CorsOriginFn;
|
|
2213
|
+
/**
|
|
2214
|
+
* Options for `ingenium.cors`. All fields are optional. See README for details.
|
|
2215
|
+
*/
|
|
2216
|
+
interface CorsOptions {
|
|
2217
|
+
/** Origin policy. Default: `'*'`. */
|
|
2218
|
+
origin?: CorsOrigin;
|
|
2219
|
+
/**
|
|
2220
|
+
* Methods advertised on `Access-Control-Allow-Methods` for preflight.
|
|
2221
|
+
* Default: `['GET','HEAD','PUT','PATCH','POST','DELETE']`.
|
|
2222
|
+
*/
|
|
2223
|
+
methods?: string[];
|
|
2224
|
+
/**
|
|
2225
|
+
* Headers advertised on `Access-Control-Allow-Headers` for preflight.
|
|
2226
|
+
* If `undefined`, the value of `Access-Control-Request-Headers` from the
|
|
2227
|
+
* preflight request is mirrored back. Default: `undefined`.
|
|
2228
|
+
*/
|
|
2229
|
+
allowedHeaders?: string[];
|
|
2230
|
+
/**
|
|
2231
|
+
* Headers advertised on `Access-Control-Expose-Headers` for simple
|
|
2232
|
+
* responses. Default: `undefined` (header omitted).
|
|
2233
|
+
*/
|
|
2234
|
+
exposedHeaders?: string[];
|
|
2235
|
+
/**
|
|
2236
|
+
* If `true`, sets `Access-Control-Allow-Credentials: true`.
|
|
2237
|
+
* Incompatible with `origin: '*'` — throws at construction time.
|
|
2238
|
+
* Default: `false`.
|
|
2239
|
+
*/
|
|
2240
|
+
credentials?: boolean;
|
|
2241
|
+
/**
|
|
2242
|
+
* `Access-Control-Max-Age` (seconds). Default: `undefined` (header omitted).
|
|
2243
|
+
*/
|
|
2244
|
+
maxAge?: number;
|
|
2245
|
+
/**
|
|
2246
|
+
* Status code for successful preflight responses. Default: `204`.
|
|
2247
|
+
*/
|
|
2248
|
+
optionsSuccessStatus?: number;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* CORS middleware. Implements the standard CORS protocol (Fetch spec
|
|
2253
|
+
* §3.2.4) for both simple requests and preflight (`OPTIONS` +
|
|
2254
|
+
* `Access-Control-Request-Method`).
|
|
2255
|
+
*
|
|
2256
|
+
* @example
|
|
2257
|
+
* app.use(ingenium.cors())
|
|
2258
|
+
* app.use(ingenium.cors({ origin: ['https://app.example.com'], credentials: true }))
|
|
2259
|
+
*/
|
|
2260
|
+
declare function corsMiddleware(opts?: CorsOptions): IngeniumMiddleware;
|
|
2261
|
+
|
|
2262
|
+
/**
|
|
2263
|
+
* A single Server-Sent Event. The `data` field is required; if you pass an
|
|
2264
|
+
* object, it's `JSON.stringify`'d before being written. All other fields are
|
|
2265
|
+
* optional and serialized per the EventSource specification.
|
|
2266
|
+
*
|
|
2267
|
+
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
2268
|
+
*/
|
|
2269
|
+
interface SseEvent {
|
|
2270
|
+
/** Payload. Strings are written verbatim; objects are JSON-encoded. */
|
|
2271
|
+
data: string | object;
|
|
2272
|
+
/** Optional event name — populates `event:` field. */
|
|
2273
|
+
event?: string;
|
|
2274
|
+
/** Optional event id — populates `id:` field. */
|
|
2275
|
+
id?: string;
|
|
2276
|
+
/** Optional retry hint in milliseconds — populates `retry:` field. */
|
|
2277
|
+
retry?: number;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Handle for an open SSE connection. Returned by {@link sse}. Use `send()`
|
|
2281
|
+
* to push events, `comment()` for keep-alive frames, and `close()` to end
|
|
2282
|
+
* the response stream cleanly.
|
|
2283
|
+
*/
|
|
2284
|
+
interface SseStream {
|
|
2285
|
+
/**
|
|
2286
|
+
* Send a single event. A bare string is treated as `{ data: <string> }`.
|
|
2287
|
+
*/
|
|
2288
|
+
send(event: SseEvent | string): void;
|
|
2289
|
+
/** Write a comment line (`: <text>`). Useful for heartbeats / keep-alive. */
|
|
2290
|
+
comment(text: string): void;
|
|
2291
|
+
/** End the response stream. Subsequent calls are no-ops. */
|
|
2292
|
+
close(): void;
|
|
2293
|
+
/** Whether the underlying stream has been closed (locally or by the client). */
|
|
2294
|
+
readonly closed: boolean;
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Open a Server-Sent Events response on the given context. Sets the
|
|
2298
|
+
* appropriate headers (`Content-Type: text/event-stream`, no caching, no
|
|
2299
|
+
* proxy buffering) and wires a `PassThrough` into `ctx.stream()`.
|
|
2300
|
+
*
|
|
2301
|
+
* @example
|
|
2302
|
+
* app.get('/events', (ctx) => {
|
|
2303
|
+
* const stream = sse(ctx)
|
|
2304
|
+
* stream.send({ event: 'hello', data: { msg: 'world' } })
|
|
2305
|
+
* setTimeout(() => stream.close(), 1000)
|
|
2306
|
+
* })
|
|
2307
|
+
*/
|
|
2308
|
+
declare function sse(ctx: IngeniumContext): SseStream;
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Pluggable backing store for the rate-limit middleware. The default
|
|
2312
|
+
* in-memory implementation is sync internally but exposes a Promise-based
|
|
2313
|
+
* surface so a Redis (or other distributed) store can drop in unchanged.
|
|
2314
|
+
*/
|
|
2315
|
+
interface RateLimitStore {
|
|
2316
|
+
/**
|
|
2317
|
+
* Record a hit for `key`. Returns the new count and the unix-millis
|
|
2318
|
+
* timestamp at which the current window expires.
|
|
2319
|
+
*
|
|
2320
|
+
* Implementations MUST roll the window over when `Date.now() >= resetAt`,
|
|
2321
|
+
* resetting the count to 1.
|
|
2322
|
+
*/
|
|
2323
|
+
hit(key: string, windowMs: number): Promise<{
|
|
2324
|
+
count: number;
|
|
2325
|
+
resetAt: number;
|
|
2326
|
+
}>;
|
|
2327
|
+
/** Clear the counter for `key`. Used by tests and by ops tooling. */
|
|
2328
|
+
reset(key: string): Promise<void>;
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Options for {@link rateLimit}.
|
|
2332
|
+
*/
|
|
2333
|
+
interface RateLimitOptions {
|
|
2334
|
+
/**
|
|
2335
|
+
* Window length in milliseconds. Default: `60_000` (one minute).
|
|
2336
|
+
*
|
|
2337
|
+
* Each key is allowed at most `max` requests per window. Counts reset
|
|
2338
|
+
* sharply at window boundaries (fixed-window algorithm).
|
|
2339
|
+
*/
|
|
2340
|
+
windowMs?: number;
|
|
2341
|
+
/** Max requests per `windowMs` per key. Default: `100`. */
|
|
2342
|
+
max?: number;
|
|
2343
|
+
/**
|
|
2344
|
+
* Build the limiter key for a request. Default uses `X-Forwarded-For`
|
|
2345
|
+
* (first hop), then `X-Real-IP`, then the literal string `'unknown'`.
|
|
2346
|
+
*
|
|
2347
|
+
* **Security**: the default trusts `X-Forwarded-For` blindly. Without
|
|
2348
|
+
* an upstream that strips client-supplied values, this header is
|
|
2349
|
+
* forgeable. Production deployments behind a proxy should validate the
|
|
2350
|
+
* proxy chain or supply a custom `keyGenerator`.
|
|
2351
|
+
*/
|
|
2352
|
+
keyGenerator?: (ctx: IngeniumContext) => string;
|
|
2353
|
+
/**
|
|
2354
|
+
* Skip rate-limiting for a given request. When this returns `true`, no
|
|
2355
|
+
* counter hit is recorded and no `X-RateLimit-*` headers are written.
|
|
2356
|
+
* Default: never skip.
|
|
2357
|
+
*/
|
|
2358
|
+
skip?: (ctx: IngeniumContext) => boolean;
|
|
2359
|
+
/**
|
|
2360
|
+
* Backing store. Default: an in-process {@link MemoryStore}. Swap in a
|
|
2361
|
+
* shared store (Redis etc.) when running multiple replicas.
|
|
2362
|
+
*/
|
|
2363
|
+
store?: RateLimitStore;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/**
|
|
2367
|
+
* Fixed-window rate-limiting middleware. Each key is allowed at most `max`
|
|
2368
|
+
* requests per `windowMs`. Over-limit requests get a `429 Too Many
|
|
2369
|
+
* Requests` response with `Retry-After` and a JSON body.
|
|
2370
|
+
*
|
|
2371
|
+
* Every passing response carries `X-RateLimit-Limit`,
|
|
2372
|
+
* `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds).
|
|
2373
|
+
*
|
|
2374
|
+
* @example
|
|
2375
|
+
* app.use(rateLimit({ max: 100, windowMs: 60_000 }))
|
|
2376
|
+
* app.use('/auth', rateLimit({ max: 5, windowMs: 60_000 }))
|
|
2377
|
+
*/
|
|
2378
|
+
declare function rateLimit(opts?: RateLimitOptions): IngeniumMiddleware;
|
|
2379
|
+
|
|
2380
|
+
/**
|
|
2381
|
+
* Base error class for all framework-emitted errors. Errors that extend
|
|
2382
|
+
* `IngeniumError` are caught by the global error boundary and serialized to the
|
|
2383
|
+
* client according to their `statusCode` and `code`.
|
|
2384
|
+
*/
|
|
2385
|
+
declare class IngeniumError extends Error {
|
|
2386
|
+
readonly statusCode: number;
|
|
2387
|
+
readonly code: string;
|
|
2388
|
+
readonly cause?: unknown | undefined;
|
|
2389
|
+
/**
|
|
2390
|
+
* @param statusCode HTTP status code to send to the client.
|
|
2391
|
+
* @param code Machine-readable error code (UPPER_SNAKE_CASE convention).
|
|
2392
|
+
* @param message Human-readable error message.
|
|
2393
|
+
* @param cause Optional underlying error.
|
|
2394
|
+
*/
|
|
2395
|
+
constructor(statusCode: number, code: string, message: string, cause?: unknown | undefined);
|
|
2396
|
+
}
|
|
2397
|
+
/** 404 — no route matched. */
|
|
2398
|
+
declare class IngeniumNotFoundError extends IngeniumError {
|
|
2399
|
+
constructor(message?: string);
|
|
2400
|
+
}
|
|
2401
|
+
/** 401 — authentication required or invalid. */
|
|
2402
|
+
declare class IngeniumUnauthorizedError extends IngeniumError {
|
|
2403
|
+
constructor(message?: string);
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* 405 — path matched but method did not. Includes the list of allowed methods,
|
|
2407
|
+
* which the framework writes into the `Allow` response header automatically.
|
|
2408
|
+
*/
|
|
2409
|
+
declare class IngeniumMethodNotAllowedError extends IngeniumError {
|
|
2410
|
+
readonly allowed: readonly string[];
|
|
2411
|
+
constructor(allowed: readonly string[], message?: string);
|
|
2412
|
+
}
|
|
2413
|
+
/** 413 — request body exceeded the configured `maxBytes` limit. */
|
|
2414
|
+
declare class IngeniumPayloadTooLargeError extends IngeniumError {
|
|
2415
|
+
constructor(message?: string);
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* 422 — request body parsed successfully but failed validation. The `fields`
|
|
2419
|
+
* map is serialized into the response body so clients can render field-level
|
|
2420
|
+
* error messages.
|
|
2421
|
+
*/
|
|
2422
|
+
declare class IngeniumValidationError extends IngeniumError {
|
|
2423
|
+
readonly fields: Record<string, string>;
|
|
2424
|
+
constructor(fields: Record<string, string>, message?: string);
|
|
2425
|
+
}
|
|
2426
|
+
/** 400 — request was malformed (bad JSON, invalid content-type, etc). */
|
|
2427
|
+
declare class IngeniumBadRequestError extends IngeniumError {
|
|
2428
|
+
constructor(message?: string, cause?: unknown);
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* 500 — caller attempted to write a header name or value containing CR or
|
|
2432
|
+
* LF. Node would eventually reject these at the wire level, but the late
|
|
2433
|
+
* throw produces a useless stack — we fail fast at the call site so the
|
|
2434
|
+
* offending header (and the route that set it) shows up in the trace.
|
|
2435
|
+
*/
|
|
2436
|
+
declare class IngeniumHeaderInjectionError extends IngeniumError {
|
|
2437
|
+
constructor(message?: string);
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* 500 — `ctx.json` (or `respondJsonWithEtag`) was handed a value that
|
|
2441
|
+
* `JSON.stringify` cannot serialize: a circular structure, a `BigInt`, or
|
|
2442
|
+
* any other unsupported shape. The original `TypeError` is attached as
|
|
2443
|
+
* `cause` and emitted via `process.emitWarning` for diagnostics.
|
|
2444
|
+
*/
|
|
2445
|
+
declare class IngeniumUnserializableError extends IngeniumError {
|
|
2446
|
+
constructor(message: string, cause?: unknown);
|
|
2447
|
+
}
|
|
2448
|
+
/**
|
|
2449
|
+
* Sinatra-style `halt` short-circuit. Thrown by `ctx.halt(status, body?)`;
|
|
2450
|
+
* caught by the default error boundary and serialized according to `bodyShape`:
|
|
2451
|
+
*
|
|
2452
|
+
* - `'none'` → boundary uses default `{ error, code: 'HALT' }` JSON shape.
|
|
2453
|
+
* - `'text'` → boundary writes `body` as `text/plain` verbatim.
|
|
2454
|
+
* - `'json'` → boundary writes `body` as `application/json`.
|
|
2455
|
+
*
|
|
2456
|
+
* The body shape is decided at the call site (string ⇒ text, object ⇒ json,
|
|
2457
|
+
* undefined ⇒ none) so the boundary can branch without re-inspecting types.
|
|
2458
|
+
* Custom `app.onError` handlers still receive the error and can override it
|
|
2459
|
+
* (e.g. add a header, reshape the body) by writing the response themselves.
|
|
2460
|
+
*/
|
|
2461
|
+
declare class IngeniumHaltError extends IngeniumError {
|
|
2462
|
+
/** What the default error boundary should do with `body`. */
|
|
2463
|
+
readonly bodyShape: 'none' | 'text' | 'json';
|
|
2464
|
+
/** The body argument from `ctx.halt(status, body?)`. */
|
|
2465
|
+
readonly body: string | Record<string, unknown> | undefined;
|
|
2466
|
+
constructor(statusCode: number, body?: string | Record<string, unknown>);
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* 503 — handler exceeded the configured `requestTimeoutMs` ceiling. The
|
|
2470
|
+
* orphaned handler is NOT cancelled (JavaScript can't safely cancel a
|
|
2471
|
+
* Promise); the framework just stops waiting for it. Late writes from the
|
|
2472
|
+
* orphaned handler are guarded by the per-request epoch counter on the
|
|
2473
|
+
* context and discarded with a `process.emitWarning`.
|
|
2474
|
+
*/
|
|
2475
|
+
declare class IngeniumTimeoutError extends IngeniumError {
|
|
2476
|
+
constructor(timeoutMs: number, message?: string);
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/**
|
|
2480
|
+
* Where the CSRF token lives between requests.
|
|
2481
|
+
*
|
|
2482
|
+
* - `'cookie'` (default): double-submit cookie pattern. Token is generated
|
|
2483
|
+
* on safe requests, written to a non-HttpOnly cookie, and the client must
|
|
2484
|
+
* echo it back via a header on unsafe requests. No session required.
|
|
2485
|
+
* - `'session'`: synchronizer pattern. Token is stored on `ctx.session`
|
|
2486
|
+
* and validated against the submitted token on unsafe requests. Requires
|
|
2487
|
+
* `sessionMiddleware` to run before this middleware.
|
|
2488
|
+
*/
|
|
2489
|
+
type CsrfStorage = 'cookie' | 'session';
|
|
2490
|
+
/** How to extract the submitted token from an incoming request. */
|
|
2491
|
+
type CsrfValueReader = (ctx: IngeniumContext) => string | undefined | Promise<string | undefined>;
|
|
2492
|
+
interface CsrfCookieOptions {
|
|
2493
|
+
/** Cookie name. Default `ingenium.csrf`. */
|
|
2494
|
+
name?: string;
|
|
2495
|
+
/** Restrict cookie to a single subpath. Default `/`. */
|
|
2496
|
+
path?: string;
|
|
2497
|
+
/** Restrict cookie to a domain. Default unset. */
|
|
2498
|
+
domain?: string;
|
|
2499
|
+
/** SameSite policy. Default `'lax'`. */
|
|
2500
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
2501
|
+
/** Mark cookie Secure. Default `false`; set `true` behind TLS. */
|
|
2502
|
+
secure?: boolean;
|
|
2503
|
+
/**
|
|
2504
|
+
* Mark cookie HttpOnly. **Default `false`** — clients must read the cookie
|
|
2505
|
+
* to copy the value into the request header. Setting `true` would break the
|
|
2506
|
+
* double-submit pattern; only enable with a custom value reader that pulls
|
|
2507
|
+
* the token from elsewhere.
|
|
2508
|
+
*/
|
|
2509
|
+
httpOnly?: boolean;
|
|
2510
|
+
/** Cookie max-age (seconds). Default 7 days. */
|
|
2511
|
+
maxAgeSeconds?: number;
|
|
2512
|
+
}
|
|
2513
|
+
interface CsrfOptions {
|
|
2514
|
+
/**
|
|
2515
|
+
* HMAC secret used to sign the token. Required for the cookie storage
|
|
2516
|
+
* mode (signed double-submit). For session storage the secret is optional
|
|
2517
|
+
* — the session id already authenticates the binding.
|
|
2518
|
+
*/
|
|
2519
|
+
secret?: string | string[];
|
|
2520
|
+
/** Token storage strategy. Default `'cookie'`. */
|
|
2521
|
+
storage?: CsrfStorage;
|
|
2522
|
+
/** Cookie options when `storage === 'cookie'`. */
|
|
2523
|
+
cookie?: CsrfCookieOptions;
|
|
2524
|
+
/** Methods that bypass validation. Default `['GET', 'HEAD', 'OPTIONS', 'TRACE']`. */
|
|
2525
|
+
ignoreMethods?: readonly string[];
|
|
2526
|
+
/**
|
|
2527
|
+
* How to extract the submitted token. Default reads (in order):
|
|
2528
|
+
* 1. `X-CSRF-Token` header
|
|
2529
|
+
* 2. `X-XSRF-Token` header (Angular convention)
|
|
2530
|
+
* 3. `_csrf` query string parameter
|
|
2531
|
+
*/
|
|
2532
|
+
value?: CsrfValueReader;
|
|
2533
|
+
/**
|
|
2534
|
+
* Per-request opt-out. Return `true` to skip validation entirely for
|
|
2535
|
+
* this request (and skip token issuance).
|
|
2536
|
+
*/
|
|
2537
|
+
skip?: (ctx: IngeniumContext) => boolean | Promise<boolean>;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/** 403 Forbidden — CSRF token missing or mismatched. */
|
|
2541
|
+
declare class IngeniumCsrfError extends IngeniumError {
|
|
2542
|
+
constructor(message?: string);
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* CSRF protection middleware. Two modes:
|
|
2546
|
+
*
|
|
2547
|
+
* - `storage: 'cookie'` (default) — double-submit cookie pattern. A
|
|
2548
|
+
* randomly-generated token is HMAC-signed, written to a non-HttpOnly
|
|
2549
|
+
* cookie on safe requests, and the client must echo the cookie value
|
|
2550
|
+
* back in a header (`X-CSRF-Token`) on unsafe requests. The signature
|
|
2551
|
+
* prevents client-side forgery; the same-origin policy prevents
|
|
2552
|
+
* cross-origin sites from reading the cookie.
|
|
2553
|
+
*
|
|
2554
|
+
* - `storage: 'session'` — synchronizer pattern. The token is stored on
|
|
2555
|
+
* `ctx.session` and matched against the submitted token. Requires
|
|
2556
|
+
* `sessionMiddleware` to run before this middleware.
|
|
2557
|
+
*
|
|
2558
|
+
* Use `ctx.state.csrfToken` (or call `(ctx as IngeniumContext & { csrfToken(): string }).csrfToken()`)
|
|
2559
|
+
* to read the current token to embed in HTML forms or send to a JS client.
|
|
2560
|
+
*/
|
|
2561
|
+
declare function csrfMiddleware(opts?: CsrfOptions): IngeniumMiddleware;
|
|
2562
|
+
|
|
2563
|
+
/**
|
|
2564
|
+
* RFC 7807 Problem Details for HTTP APIs.
|
|
2565
|
+
*
|
|
2566
|
+
* The five standard members are reserved by the spec; arbitrary additional
|
|
2567
|
+
* extension members are permitted via the index signature. See
|
|
2568
|
+
* https://datatracker.ietf.org/doc/html/rfc7807#section-3.1.
|
|
2569
|
+
*/
|
|
2570
|
+
interface ProblemDetails {
|
|
2571
|
+
/**
|
|
2572
|
+
* A URI reference that identifies the problem type. When dereferenced it
|
|
2573
|
+
* SHOULD provide human-readable documentation. Default per spec is
|
|
2574
|
+
* `'about:blank'`, indicating that no specific problem-type URL exists
|
|
2575
|
+
* (in which case `title` is conventionally the HTTP status reason phrase).
|
|
2576
|
+
*/
|
|
2577
|
+
type: string;
|
|
2578
|
+
/**
|
|
2579
|
+
* A short, human-readable summary of the problem type. SHOULD NOT change
|
|
2580
|
+
* from occurrence to occurrence (use `detail` for instance-specific info).
|
|
2581
|
+
*/
|
|
2582
|
+
title: string;
|
|
2583
|
+
/** HTTP status code generated by the origin server. */
|
|
2584
|
+
status: number;
|
|
2585
|
+
/** Human-readable explanation specific to this occurrence of the problem. */
|
|
2586
|
+
detail?: string;
|
|
2587
|
+
/** A URI reference identifying the specific occurrence of the problem. */
|
|
2588
|
+
instance?: string;
|
|
2589
|
+
/** RFC 7807 permits arbitrary extension members. */
|
|
2590
|
+
[key: string]: unknown;
|
|
2591
|
+
}
|
|
2592
|
+
/** Options accepted by `ingenium.problemDetails(...)`. */
|
|
2593
|
+
interface ProblemDetailsOptions {
|
|
2594
|
+
/**
|
|
2595
|
+
* Prefix used when constructing the `type` URI from an error's `code`.
|
|
2596
|
+
* Example: `'https://api.example.com/errors/'` + `NOT_FOUND` →
|
|
2597
|
+
* `'https://api.example.com/errors/not-found'`.
|
|
2598
|
+
*
|
|
2599
|
+
* Default `'about:blank'` (per spec — no problem-specific docs URL).
|
|
2600
|
+
*/
|
|
2601
|
+
typeBaseUrl?: string;
|
|
2602
|
+
/**
|
|
2603
|
+
* If true, attaches the error's `stack` as an extension member. Useful in
|
|
2604
|
+
* development; never enable in production — stack traces leak source paths
|
|
2605
|
+
* and internal structure. Default `false`.
|
|
2606
|
+
*/
|
|
2607
|
+
includeStack?: boolean;
|
|
2608
|
+
/**
|
|
2609
|
+
* Override how the `instance` URI is derived. Default returns `ctx.path`.
|
|
2610
|
+
* Return `undefined` to omit the field entirely.
|
|
2611
|
+
*/
|
|
2612
|
+
instance?: (ctx: IngeniumContext) => string | undefined;
|
|
2613
|
+
}
|
|
2614
|
+
/** Options after defaults have been applied. Internal use. */
|
|
2615
|
+
interface ResolvedProblemDetailsOptions {
|
|
2616
|
+
typeBaseUrl: string;
|
|
2617
|
+
includeStack: boolean;
|
|
2618
|
+
instance: (ctx: IngeniumContext) => string | undefined;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* RFC 7807 Problem Details middleware. Wraps downstream handlers in a
|
|
2623
|
+
* try/catch and serializes any `IngeniumError` (or unknown error) as
|
|
2624
|
+
* `application/problem+json` instead of the framework's default
|
|
2625
|
+
* `{ error, code, fields? }` shape.
|
|
2626
|
+
*
|
|
2627
|
+
* Composition notes:
|
|
2628
|
+
* - This sits as a regular middleware in front of user handlers, NOT in
|
|
2629
|
+
* place of `app.onError`. If `app.onError` is configured AND it re-throws
|
|
2630
|
+
* (or the user handler throws past the onError), this middleware catches
|
|
2631
|
+
* the error before it reaches the default boundary.
|
|
2632
|
+
* - Composes cleanly with other middleware (e.g. idempotency) — the
|
|
2633
|
+
* try/catch is the only thing it does on the way out.
|
|
2634
|
+
*
|
|
2635
|
+
* @example
|
|
2636
|
+
* app.use(ingenium.problemDetails({
|
|
2637
|
+
* typeBaseUrl: 'https://api.example.com/errors/',
|
|
2638
|
+
* includeStack: process.env.NODE_ENV !== 'production',
|
|
2639
|
+
* }))
|
|
2640
|
+
*/
|
|
2641
|
+
declare function problemDetailsMiddleware(opts?: ProblemDetailsOptions): IngeniumMiddleware;
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* A frozen snapshot of an outgoing response. Captured AFTER the handler
|
|
2645
|
+
* runs and stored in the idempotency cache for replay on retry.
|
|
2646
|
+
*
|
|
2647
|
+
* `body` is `null` only for empty responses (e.g. 204).
|
|
2648
|
+
*/
|
|
2649
|
+
interface CachedResponse {
|
|
2650
|
+
/** HTTP status code from the original response. */
|
|
2651
|
+
statusCode: number;
|
|
2652
|
+
/** Plain header bag (lowercased keys), copied from `ctx._headers`. */
|
|
2653
|
+
headers: Record<string, string | string[]>;
|
|
2654
|
+
/** Serialized body. `null` when the original response had no body. */
|
|
2655
|
+
body: string | Buffer$1 | null;
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Pluggable storage for the idempotency cache. Default impl is in-memory;
|
|
2659
|
+
* swap for Redis/etc. when running multiple replicas.
|
|
2660
|
+
*/
|
|
2661
|
+
interface IdempotencyStore {
|
|
2662
|
+
/** Returns the cached response for `key` or `null` if missing/expired. */
|
|
2663
|
+
get(key: string): Promise<CachedResponse | null>;
|
|
2664
|
+
/** Persist `value` under `key` for `ttlMs` milliseconds. */
|
|
2665
|
+
set(key: string, value: CachedResponse, ttlMs: number): Promise<void>;
|
|
2666
|
+
/** Remove `key`. Idempotent — does nothing if absent. */
|
|
2667
|
+
delete(key: string): Promise<void>;
|
|
2668
|
+
}
|
|
2669
|
+
/** Options accepted by `ingenium.idempotency(...)`. */
|
|
2670
|
+
interface IdempotencyOptions {
|
|
2671
|
+
/**
|
|
2672
|
+
* Header name carrying the idempotency key. Comparison is
|
|
2673
|
+
* case-insensitive (Node lowercases header names automatically).
|
|
2674
|
+
* Default `'Idempotency-Key'`.
|
|
2675
|
+
*/
|
|
2676
|
+
header?: string;
|
|
2677
|
+
/** Backing cache. Default: an in-process `IdempotencyMemoryStore`. */
|
|
2678
|
+
store?: IdempotencyStore;
|
|
2679
|
+
/**
|
|
2680
|
+
* Time-to-live for cached responses, in seconds. After this elapses, the
|
|
2681
|
+
* same key replays nothing and the handler runs again. Default `86400`
|
|
2682
|
+
* (24h) — matches Stripe's documented behavior.
|
|
2683
|
+
*/
|
|
2684
|
+
ttlSeconds?: number;
|
|
2685
|
+
/**
|
|
2686
|
+
* Namespace function — distinguishes keys belonging to different callers
|
|
2687
|
+
* so two clients can independently use the same idempotency-key string.
|
|
2688
|
+
* Default uses the `Authorization` header (or `'anon'` when absent).
|
|
2689
|
+
*/
|
|
2690
|
+
scope?: (ctx: IngeniumContext) => string;
|
|
2691
|
+
/**
|
|
2692
|
+
* HTTP methods eligible for idempotency caching. Default: `['POST',
|
|
2693
|
+
* 'PATCH', 'DELETE']` — only mutating methods. Safe methods (GET/HEAD/
|
|
2694
|
+
* OPTIONS) and idempotent-by-spec PUT are skipped by default; opt in by
|
|
2695
|
+
* extending this list if you need PUT semantics cached too.
|
|
2696
|
+
*/
|
|
2697
|
+
methods?: readonly HttpMethod[];
|
|
2698
|
+
/**
|
|
2699
|
+
* Predicate deciding which response status codes are cacheable. Default:
|
|
2700
|
+
* `(s) => s >= 200 && s < 500` — caches 2xx/3xx/4xx but NOT 5xx, matching
|
|
2701
|
+
* Stripe's documented behavior. The intent: a transient 500 (DB blip,
|
|
2702
|
+
* deploy race) must NOT be cached for the entire TTL — every retry would
|
|
2703
|
+
* replay the same failure and the user would be permanently broken until
|
|
2704
|
+
* the cache expires. Validation errors (4xx) are deterministic and worth
|
|
2705
|
+
* caching. Override to opt in to 5xx caching or tighten further.
|
|
2706
|
+
*/
|
|
2707
|
+
cacheable?: (statusCode: number) => boolean;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* Idempotency-Key middleware (per Stripe / IETF idempotency-key draft).
|
|
2712
|
+
*
|
|
2713
|
+
* Behavior:
|
|
2714
|
+
* - Non-mutating method or missing header → pass through.
|
|
2715
|
+
* - Mutating method WITH header:
|
|
2716
|
+
* 1. Build cache key: `<scope>:<method>:<path>:<idempotency-key>`.
|
|
2717
|
+
* 2. Cache hit → replay the cached (status, headers, body) and set
|
|
2718
|
+
* `Idempotent-Replayed: true`. Handler does NOT run.
|
|
2719
|
+
* 3. Cache miss → run handler. If the response is cacheable (i.e. not a
|
|
2720
|
+
* stream and something was written), persist it under the key with
|
|
2721
|
+
* the configured TTL.
|
|
2722
|
+
* 4. Concurrent in-flight requests for the same key are coordinated via
|
|
2723
|
+
* an in-process Promise map: the second request awaits the first and
|
|
2724
|
+
* replays its result.
|
|
2725
|
+
*
|
|
2726
|
+
* Note: the cache key intentionally does NOT include the request body —
|
|
2727
|
+
* the spec assumes the client guarantees byte-for-byte identical retries,
|
|
2728
|
+
* and reading the body at middleware-entry time would defeat lazy parsing.
|
|
2729
|
+
*
|
|
2730
|
+
* @example
|
|
2731
|
+
* app.use(ingenium.idempotency({
|
|
2732
|
+
* store: new IdempotencyMemoryStore(),
|
|
2733
|
+
* ttlSeconds: 86_400,
|
|
2734
|
+
* }))
|
|
2735
|
+
*/
|
|
2736
|
+
declare function idempotencyMiddleware(opts?: IdempotencyOptions): IngeniumMiddleware;
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Supported JWT signing algorithms.
|
|
2740
|
+
*
|
|
2741
|
+
* - `HSxxx` — HMAC with the supplied shared secret.
|
|
2742
|
+
* - `RSxxx` — RSASSA-PKCS1-v1_5 with the supplied RSA public key (PEM / JWK / KeyObject).
|
|
2743
|
+
* - `PSxxx` — RSASSA-PSS (MGF1, salt length = digest length).
|
|
2744
|
+
* - `ESxxx` — ECDSA on P-256 / P-384 / P-521 (raw r||s, NOT DER — per the JWT spec).
|
|
2745
|
+
*
|
|
2746
|
+
* `alg: 'none'` is intentionally absent from this union and is hard-rejected
|
|
2747
|
+
* at the verifier — never accept unsigned tokens, even with an empty allowlist.
|
|
2748
|
+
*/
|
|
2749
|
+
type JwtAlgorithm = 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512' | 'ES256' | 'ES384' | 'ES512' | 'PS256' | 'PS384' | 'PS512';
|
|
2750
|
+
/** Decoded JWT header. The `alg` field is required by the spec. */
|
|
2751
|
+
interface JwtHeader {
|
|
2752
|
+
alg: string;
|
|
2753
|
+
typ?: string;
|
|
2754
|
+
kid?: string;
|
|
2755
|
+
[k: string]: unknown;
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* A successfully verified JWT. `payload` carries the typed claims object,
|
|
2759
|
+
* `header` carries the decoded protected header, and `raw` is the original
|
|
2760
|
+
* compact-serialization string the client sent (useful for re-emitting).
|
|
2761
|
+
*/
|
|
2762
|
+
interface JwtVerified<T = Record<string, unknown>> {
|
|
2763
|
+
header: JwtHeader;
|
|
2764
|
+
payload: T;
|
|
2765
|
+
raw: string;
|
|
2766
|
+
}
|
|
2767
|
+
/** Internal — verifier failure mode. Public surface only ever sees `'Invalid token'`. */
|
|
2768
|
+
type JwtVerifyError = {
|
|
2769
|
+
error: 'malformed';
|
|
2770
|
+
} | {
|
|
2771
|
+
error: 'unsupported_alg';
|
|
2772
|
+
} | {
|
|
2773
|
+
error: 'bad_signature';
|
|
2774
|
+
} | {
|
|
2775
|
+
error: 'expired';
|
|
2776
|
+
} | {
|
|
2777
|
+
error: 'not_yet_valid';
|
|
2778
|
+
} | {
|
|
2779
|
+
error: 'too_old';
|
|
2780
|
+
} | {
|
|
2781
|
+
error: 'aud_mismatch';
|
|
2782
|
+
} | {
|
|
2783
|
+
error: 'iss_mismatch';
|
|
2784
|
+
} | {
|
|
2785
|
+
error: 'kid_unknown';
|
|
2786
|
+
} | {
|
|
2787
|
+
error: 'jwks_fetch_failed';
|
|
2788
|
+
};
|
|
2789
|
+
/**
|
|
2790
|
+
* A single signing/verification key. For HMAC algorithms (HSxxx) this is the
|
|
2791
|
+
* shared secret as a string; for asymmetric algorithms it's the PUBLIC key in
|
|
2792
|
+
* PEM (string / Buffer) or as a pre-built `KeyObject`.
|
|
2793
|
+
*
|
|
2794
|
+
* Wrapping with `{ kid, key }` enables header-based key selection — the
|
|
2795
|
+
* verifier picks the entry whose `kid` matches `header.kid`.
|
|
2796
|
+
*/
|
|
2797
|
+
type JwtKey = string | Buffer$1 | KeyObject | {
|
|
2798
|
+
kid: string;
|
|
2799
|
+
key: string | Buffer$1 | KeyObject;
|
|
2800
|
+
};
|
|
2801
|
+
/**
|
|
2802
|
+
* Resolve a per-request key. Receives the decoded JWT header so callers can
|
|
2803
|
+
* implement `kid`-based JWKS-style routing without parsing the token themselves.
|
|
2804
|
+
*/
|
|
2805
|
+
type JwtSecretResolver<_T = Record<string, unknown>> = (header: JwtHeader) => JwtKey | Promise<JwtKey>;
|
|
2806
|
+
/** All ways `secret` can be supplied. */
|
|
2807
|
+
type JwtSecret<T = Record<string, unknown>> = JwtKey | JwtKey[] | JwtSecretResolver<T>;
|
|
2808
|
+
/** Signature for pulling the raw compact-serialization out of the request. */
|
|
2809
|
+
type JwtTokenReader = (ctx: IngeniumContext) => string | undefined | Promise<string | undefined>;
|
|
2810
|
+
/** Optional structured logger for redacted verification diagnostics. */
|
|
2811
|
+
type JwtLogger = (event: {
|
|
2812
|
+
reason: string;
|
|
2813
|
+
alg?: string;
|
|
2814
|
+
}) => void;
|
|
2815
|
+
interface JwtOptions<T = Record<string, unknown>> {
|
|
2816
|
+
/**
|
|
2817
|
+
* Verification key material. Accepts:
|
|
2818
|
+
* - A single key (string secret, PEM, Buffer, or `KeyObject`).
|
|
2819
|
+
* - `{ kid, key }` for explicit key-id tagging.
|
|
2820
|
+
* - An array of any of the above (rotation / multi-key — the verifier
|
|
2821
|
+
* picks by `kid` if present, else tries each in order).
|
|
2822
|
+
* - A function `(header) => key | Promise<key>` for fully custom routing.
|
|
2823
|
+
*/
|
|
2824
|
+
secret: JwtSecret<T>;
|
|
2825
|
+
/** Allowed signing algorithms. Default `['HS256']`. */
|
|
2826
|
+
algorithms?: readonly JwtAlgorithm[];
|
|
2827
|
+
/** Required `aud` claim. Token's `aud` must match (or include) one of these. */
|
|
2828
|
+
audience?: string | readonly string[];
|
|
2829
|
+
/** Required `iss` claim. */
|
|
2830
|
+
issuer?: string | readonly string[];
|
|
2831
|
+
/** Reject tokens whose `iat` is older than N seconds. */
|
|
2832
|
+
maxAgeSeconds?: number;
|
|
2833
|
+
/** Leeway for `nbf` / `exp` checks, in seconds. Default `5`. */
|
|
2834
|
+
clockSkewSeconds?: number;
|
|
2835
|
+
/**
|
|
2836
|
+
* If `true` (default), missing tokens raise `IngeniumUnauthorizedError`.
|
|
2837
|
+
* If `false`, missing tokens just call `next()` with no `ctx.jwt`.
|
|
2838
|
+
*/
|
|
2839
|
+
required?: boolean;
|
|
2840
|
+
/**
|
|
2841
|
+
* Custom token reader. Default reads `Authorization: Bearer <token>`.
|
|
2842
|
+
* Return `undefined` to indicate "no token in this request".
|
|
2843
|
+
*/
|
|
2844
|
+
getToken?: JwtTokenReader;
|
|
2845
|
+
/**
|
|
2846
|
+
* Optional sink for redacted verification failure reasons. Useful for
|
|
2847
|
+
* observability without leaking the failure type to the wire (which would
|
|
2848
|
+
* be an oracle for attackers).
|
|
2849
|
+
*/
|
|
2850
|
+
logger?: JwtLogger;
|
|
2851
|
+
/**
|
|
2852
|
+
* Optional JWKS endpoint URL. When set, the middleware fetches the keys
|
|
2853
|
+
* from this URL on demand and looks them up by `header.kid`. Cached for
|
|
2854
|
+
* `jwksCacheMs` (default 10 minutes) per URL with a single in-flight
|
|
2855
|
+
* request coalesced across concurrent callers.
|
|
2856
|
+
*/
|
|
2857
|
+
jwksUrl?: string;
|
|
2858
|
+
/** JWKS cache TTL in milliseconds. Default `600_000` (10 minutes). */
|
|
2859
|
+
jwksCacheMs?: number;
|
|
2860
|
+
/** Phantom — narrows `ctx.jwt.payload` for typed handlers. */
|
|
2861
|
+
_payload?: T;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
/**
|
|
2865
|
+
* Bearer-token JWT verification middleware.
|
|
2866
|
+
*
|
|
2867
|
+
* Attaches the verified token at `ctx.jwt`. Callers should module-augment
|
|
2868
|
+
* `IngeniumContext` for typed access (the framework purposely doesn't ship a
|
|
2869
|
+
* baked-in `jwt` field — payload shape is application-specific).
|
|
2870
|
+
*
|
|
2871
|
+
* @example
|
|
2872
|
+
* ```ts
|
|
2873
|
+
* declare module 'ingenium' {
|
|
2874
|
+
* interface IngeniumContext {
|
|
2875
|
+
* jwt?: import('ingenium').JwtVerified<{ sub: string; roles: string[] }>
|
|
2876
|
+
* }
|
|
2877
|
+
* }
|
|
2878
|
+
*
|
|
2879
|
+
* import { ingenium } from 'ingenium'
|
|
2880
|
+
* const app = ingenium()
|
|
2881
|
+
* // HMAC
|
|
2882
|
+
* app.use(ingenium.jwt({ secret: process.env.JWT_SECRET! }))
|
|
2883
|
+
* // RSA via JWKS (Auth0, Okta, Cognito, Clerk, Supabase, ...)
|
|
2884
|
+
* app.use(ingenium.jwt({
|
|
2885
|
+
* secret: [],
|
|
2886
|
+
* algorithms: ['RS256'],
|
|
2887
|
+
* jwksUrl: 'https://example.auth0.com/.well-known/jwks.json',
|
|
2888
|
+
* issuer: 'https://example.auth0.com/',
|
|
2889
|
+
* audience: 'https://api.example.com',
|
|
2890
|
+
* }))
|
|
2891
|
+
* ```
|
|
2892
|
+
*
|
|
2893
|
+
* Security choices:
|
|
2894
|
+
* - Default leeway (`clockSkewSeconds`) is **5 seconds** — enough for typical
|
|
2895
|
+
* multi-host clock drift, small enough that an expired token does not stay
|
|
2896
|
+
* usable for long.
|
|
2897
|
+
* - HMAC signature comparison uses `crypto.timingSafeEqual` after an explicit
|
|
2898
|
+
* length check inside {@link verifyJwt}; asymmetric verification uses
|
|
2899
|
+
* `crypto.verify` (constant-time within OpenSSL).
|
|
2900
|
+
* - The algorithm allowlist is enforced at verify time. Even if an attacker
|
|
2901
|
+
* crafts `alg: 'RS256'` and we have a matching JWKS key, verification fails
|
|
2902
|
+
* unless `RS256` appears in `algorithms`. This is the canonical defence
|
|
2903
|
+
* against algorithm-confusion attacks.
|
|
2904
|
+
* - `'none'` is rejected unconditionally, regardless of the allowlist.
|
|
2905
|
+
* - The wire-facing error is always `IngeniumUnauthorizedError('Invalid token')`
|
|
2906
|
+
* regardless of which check failed (signature vs exp vs aud) — this avoids
|
|
2907
|
+
* handing attackers an oracle. Detailed reasons go to `opts.logger` (or
|
|
2908
|
+
* `process.emitWarning` if no logger is supplied).
|
|
2909
|
+
*/
|
|
2910
|
+
declare function jwtMiddleware<T = Record<string, unknown>>(opts: JwtOptions<T>): IngeniumMiddleware;
|
|
2911
|
+
|
|
2912
|
+
/** Result of a custom API-key validator. */
|
|
2913
|
+
type ApiKeyValidator = (key: string, ctx: IngeniumContext) => boolean | Promise<boolean>;
|
|
2914
|
+
/** Optional logger for redacted failure reasons. */
|
|
2915
|
+
type ApiKeyLogger = (event: {
|
|
2916
|
+
reason: string;
|
|
2917
|
+
}) => void;
|
|
2918
|
+
interface ApiKeyOptions {
|
|
2919
|
+
/**
|
|
2920
|
+
* Either an allow-list of valid keys (compared with `timingSafeEqual`) or a
|
|
2921
|
+
* custom validator. Functions get the candidate key and the request ctx.
|
|
2922
|
+
*/
|
|
2923
|
+
keys: readonly string[] | ApiKeyValidator;
|
|
2924
|
+
/** Header to read the key from. Default `'x-api-key'`. */
|
|
2925
|
+
header?: string;
|
|
2926
|
+
/**
|
|
2927
|
+
* Optional fallback query-string parameter, e.g. `'api_key'`. When set,
|
|
2928
|
+
* the middleware checks `?api_key=...` if no header / scheme key matched.
|
|
2929
|
+
*/
|
|
2930
|
+
query?: string;
|
|
2931
|
+
/**
|
|
2932
|
+
* Optional `Authorization` scheme to accept, e.g. `'ApiKey'`. When set,
|
|
2933
|
+
* the middleware accepts `Authorization: ApiKey <key>`.
|
|
2934
|
+
*/
|
|
2935
|
+
scheme?: string;
|
|
2936
|
+
/**
|
|
2937
|
+
* If `true` (default), missing keys raise `IngeniumUnauthorizedError`. If
|
|
2938
|
+
* `false`, missing keys just call `next()` with no `ctx.apiKey`.
|
|
2939
|
+
*/
|
|
2940
|
+
required?: boolean;
|
|
2941
|
+
/** Optional sink for redacted failure reasons. Defaults to `process.emitWarning`. */
|
|
2942
|
+
logger?: ApiKeyLogger;
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/**
|
|
2946
|
+
* API-key authentication middleware.
|
|
2947
|
+
*
|
|
2948
|
+
* Attaches the validated key string at `ctx.apiKey`. Callers should
|
|
2949
|
+
* module-augment `IngeniumContext` for typed access:
|
|
2950
|
+
*
|
|
2951
|
+
* @example
|
|
2952
|
+
* ```ts
|
|
2953
|
+
* declare module 'ingenium' {
|
|
2954
|
+
* interface IngeniumContext { apiKey?: string }
|
|
2955
|
+
* }
|
|
2956
|
+
*
|
|
2957
|
+
* import { ingenium } from 'ingenium'
|
|
2958
|
+
* const app = ingenium()
|
|
2959
|
+
* app.use(ingenium.apiKey({
|
|
2960
|
+
* keys: process.env.API_KEYS!.split(','),
|
|
2961
|
+
* scheme: 'ApiKey',
|
|
2962
|
+
* query: 'api_key',
|
|
2963
|
+
* }))
|
|
2964
|
+
* ```
|
|
2965
|
+
*
|
|
2966
|
+
* Security choices:
|
|
2967
|
+
* - Allow-list comparisons go through `crypto.timingSafeEqual` after an
|
|
2968
|
+
* explicit length check, so neither equality nor length leaks via timing.
|
|
2969
|
+
* - The wire-facing error is always `IngeniumUnauthorizedError('Invalid API key')`
|
|
2970
|
+
* regardless of which lookup failed (header vs scheme vs query) — no
|
|
2971
|
+
* oracle for which transport surface the legit key uses.
|
|
2972
|
+
* - Custom validators get the candidate key + ctx; their boolean result is
|
|
2973
|
+
* trusted as-is. Validators should be constant-time when comparing keys.
|
|
2974
|
+
*/
|
|
2975
|
+
declare function apiKeyMiddleware(opts: ApiKeyOptions): IngeniumMiddleware;
|
|
2976
|
+
|
|
2977
|
+
/** Public options for `generateOpenApi(app, opts)`. */
|
|
2978
|
+
interface GenerateOpenApiOptions {
|
|
2979
|
+
info: Info;
|
|
2980
|
+
servers?: Server[];
|
|
2981
|
+
tags?: Tag[];
|
|
2982
|
+
security?: SecurityRequirement[];
|
|
2983
|
+
/**
|
|
2984
|
+
* Auto-tag generated operations by path prefix. The longest matching
|
|
2985
|
+
* prefix wins. Routes that already have `tags` in their descriptor are
|
|
2986
|
+
* left alone.
|
|
2987
|
+
*
|
|
2988
|
+
* @example { '/users': 'users', '/auth': 'auth' }
|
|
2989
|
+
*/
|
|
2990
|
+
tagsByPrefix?: Record<string, string>;
|
|
2991
|
+
/**
|
|
2992
|
+
* Hide routes whose path matches any entry. Strings match exactly,
|
|
2993
|
+
* RegExps are tested against the full path.
|
|
2994
|
+
*/
|
|
2995
|
+
excludePaths?: (string | RegExp)[];
|
|
2996
|
+
/** Pass-through `components.securitySchemes`. */
|
|
2997
|
+
securitySchemes?: Record<string, SecurityScheme>;
|
|
2998
|
+
/**
|
|
2999
|
+
* Optional additional schemas to merge into `components.schemas`. Useful
|
|
3000
|
+
* when you reference shared models via `$ref: '#/components/schemas/X'`.
|
|
3001
|
+
*/
|
|
3002
|
+
componentSchemas?: Record<string, Schema>;
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Generate an OpenAPI 3.1 spec from a composed (or uncomposed) IngeniumApp.
|
|
3006
|
+
* Walks the registration journal — does not require `compose()` to have run.
|
|
3007
|
+
*
|
|
3008
|
+
* Schema-conversion strategy (in priority order):
|
|
3009
|
+
* 1. If a request/response schema has a `toJsonSchema()` method (Zod 3.24+,
|
|
3010
|
+
* ArkType, Effect Schema, etc.), call it.
|
|
3011
|
+
* 2. If it looks like a Standard Schema (has `~standard`), emit `{}` plus
|
|
3012
|
+
* `x-schema-source: '<vendor>-untranslated'` as a TODO marker.
|
|
3013
|
+
* 3. Otherwise, pass the value through unchanged (assumed JSON Schema).
|
|
3014
|
+
*/
|
|
3015
|
+
declare function generateOpenApi(app: IngeniumApp, opts: GenerateOpenApiOptions): OpenApiSpec;
|
|
3016
|
+
|
|
3017
|
+
/**
|
|
3018
|
+
* Build a route handler that serves the generated OpenAPI spec as JSON.
|
|
3019
|
+
*
|
|
3020
|
+
* The spec is generated lazily on the first request that hits this handler
|
|
3021
|
+
* and cached on the app under a private symbol. The cache invalidates when
|
|
3022
|
+
* the registration journal length changes — i.e. when new routes are added —
|
|
3023
|
+
* so live-registered routes are reflected on the next request.
|
|
3024
|
+
*
|
|
3025
|
+
* @example
|
|
3026
|
+
* app.get('/openapi.json', ingenium.openapiHandler({
|
|
3027
|
+
* info: { title: 'My API', version: '1.0.0' },
|
|
3028
|
+
* }))
|
|
3029
|
+
*/
|
|
3030
|
+
declare function openapiHandler(opts: GenerateOpenApiOptions): IngeniumHandler;
|
|
3031
|
+
|
|
3032
|
+
/**
|
|
3033
|
+
* A bounded free-list of `IngeniumContext` objects. Acquire on each request,
|
|
3034
|
+
* release back when the response has been written. If the pool is empty,
|
|
3035
|
+
* a fresh context is allocated; if the pool is full on release, the
|
|
3036
|
+
* context is discarded (GC handles it). Never blocks.
|
|
3037
|
+
*/
|
|
3038
|
+
declare class IngeniumContextPool {
|
|
3039
|
+
private readonly pool;
|
|
3040
|
+
private readonly max;
|
|
3041
|
+
constructor(maxSize?: number);
|
|
3042
|
+
/** Acquire a context. Caller must call `release()` when done. */
|
|
3043
|
+
acquire(): IngeniumContext;
|
|
3044
|
+
/** Reset and return the context to the free list (or discard if full). */
|
|
3045
|
+
release(ctx: IngeniumContext): void;
|
|
3046
|
+
/** Current free-list size. Useful for tests and metrics. */
|
|
3047
|
+
get size(): number;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* Compose an array of middleware into a single async function. Composition
|
|
3052
|
+
* runs ONCE at registration / first-request time; the returned function has
|
|
3053
|
+
* no per-request `bind`, no index variable, and no `stack[n]` lookups.
|
|
3054
|
+
*
|
|
3055
|
+
* The dispatcher chain is pre-built bottom-up: `dispatchers[i]` runs
|
|
3056
|
+
* `stack[i]` with a `next` that invokes `dispatchers[i + 1]`. Each
|
|
3057
|
+
* middleware-level invocation still allocates one closure to capture `ctx`
|
|
3058
|
+
* (unavoidable without dropping concurrency safety).
|
|
3059
|
+
*
|
|
3060
|
+
* Double-`next()` calls are detected only when `process.env.REX_DEBUG` is
|
|
3061
|
+
* truthy, to keep the production hot path free of per-call guard variables.
|
|
3062
|
+
*/
|
|
3063
|
+
declare function compose(stack: readonly IngeniumMiddleware[]): ComposedHandler;
|
|
3064
|
+
/**
|
|
3065
|
+
* Compose middleware then append a terminal handler that does not receive a
|
|
3066
|
+
* `next` (so a route handler can be the leaf of the chain). The handler's
|
|
3067
|
+
* return value is reflected to the response per the contract in
|
|
3068
|
+
* `response/reflect.ts` — unless the handler called a `ctx.json/...` helper,
|
|
3069
|
+
* in which case the return value is ignored.
|
|
3070
|
+
*
|
|
3071
|
+
* Hot-path optimization: when there are no middleware, we skip the
|
|
3072
|
+
* dispatcher chain entirely and return a thin wrapper that calls the
|
|
3073
|
+
* handler directly. We also detect synchronous handler return values
|
|
3074
|
+
* (non-thenable) and avoid the `await` microtask in that case — measurable
|
|
3075
|
+
* on JSON-returning routes that don't touch the body.
|
|
3076
|
+
*/
|
|
3077
|
+
declare function composeWithHandler(middleware: readonly IngeniumMiddleware[], handler: (ctx: IngeniumContext) => unknown | Promise<unknown>): ComposedHandler;
|
|
3078
|
+
|
|
3079
|
+
/**
|
|
3080
|
+
* One node in the radix trie. Static segments win over `:param`, which wins
|
|
3081
|
+
* over `*wild`. Method-specific composed handlers live at the leaf.
|
|
3082
|
+
*/
|
|
3083
|
+
declare class TrieNode {
|
|
3084
|
+
staticChildren: Map<string, TrieNode>;
|
|
3085
|
+
paramChild: TrieNode | null;
|
|
3086
|
+
paramName: string | null;
|
|
3087
|
+
wildcardChild: TrieNode | null;
|
|
3088
|
+
wildcardName: string | null;
|
|
3089
|
+
/**
|
|
3090
|
+
* Compiled inline constraint for this node *as a param child*, or `null`
|
|
3091
|
+
* when the param is unconstrained. Set at insert time when the registered
|
|
3092
|
+
* segment carries a `(regex)` group (e.g. `:id(\d+)`). The `find()` hot
|
|
3093
|
+
* path loads this field and only runs `.test()` when it is non-null, so
|
|
3094
|
+
* unconstrained routes pay zero extra cost. Lives on the param node itself
|
|
3095
|
+
* (the child) so the matcher can test it the instant it descends.
|
|
3096
|
+
*/
|
|
3097
|
+
paramConstraint: RegExp | null;
|
|
3098
|
+
/** Per-method composed handlers, populated by `RouteRegistry` after compose. */
|
|
3099
|
+
handlers: Partial<Record<HttpMethod, ComposedHandler>>;
|
|
3100
|
+
/**
|
|
3101
|
+
* Param names accumulated from root → this node, in order. Cached so
|
|
3102
|
+
* matching can fill the params object in O(k) without re-walking parents.
|
|
3103
|
+
*/
|
|
3104
|
+
paramNames: readonly string[];
|
|
3105
|
+
}
|
|
3106
|
+
/** Result of a trie lookup. `params` may be empty if the route had none. */
|
|
3107
|
+
interface MatchResult {
|
|
3108
|
+
handler: ComposedHandler;
|
|
3109
|
+
params: Record<string, string>;
|
|
3110
|
+
/** Methods registered at this leaf — used to populate `Allow` on 405. */
|
|
3111
|
+
allowed: readonly HttpMethod[];
|
|
3112
|
+
}
|
|
3113
|
+
/** Why a lookup failed. */
|
|
3114
|
+
type MatchMiss = {
|
|
3115
|
+
kind: 'not-found';
|
|
3116
|
+
} | {
|
|
3117
|
+
kind: 'method-not-allowed';
|
|
3118
|
+
allowed: readonly HttpMethod[];
|
|
3119
|
+
};
|
|
3120
|
+
/**
|
|
3121
|
+
* Radix trie router. `insert()` is called at registration; `find()` runs on
|
|
3122
|
+
* every request and is the single hottest piece of code in the framework.
|
|
3123
|
+
*/
|
|
3124
|
+
declare class RouterTrie {
|
|
3125
|
+
readonly root: TrieNode;
|
|
3126
|
+
/**
|
|
3127
|
+
* Walks/creates trie nodes for the path. Returns the leaf where handlers
|
|
3128
|
+
* should be attached. Path must start with `/`.
|
|
3129
|
+
*/
|
|
3130
|
+
insert(path: string): TrieNode;
|
|
3131
|
+
/**
|
|
3132
|
+
* Look up a route. Iterative with single-level wildcard backtrack — if the
|
|
3133
|
+
* static/param walk dead-ends and an ancestor had a `*wildcard` child, we
|
|
3134
|
+
* retry from the wildcard with the remaining segments. Backtrack frames
|
|
3135
|
+
* are tracked in a small stack (one per wildcard ancestor encountered).
|
|
3136
|
+
*/
|
|
3137
|
+
find(method: HttpMethod, path: string): MatchResult | MatchMiss;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
/**
|
|
3141
|
+
* `safeJsonStringify(value, opts?)` — a lenient `JSON.stringify` that never
|
|
3142
|
+
* throws on circular references or `BigInt` values.
|
|
3143
|
+
*
|
|
3144
|
+
* Behavior:
|
|
3145
|
+
* - Circular references → replaced with the string `'[Circular]'`.
|
|
3146
|
+
* - `BigInt` values → serialized as a JSON string (e.g. `1n` → `"1"`).
|
|
3147
|
+
* This preserves precision and is reversible by the caller; if you need a
|
|
3148
|
+
* different convention, pass your own `replacer`.
|
|
3149
|
+
* - Symbol values → omitted (matches `JSON.stringify` default).
|
|
3150
|
+
* - Functions → omitted (matches `JSON.stringify` default).
|
|
3151
|
+
*
|
|
3152
|
+
* Intended for opt-in use by callers who want lenient behavior — the
|
|
3153
|
+
* default `ctx.json()` path remains strict and surfaces a
|
|
3154
|
+
* `IngeniumUnserializableError` so the bug is visible.
|
|
3155
|
+
*
|
|
3156
|
+
* @example
|
|
3157
|
+
* import { safeJsonStringify } from 'ingenium'
|
|
3158
|
+
* ctx.send(safeJsonStringify(value), 200)
|
|
3159
|
+
* ctx.set('content-type', 'application/json; charset=utf-8')
|
|
3160
|
+
*/
|
|
3161
|
+
/** Options for `safeJsonStringify`. */
|
|
3162
|
+
interface SafeJsonStringifyOptions {
|
|
3163
|
+
/**
|
|
3164
|
+
* Pass-through to `JSON.stringify`'s third argument — number of spaces or
|
|
3165
|
+
* indent string for pretty-printing. Defaults to no indentation.
|
|
3166
|
+
*/
|
|
3167
|
+
space?: string | number;
|
|
3168
|
+
/**
|
|
3169
|
+
* Optional user replacer applied AFTER the cycle/BigInt sanitization. If
|
|
3170
|
+
* provided, behaves like `JSON.stringify`'s second argument.
|
|
3171
|
+
*/
|
|
3172
|
+
replacer?: (key: string, value: unknown) => unknown;
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Stringify `value` without throwing on circular structures or `BigInt`s.
|
|
3176
|
+
* See module doc for the exact substitution rules.
|
|
3177
|
+
*/
|
|
3178
|
+
declare function safeJsonStringify(value: unknown, opts?: SafeJsonStringifyOptions): string;
|
|
3179
|
+
|
|
3180
|
+
/**
|
|
3181
|
+
* Node.js `node:http` transport. Owns a single `http.Server`; on each
|
|
3182
|
+
* request, populates a pooled `IngeniumContext` directly from the
|
|
3183
|
+
* `IncomingMessage` (no WinterCG translation), awaits dispatch, then writes
|
|
3184
|
+
* the context's response state to the `ServerResponse`.
|
|
3185
|
+
*/
|
|
3186
|
+
declare class NodeAdapter implements Transport {
|
|
3187
|
+
private hooks;
|
|
3188
|
+
attach(hooks: TransportHooks): void;
|
|
3189
|
+
listen(port: number, host?: string): Promise<ListeningServer>;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
/** TLS options accepted by the h2 (secure) adapter. */
|
|
3193
|
+
interface Http2AdapterOptions {
|
|
3194
|
+
/** TLS certificate (PEM). */
|
|
3195
|
+
cert: Buffer | string;
|
|
3196
|
+
/** TLS private key (PEM). */
|
|
3197
|
+
key: Buffer | string;
|
|
3198
|
+
/**
|
|
3199
|
+
* If true, the secure server also accepts HTTP/1.1 connections via ALPN
|
|
3200
|
+
* fallback. Inbound HTTP/1 requests are dispatched through the same path
|
|
3201
|
+
* used by `NodeAdapter`. Default: false (HTTP/2 only).
|
|
3202
|
+
*/
|
|
3203
|
+
allowHttp1?: boolean;
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* HTTP/2-over-TLS (`h2`) transport. Uses Node's built-in `http2.createSecureServer`.
|
|
3207
|
+
* Browsers REQUIRE TLS for HTTP/2 — there is no cleartext HTTP/2 negotiation
|
|
3208
|
+
* over the open web. For local testing without certs, use {@link Http2cAdapter}.
|
|
3209
|
+
*
|
|
3210
|
+
* Per-request: on `'stream'`, populates a pooled `IngeniumContext` from pseudo-headers,
|
|
3211
|
+
* awaits dispatch, then writes the response via `stream.respond()` + `stream.end()`
|
|
3212
|
+
* (or pipes for `Readable` bodies).
|
|
3213
|
+
*/
|
|
3214
|
+
declare class Http2Adapter implements Transport {
|
|
3215
|
+
private readonly options;
|
|
3216
|
+
private hooks;
|
|
3217
|
+
constructor(options: Http2AdapterOptions);
|
|
3218
|
+
attach(hooks: TransportHooks): void;
|
|
3219
|
+
listen(port: number, host?: string): Promise<ListeningServer>;
|
|
3220
|
+
}
|
|
3221
|
+
/**
|
|
3222
|
+
* HTTP/2 cleartext (`h2c`) transport. Uses Node's `http2.createServer` — no TLS,
|
|
3223
|
+
* so this is intended for local development, internal service-to-service calls
|
|
3224
|
+
* behind an L7 proxy that handles TLS termination, or test suites. Browsers do
|
|
3225
|
+
* not speak h2c; use {@link Http2Adapter} for browser traffic.
|
|
3226
|
+
*
|
|
3227
|
+
* Constructor takes no required arguments.
|
|
3228
|
+
*/
|
|
3229
|
+
declare class Http2cAdapter implements Transport {
|
|
3230
|
+
private hooks;
|
|
3231
|
+
constructor(_options?: {});
|
|
3232
|
+
attach(hooks: TransportHooks): void;
|
|
3233
|
+
listen(port: number, host?: string): Promise<ListeningServer>;
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
/**
|
|
3237
|
+
* Graceful shutdown helper. Wires POSIX signal handlers to drain a
|
|
3238
|
+
* {@link ListeningServer}, run a user cleanup hook, then exit.
|
|
3239
|
+
*
|
|
3240
|
+
* Most production deployments (Kubernetes, systemd, PM2, ECS, Fly, …) send
|
|
3241
|
+
* SIGTERM when they want a process to stop. By default Node simply dies on
|
|
3242
|
+
* SIGTERM, which kills in-flight requests and leaves keep-alive sockets
|
|
3243
|
+
* dangling. Calling {@link gracefulShutdown} after `app.listen()` opts the
|
|
3244
|
+
* process into a clean drain instead.
|
|
3245
|
+
*/
|
|
3246
|
+
|
|
3247
|
+
/** Options for {@link gracefulShutdown}. */
|
|
3248
|
+
interface ShutdownOptions {
|
|
3249
|
+
/**
|
|
3250
|
+
* Maximum time (ms) to wait for sockets to drain before they are forcibly
|
|
3251
|
+
* destroyed. Defaults to `10_000` (10s) — matches Kubernetes' default
|
|
3252
|
+
* `terminationGracePeriodSeconds` headroom.
|
|
3253
|
+
*/
|
|
3254
|
+
gracefulTimeoutMs?: number;
|
|
3255
|
+
/**
|
|
3256
|
+
* Signals to listen for. Defaults to `['SIGTERM', 'SIGINT']`.
|
|
3257
|
+
*/
|
|
3258
|
+
signals?: NodeJS.Signals[];
|
|
3259
|
+
/**
|
|
3260
|
+
* User cleanup hook — runs AFTER the server stops accepting new
|
|
3261
|
+
* connections but BEFORE the process exits. Use for closing DB pools,
|
|
3262
|
+
* flushing logs, etc. Awaited; throwing exits with code 1.
|
|
3263
|
+
*/
|
|
3264
|
+
onShutdown?: () => void | Promise<void>;
|
|
3265
|
+
/** Logger used to announce shutdown lifecycle events. Defaults to `console.log`. */
|
|
3266
|
+
logger?: (msg: string) => void;
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Wire signal handlers that gracefully shut down `server` on SIGTERM/SIGINT
|
|
3270
|
+
* (or whichever signals you pass). Returns an unsubscribe function that
|
|
3271
|
+
* removes the listeners — mostly useful for tests.
|
|
3272
|
+
*
|
|
3273
|
+
* @example
|
|
3274
|
+
* const server = await app.listen(3000)
|
|
3275
|
+
* gracefulShutdown(server, { onShutdown: async () => db.close() })
|
|
3276
|
+
*/
|
|
3277
|
+
declare function gracefulShutdown(server: ListeningServer, opts?: ShutdownOptions): () => void;
|
|
3278
|
+
|
|
3279
|
+
/**
|
|
3280
|
+
* Send a `:keepalive` comment to the given SSE stream every `intervalMs`
|
|
3281
|
+
* milliseconds. Returns a cancellation function.
|
|
3282
|
+
*
|
|
3283
|
+
* The interval is automatically cancelled when the stream closes — but
|
|
3284
|
+
* callers should still hold onto the cancel function for explicit cleanup
|
|
3285
|
+
* (e.g. on a separate teardown signal).
|
|
3286
|
+
*
|
|
3287
|
+
* The internal timer is `unref()`'d so it won't keep the Node event loop
|
|
3288
|
+
* alive on its own.
|
|
3289
|
+
*
|
|
3290
|
+
* @example
|
|
3291
|
+
* const stream = sse(ctx)
|
|
3292
|
+
* const cancel = startKeepAlive(stream, 15_000)
|
|
3293
|
+
* ctx.req.on('close', cancel) // optional
|
|
3294
|
+
*/
|
|
3295
|
+
declare function startKeepAlive(stream: SseStream, intervalMs?: number): () => void;
|
|
3296
|
+
|
|
3297
|
+
interface MemoryStoreOptions {
|
|
3298
|
+
/**
|
|
3299
|
+
* Hard ceiling on the number of distinct keys retained. When exceeded, the
|
|
3300
|
+
* **least-recently-touched** entry is evicted to make room. Default
|
|
3301
|
+
* `100_000`.
|
|
3302
|
+
*
|
|
3303
|
+
* The cap exists to bound memory under adversarial conditions: an attacker
|
|
3304
|
+
* generating one request per unique IP would otherwise grow the map without
|
|
3305
|
+
* bound. With the cap, the worst case is a fixed memory footprint and
|
|
3306
|
+
* attackers' counters get evicted (which means they bypass rate-limiting
|
|
3307
|
+
* for the exact endpoint they're hammering — a real trade-off, but better
|
|
3308
|
+
* than OOM).
|
|
3309
|
+
*
|
|
3310
|
+
* For genuinely high-cardinality production workloads (millions of distinct
|
|
3311
|
+
* users), prefer a Redis-backed store so eviction isn't required.
|
|
3312
|
+
*/
|
|
3313
|
+
maxEntries?: number;
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3316
|
+
* In-process fixed-window counter store. Suitable for single-replica
|
|
3317
|
+
* deployments and tests; swap for a Redis-backed store when running
|
|
3318
|
+
* multiple replicas behind a load balancer.
|
|
3319
|
+
*
|
|
3320
|
+
* A periodic sweep removes expired entries every `windowMs` so long-lived
|
|
3321
|
+
* processes don't leak memory across forgotten keys. The sweep timer is
|
|
3322
|
+
* `.unref()`'d, so it never keeps the Node event loop alive.
|
|
3323
|
+
*
|
|
3324
|
+
* The `Map` itself is bounded by `maxEntries` (default 100k). When the cap
|
|
3325
|
+
* is reached, the least-recently-touched entry is evicted before the new
|
|
3326
|
+
* entry is inserted. We rely on the JS `Map` insertion-order guarantee:
|
|
3327
|
+
* delete-then-set on an existing key moves it to the end, so the first
|
|
3328
|
+
* iteration step always returns the genuine LRU. **This is intentional
|
|
3329
|
+
* defense against scanner attacks that would otherwise OOM the process by
|
|
3330
|
+
* generating unique keys.**
|
|
3331
|
+
*/
|
|
3332
|
+
declare class MemoryStore$1 implements RateLimitStore {
|
|
3333
|
+
private readonly map;
|
|
3334
|
+
private sweeper;
|
|
3335
|
+
private sweepIntervalMs;
|
|
3336
|
+
private readonly maxEntries;
|
|
3337
|
+
constructor(opts?: MemoryStoreOptions);
|
|
3338
|
+
hit(key: string, windowMs: number): Promise<{
|
|
3339
|
+
count: number;
|
|
3340
|
+
resetAt: number;
|
|
3341
|
+
}>;
|
|
3342
|
+
reset(key: string): Promise<void>;
|
|
3343
|
+
/**
|
|
3344
|
+
* Stop the cleanup interval. Safe to call multiple times. Mostly useful
|
|
3345
|
+
* in tests; production usage doesn't need this because the timer is
|
|
3346
|
+
* already unref'd.
|
|
3347
|
+
*/
|
|
3348
|
+
destroy(): void;
|
|
3349
|
+
/** @internal Current entry count — exposed for ops/tests. */
|
|
3350
|
+
get size(): number;
|
|
3351
|
+
private ensureSweeper;
|
|
3352
|
+
private sweep;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
/**
|
|
3356
|
+
* Pure parsers and matchers for HTTP `Accept`-family headers.
|
|
3357
|
+
*
|
|
3358
|
+
* Implemented from scratch — no `negotiator` / `accepts` runtime dep.
|
|
3359
|
+
* Used by `negotiate.ts`, `format.ts`, and downstream context helpers.
|
|
3360
|
+
*
|
|
3361
|
+
* Spec references:
|
|
3362
|
+
* - RFC 9110 §12.5.1 (Accept), §12.5.2 (Accept-Charset),
|
|
3363
|
+
* §12.5.4 (Accept-Encoding), §12.5.5 (Accept-Language).
|
|
3364
|
+
*/
|
|
3365
|
+
/** A single parsed media-range entry from an `Accept` header. */
|
|
3366
|
+
interface ParsedAccept {
|
|
3367
|
+
/** The full media-range string (lowercased), e.g. `text/html`, `text/\*`, `\*\/\*`. */
|
|
3368
|
+
type: string;
|
|
3369
|
+
/** Quality factor from `;q=N`, default `1`. Out-of-range values are clamped. */
|
|
3370
|
+
quality: number;
|
|
3371
|
+
/** Any other extension parameters (e.g. `level=1`). */
|
|
3372
|
+
params: Record<string, string>;
|
|
3373
|
+
}
|
|
3374
|
+
/** Resolve a shorthand (`'json'`) to its canonical mime, or pass through. */
|
|
3375
|
+
declare function expandShorthand(token: string): string;
|
|
3376
|
+
/**
|
|
3377
|
+
* Parse a comma-separated `Accept`-family header into a list of entries.
|
|
3378
|
+
* Empty / undefined input returns an empty array. Malformed entries are
|
|
3379
|
+
* silently dropped (lenient parsing — same as Express).
|
|
3380
|
+
*
|
|
3381
|
+
* Result is **not** sorted; pass to `sortByPreference` if you need ordering.
|
|
3382
|
+
*/
|
|
3383
|
+
declare function parseAcceptHeader(header: string | undefined): ParsedAccept[];
|
|
3384
|
+
/**
|
|
3385
|
+
* Stable sort by RFC preference: highest q first, then most-specific first.
|
|
3386
|
+
* Returns a NEW array; does not mutate input.
|
|
3387
|
+
*/
|
|
3388
|
+
declare function sortByPreference(entries: readonly ParsedAccept[]): ParsedAccept[];
|
|
3389
|
+
/**
|
|
3390
|
+
* Return the best match for `offered` against `acceptHeader`, or `false`.
|
|
3391
|
+
*
|
|
3392
|
+
* Matching algorithm:
|
|
3393
|
+
* 1. If `acceptHeader` is missing/empty → first offered wins (Express behavior).
|
|
3394
|
+
* 2. Walk parsed entries sorted by quality + specificity.
|
|
3395
|
+
* 3. For each entry (in preference order), pick the first offered that matches.
|
|
3396
|
+
* Among ties at the same Accept entry, the offered's listed order wins.
|
|
3397
|
+
* 4. Entries with `q=0` reject — never match.
|
|
3398
|
+
*/
|
|
3399
|
+
declare function selectBest(acceptHeader: string | undefined, offered: readonly string[]): string | false;
|
|
3400
|
+
|
|
3401
|
+
/**
|
|
3402
|
+
* `isFresh(reqHeaders, resHeaders)` — RFC 7232 conditional-request evaluator.
|
|
3403
|
+
*
|
|
3404
|
+
* Returns `true` when the response can be considered fresh relative to the
|
|
3405
|
+
* client's cached copy, i.e. a `304 Not Modified` is appropriate. This is
|
|
3406
|
+
* the engine behind `ctx.fresh` / `ctx.stale`.
|
|
3407
|
+
*
|
|
3408
|
+
* Decision matrix:
|
|
3409
|
+
* - `If-None-Match` present → compare against response `ETag`. Wildcard
|
|
3410
|
+
* `*` matches any current representation. Strong/weak prefixes are
|
|
3411
|
+
* normalized away (per RFC 7232 §2.3.2 weak-comparison rules).
|
|
3412
|
+
* - Else if `If-Modified-Since` present → compare against response
|
|
3413
|
+
* `Last-Modified` (or fall back to `Date`). Fresh when the resource has
|
|
3414
|
+
* not been modified since.
|
|
3415
|
+
* - Otherwise → not fresh (no precondition to evaluate).
|
|
3416
|
+
*
|
|
3417
|
+
* Methods other than GET/HEAD are not handled here — callers should gate
|
|
3418
|
+
* on method themselves (Express does the same in `req.fresh`).
|
|
3419
|
+
*/
|
|
3420
|
+
/** Header bag shape — accepts both incoming-request and stored-response styles. */
|
|
3421
|
+
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
3422
|
+
/**
|
|
3423
|
+
* Returns `true` when the response is fresh w.r.t. the client's preconditions.
|
|
3424
|
+
*/
|
|
3425
|
+
declare function isFresh(reqHeaders: HeaderBag, resHeaders: HeaderBag): boolean;
|
|
3426
|
+
|
|
3427
|
+
/**
|
|
3428
|
+
* `computeEtag(body, weak?)` — sha1-based entity tag for response bodies.
|
|
3429
|
+
*
|
|
3430
|
+
* Format: `W/"<sha1-base64-without-padding>"` (weak) or `"<sha1-base64-without-padding>"`.
|
|
3431
|
+
* Weak is the default — fine for JSON where serialization may legitimately vary
|
|
3432
|
+
* (key order, whitespace) without representing a different resource.
|
|
3433
|
+
*
|
|
3434
|
+
* The empty-body ETag is special-cased to a fixed constant so two empty
|
|
3435
|
+
* bodies always compare equal without pumping through the hash.
|
|
3436
|
+
*/
|
|
3437
|
+
|
|
3438
|
+
/**
|
|
3439
|
+
* Compute an ETag for the given body. Strings are treated as UTF-8.
|
|
3440
|
+
* @param body Response body — usually `JSON.stringify(...)` or a `Buffer`.
|
|
3441
|
+
* @param weak Prefix the tag with `W/`. Defaults to `true` for JSON safety.
|
|
3442
|
+
*/
|
|
3443
|
+
declare function computeEtag(body: string | Buffer$1, weak?: boolean): string;
|
|
3444
|
+
|
|
3445
|
+
/**
|
|
3446
|
+
* Convert a thrown value into an RFC 7807 ProblemDetails object. Handles
|
|
3447
|
+
* `IngeniumError` and its subclasses with rich extensions; unknown errors are
|
|
3448
|
+
* reported as a generic 500 with `type: 'about:blank'`.
|
|
3449
|
+
*
|
|
3450
|
+
* Side effect: for `IngeniumMethodNotAllowedError`, the `Allow` header is set
|
|
3451
|
+
* on the response so it matches the framework's default boundary behavior.
|
|
3452
|
+
*/
|
|
3453
|
+
declare function toProblemDetails(err: unknown, opts: ResolvedProblemDetailsOptions, ctx: IngeniumContext): ProblemDetails;
|
|
3454
|
+
|
|
3455
|
+
/**
|
|
3456
|
+
* In-process idempotency cache. Suitable for single-replica deployments and
|
|
3457
|
+
* tests; back with Redis when running multiple replicas behind a load
|
|
3458
|
+
* balancer (responses cached on one replica won't replay on another).
|
|
3459
|
+
*
|
|
3460
|
+
* A periodic sweep removes expired entries so long-lived processes don't
|
|
3461
|
+
* leak memory across forgotten keys. The sweep timer is `.unref()`'d, so
|
|
3462
|
+
* it never keeps the Node event loop alive.
|
|
3463
|
+
*/
|
|
3464
|
+
declare class IdempotencyMemoryStore implements IdempotencyStore {
|
|
3465
|
+
private readonly map;
|
|
3466
|
+
private sweeper;
|
|
3467
|
+
private sweepIntervalMs;
|
|
3468
|
+
get(key: string): Promise<CachedResponse | null>;
|
|
3469
|
+
set(key: string, value: CachedResponse, ttlMs: number): Promise<void>;
|
|
3470
|
+
delete(key: string): Promise<void>;
|
|
3471
|
+
/**
|
|
3472
|
+
* Stop the cleanup interval. Safe to call multiple times. Mostly useful
|
|
3473
|
+
* in tests; production usage doesn't need this because the timer is
|
|
3474
|
+
* already unref'd.
|
|
3475
|
+
*/
|
|
3476
|
+
destroy(): void;
|
|
3477
|
+
private ensureSweeper;
|
|
3478
|
+
private sweep;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
/**
|
|
3482
|
+
* A verification key as supplied by the caller (post-resolution).
|
|
3483
|
+
* - `string` / `Buffer` for HMAC secrets and PEM blobs.
|
|
3484
|
+
* - `KeyObject` for pre-built node:crypto keys (and JWKS-derived keys).
|
|
3485
|
+
*/
|
|
3486
|
+
type VerifyKeyMaterial = string | Buffer$1 | KeyObject;
|
|
3487
|
+
/** Optional kid-tagged variant — what middleware passes after kid resolution. */
|
|
3488
|
+
interface KidTaggedKey {
|
|
3489
|
+
kid?: string;
|
|
3490
|
+
key: VerifyKeyMaterial;
|
|
3491
|
+
}
|
|
3492
|
+
/** Options accepted by {@link verifyJwt}. Mirrors the relevant subset of `JwtOptions`. */
|
|
3493
|
+
interface VerifyOptions {
|
|
3494
|
+
algorithms: readonly JwtAlgorithm[];
|
|
3495
|
+
audience?: string | readonly string[];
|
|
3496
|
+
issuer?: string | readonly string[];
|
|
3497
|
+
maxAgeSeconds?: number;
|
|
3498
|
+
clockSkewSeconds?: number;
|
|
3499
|
+
/** Override "now" for deterministic tests. Returns seconds since epoch. */
|
|
3500
|
+
nowSeconds?: () => number;
|
|
3501
|
+
}
|
|
3502
|
+
/**
|
|
3503
|
+
* Pure JWT verifier. No I/O, no logging — returns either a `JwtVerified` or
|
|
3504
|
+
* a tagged failure object. The middleware layer is responsible for collapsing
|
|
3505
|
+
* every failure into the same `IngeniumUnauthorizedError('Invalid token')` so
|
|
3506
|
+
* the wire never reveals which check tripped.
|
|
3507
|
+
*
|
|
3508
|
+
* `keys` is a flat array because key-resolution (rotation, kid-lookup, JWKS)
|
|
3509
|
+
* is the caller's responsibility; this function just tries them in order.
|
|
3510
|
+
* Each entry may carry an optional `kid` — when present AND `header.kid` is
|
|
3511
|
+
* set, only matching entries are considered. Without a `header.kid`, every
|
|
3512
|
+
* entry is tried.
|
|
3513
|
+
*
|
|
3514
|
+
* `alg: 'none'` is rejected unconditionally, even if for some reason the
|
|
3515
|
+
* allowlist were extended to include it. Defence in depth.
|
|
3516
|
+
*/
|
|
3517
|
+
declare function verifyJwt<T = Record<string, unknown>>(token: string, keys: readonly (VerifyKeyMaterial | KidTaggedKey)[], opts: VerifyOptions): JwtVerified<T> | JwtVerifyError;
|
|
3518
|
+
|
|
3519
|
+
/**
|
|
3520
|
+
* Fetch + cache a JWKS. Returns a `Map<kid, KeyObject>`.
|
|
3521
|
+
*
|
|
3522
|
+
* Concurrency: if a fetch is already in flight for `url` we await the same
|
|
3523
|
+
* promise, ensuring a thundering-herd of requests collapses to one upstream
|
|
3524
|
+
* call. After the fetch resolves, all waiters get the same keys.
|
|
3525
|
+
*
|
|
3526
|
+
* Failure mode: any thrown error (network, JSON parse, malformed JWK, empty
|
|
3527
|
+
* keyset) bubbles as a generic `Error('jwks_fetch_failed')` — the caller is
|
|
3528
|
+
* responsible for translating to a wire-safe `IngeniumUnauthorizedError`. We
|
|
3529
|
+
* deliberately do NOT serve a stale cache on failure: stale public keys can
|
|
3530
|
+
* mean accepting tokens that the IdP has rotated away from.
|
|
3531
|
+
*/
|
|
3532
|
+
declare function fetchJwks(url: string, ttlMs: number): Promise<Map<string, KeyObject>>;
|
|
3533
|
+
/** Reset the in-process cache. Tests use this; production code shouldn't need it. */
|
|
3534
|
+
declare function clearJwksCache(): void;
|
|
3535
|
+
|
|
3536
|
+
/**
|
|
3537
|
+
* In-process FIFO queue store. Backs {@link IngeniumQueue} when no custom
|
|
3538
|
+
* store is supplied. Suitable for single-instance deployments and tests.
|
|
3539
|
+
*
|
|
3540
|
+
* Layout:
|
|
3541
|
+
* - `pending`: ordered list of jobs ready to be picked up. Delayed jobs
|
|
3542
|
+
* (post-retry backoff) sit here too — `next()` skips entries whose
|
|
3543
|
+
* `notBefore` hasn't elapsed yet, so callers should poll on a timer.
|
|
3544
|
+
* - `inFlight`: jobs that have been `next()`-ed but not yet `ack`/`retry`/`fail`-ed.
|
|
3545
|
+
* - `failed`: dead-letter list. Persists until `clearFailed()` is called.
|
|
3546
|
+
*
|
|
3547
|
+
* No background timers — purely event-driven via the queue worker pool.
|
|
3548
|
+
*/
|
|
3549
|
+
declare class MemoryQueueStore<TData> implements QueueStore<TData> {
|
|
3550
|
+
private readonly pending;
|
|
3551
|
+
private readonly inFlight;
|
|
3552
|
+
private readonly failed;
|
|
3553
|
+
private nextId;
|
|
3554
|
+
enqueue(data: TData): Promise<{
|
|
3555
|
+
id: string;
|
|
3556
|
+
}>;
|
|
3557
|
+
next(): Promise<{
|
|
3558
|
+
id: string;
|
|
3559
|
+
data: TData;
|
|
3560
|
+
attempt: number;
|
|
3561
|
+
} | null>;
|
|
3562
|
+
ack(id: string): Promise<void>;
|
|
3563
|
+
retry(id: string, delayMs: number): Promise<void>;
|
|
3564
|
+
fail(id: string): Promise<void>;
|
|
3565
|
+
size(): Promise<number>;
|
|
3566
|
+
failedCount(): Promise<number>;
|
|
3567
|
+
/** @internal Used by `IngeniumQueue.clearFailed()`. */
|
|
3568
|
+
clearFailed(): void;
|
|
3569
|
+
/** @internal Used by `IngeniumQueue.drain()` to know if work is outstanding. */
|
|
3570
|
+
inFlightCount(): number;
|
|
3571
|
+
/** @internal Earliest `notBefore` of any pending entry, or `null` if none. */
|
|
3572
|
+
earliestPendingAt(): number | null;
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
/**
|
|
3576
|
+
* 5-field crontab parser + "next fire time" calculator.
|
|
3577
|
+
*
|
|
3578
|
+
* Grammar (per field):
|
|
3579
|
+
* - `*` every value
|
|
3580
|
+
* - `N` literal
|
|
3581
|
+
* - `N-M` inclusive range
|
|
3582
|
+
* - `* / S` or `N-M / S` step
|
|
3583
|
+
* - `A,B,C` list (each entry can be any of the above)
|
|
3584
|
+
*
|
|
3585
|
+
* Supported field ranges:
|
|
3586
|
+
* minute 0-59
|
|
3587
|
+
* hour 0-23
|
|
3588
|
+
* dom 1-31
|
|
3589
|
+
* month 1-12, or 3-letter names jan|feb|...|dec
|
|
3590
|
+
* dow 0-6 (sunday=0), or 3-letter names sun|mon|...|sat
|
|
3591
|
+
*
|
|
3592
|
+
* Explicitly NOT supported (would need a different parser):
|
|
3593
|
+
* - 6-field syntax with seconds
|
|
3594
|
+
* - L (last day-of-month), W (weekday), # (nth-of-month)
|
|
3595
|
+
* - Predefined macros (@hourly, @daily, ...)
|
|
3596
|
+
*
|
|
3597
|
+
* Day-of-month vs day-of-week conflict resolution: when BOTH `dom` and
|
|
3598
|
+
* `dow` are restricted (i.e. neither is `*`), the cron fires when EITHER
|
|
3599
|
+
* matches (this is the historical Vixie-cron behavior). When only one is
|
|
3600
|
+
* restricted, only that one matters. This is what every other production
|
|
3601
|
+
* cron implementation does and what users expect.
|
|
3602
|
+
*/
|
|
3603
|
+
/** Parsed match-set. `Set<number>` of all valid integers per field. */
|
|
3604
|
+
interface CronMatch {
|
|
3605
|
+
minute: Set<number>;
|
|
3606
|
+
hour: Set<number>;
|
|
3607
|
+
dom: Set<number>;
|
|
3608
|
+
month: Set<number>;
|
|
3609
|
+
dow: Set<number>;
|
|
3610
|
+
/** Was the original `dom` field `*`? Used for dom/dow conflict resolution. */
|
|
3611
|
+
domIsWild: boolean;
|
|
3612
|
+
/** Was the original `dow` field `*`? */
|
|
3613
|
+
dowIsWild: boolean;
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* Parse a 5-field crontab spec into a {@link CronMatch}. Throws on any
|
|
3617
|
+
* malformed input — out-of-range, wrong field count, garbage characters.
|
|
3618
|
+
*/
|
|
3619
|
+
declare function parseCronSpec(spec: string): CronMatch;
|
|
3620
|
+
/**
|
|
3621
|
+
* Given a parsed {@link CronMatch}, find the next moment >= `from` that
|
|
3622
|
+
* matches the spec, in the given IANA timezone. Returns `null` if none
|
|
3623
|
+
* within ~5 years (defensive against pathological specs).
|
|
3624
|
+
*
|
|
3625
|
+
* Algorithm: walk forward minute-by-minute with smart skipping. We start
|
|
3626
|
+
* one minute past `from` (cron fires at the START of each minute and we
|
|
3627
|
+
* never want to re-fire the same slot back-to-back).
|
|
3628
|
+
*
|
|
3629
|
+
* Timezone handling:
|
|
3630
|
+
* - For `'UTC'` we use direct UTC accessors — fast path.
|
|
3631
|
+
* - For other zones we call `Intl.DateTimeFormat` to get the wall-clock
|
|
3632
|
+
* fields in that zone for each candidate. This relies on Node's bundled
|
|
3633
|
+
* ICU data; full-icu Node ships with a complete tz database.
|
|
3634
|
+
*
|
|
3635
|
+
* DST: by walking minute-by-minute on the UTC timeline and reading the
|
|
3636
|
+
* wall-clock fields per-step, we naturally skip the "spring forward" gap
|
|
3637
|
+
* (those minutes simply don't exist in the local clock so they can't match
|
|
3638
|
+
* the user's local-time spec) and double-fire on "fall back" (the wall
|
|
3639
|
+
* clock visits 1:30am twice; we fire each time). The latter matches Vixie
|
|
3640
|
+
* cron's documented behavior — users wanting strict once-per-day semantics
|
|
3641
|
+
* should pin their spec to UTC.
|
|
3642
|
+
*/
|
|
3643
|
+
declare function nextFireFrom(match: CronMatch, from: Date, timezone?: string): Date | null;
|
|
3644
|
+
|
|
3645
|
+
/**
|
|
3646
|
+
* Session middleware types.
|
|
3647
|
+
*
|
|
3648
|
+
* @see ./middleware.ts for the {@link sessionMiddleware} factory and the
|
|
3649
|
+
* module-augmentation pattern users opt into for typed `ctx.session`.
|
|
3650
|
+
*/
|
|
3651
|
+
/** Cookie attribute overrides. */
|
|
3652
|
+
interface SessionCookieOptions {
|
|
3653
|
+
/** Cookie `Domain` attribute. Omitted when undefined. */
|
|
3654
|
+
domain?: string;
|
|
3655
|
+
/** Cookie `Path` attribute. @default '/' */
|
|
3656
|
+
path?: string;
|
|
3657
|
+
/** Cookie `HttpOnly` attribute. @default true */
|
|
3658
|
+
httpOnly?: boolean;
|
|
3659
|
+
/** Cookie `SameSite` attribute. @default 'lax' */
|
|
3660
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
3661
|
+
/** Cookie `Secure` attribute. @default false */
|
|
3662
|
+
secure?: boolean;
|
|
3663
|
+
}
|
|
3664
|
+
/** Options accepted by {@link sessionMiddleware}. */
|
|
3665
|
+
interface SessionOptions {
|
|
3666
|
+
/**
|
|
3667
|
+
* HMAC secret(s) for signing the session-id cookie.
|
|
3668
|
+
*
|
|
3669
|
+
* - Single string: used for both signing and verification.
|
|
3670
|
+
* - Array: index `0` is the active signing key; ALL entries are accepted
|
|
3671
|
+
* for verification, enabling key rotation. Cookies signed with an older
|
|
3672
|
+
* key are re-signed with the active key on the next response.
|
|
3673
|
+
*/
|
|
3674
|
+
secret: string | string[];
|
|
3675
|
+
/** Name of the session cookie. @default 'ingenium.sid' */
|
|
3676
|
+
cookieName?: string;
|
|
3677
|
+
/** Cookie / store TTL in seconds. @default 604800 (7 days) */
|
|
3678
|
+
maxAgeSeconds?: number;
|
|
3679
|
+
/**
|
|
3680
|
+
* If true, the cookie expiry and store TTL are refreshed on every request,
|
|
3681
|
+
* even when the session data did not change. @default false
|
|
3682
|
+
*/
|
|
3683
|
+
rolling?: boolean;
|
|
3684
|
+
/** Cookie attribute overrides. */
|
|
3685
|
+
cookie?: SessionCookieOptions;
|
|
3686
|
+
/**
|
|
3687
|
+
* Backing store. Defaults to an in-process {@link MemoryStore} which is
|
|
3688
|
+
* NOT suitable for clustered deployments — supply your own for Redis,
|
|
3689
|
+
* Postgres, etc.
|
|
3690
|
+
*/
|
|
3691
|
+
store?: SessionStore;
|
|
3692
|
+
}
|
|
3693
|
+
/**
|
|
3694
|
+
* Per-request session handle attached as `ctx.session`.
|
|
3695
|
+
*
|
|
3696
|
+
* Mutations (`set`, `delete`, `destroy`, `regenerate`) mark the session as
|
|
3697
|
+
* dirty so the middleware persists changes after the handler returns.
|
|
3698
|
+
*/
|
|
3699
|
+
interface Session {
|
|
3700
|
+
/** Stable, opaque session id (rotated by {@link Session.regenerate}). */
|
|
3701
|
+
readonly id: string;
|
|
3702
|
+
/** Frozen view of the session data. */
|
|
3703
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
3704
|
+
/** Read a value from the session. */
|
|
3705
|
+
get<T = unknown>(key: string): T | undefined;
|
|
3706
|
+
/** Write a value into the session. Marks the session dirty. */
|
|
3707
|
+
set(key: string, value: unknown): void;
|
|
3708
|
+
/** Remove a key from the session. Marks the session dirty. */
|
|
3709
|
+
delete(key: string): void;
|
|
3710
|
+
/** Drop the session: remove from store + clear the cookie. */
|
|
3711
|
+
destroy(): Promise<void>;
|
|
3712
|
+
/**
|
|
3713
|
+
* Issue a new session id while preserving the current data. The old id is
|
|
3714
|
+
* removed from the store. Use after privilege changes (e.g. login) to
|
|
3715
|
+
* mitigate session-fixation attacks.
|
|
3716
|
+
*/
|
|
3717
|
+
regenerate(): Promise<void>;
|
|
3718
|
+
}
|
|
3719
|
+
/**
|
|
3720
|
+
* Pluggable session storage. Implementations must be safe to call
|
|
3721
|
+
* concurrently for distinct ids; per-id ordering is the caller's concern.
|
|
3722
|
+
*/
|
|
3723
|
+
interface SessionStore {
|
|
3724
|
+
/** Look up a session by id. Returns `null` for unknown / expired ids. */
|
|
3725
|
+
get(id: string): Promise<Record<string, unknown> | null>;
|
|
3726
|
+
/** Persist `data` under `id` with the given TTL (seconds). */
|
|
3727
|
+
set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void>;
|
|
3728
|
+
/** Remove a session entirely. No-op if it does not exist. */
|
|
3729
|
+
destroy(id: string): Promise<void>;
|
|
3730
|
+
/**
|
|
3731
|
+
* OPTIONAL: extend an existing session's TTL without rewriting its data.
|
|
3732
|
+
* Used by `rolling` sessions on requests that did not mutate state.
|
|
3733
|
+
*/
|
|
3734
|
+
touch?(id: string, ttlSeconds: number): Promise<void>;
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
/**
|
|
3738
|
+
* Cookie-backed session middleware.
|
|
3739
|
+
*
|
|
3740
|
+
* The middleware attaches a {@link Session} instance at `ctx.session`. To
|
|
3741
|
+
* make this typesafe in user code, augment the `IngeniumContext` interface in
|
|
3742
|
+
* your own project:
|
|
3743
|
+
*
|
|
3744
|
+
* @example
|
|
3745
|
+
* ```ts
|
|
3746
|
+
* declare module 'ingenium' {
|
|
3747
|
+
* interface IngeniumContext { session: import('ingenium').Session }
|
|
3748
|
+
* }
|
|
3749
|
+
*
|
|
3750
|
+
* import { ingenium, sessionMiddleware } from 'ingenium'
|
|
3751
|
+
* const app = ingenium()
|
|
3752
|
+
* app.use(sessionMiddleware({ secret: process.env.SESSION_SECRET! }))
|
|
3753
|
+
*
|
|
3754
|
+
* app.get('/me', (ctx) => ({ user: ctx.session.get('user') }))
|
|
3755
|
+
* app.post('/login', async (ctx) => {
|
|
3756
|
+
* ctx.session.set('user', { id: 1 })
|
|
3757
|
+
* await ctx.session.regenerate() // mitigate session fixation
|
|
3758
|
+
* })
|
|
3759
|
+
* ```
|
|
3760
|
+
*
|
|
3761
|
+
* Security choices:
|
|
3762
|
+
* - HMAC-SHA-256 over the session id, base64url-encoded; verified with
|
|
3763
|
+
* `timingSafeEqual`.
|
|
3764
|
+
* - 144-bit (18-byte) random ids.
|
|
3765
|
+
* - Defaults: `HttpOnly`, `SameSite=Lax`, `Path=/`. Set `secure: true`
|
|
3766
|
+
* behind TLS to enable `Secure`.
|
|
3767
|
+
* - Tampered or unknown cookies silently issue a fresh session — never an
|
|
3768
|
+
* error response, since this is an attacker-influenced surface.
|
|
3769
|
+
*/
|
|
3770
|
+
declare function sessionMiddleware(opts: SessionOptions): IngeniumMiddleware;
|
|
3771
|
+
|
|
3772
|
+
/**
|
|
3773
|
+
* In-process session store backed by a `Map`. Suitable for development and
|
|
3774
|
+
* single-instance deployments. NOT shared across workers/replicas.
|
|
3775
|
+
*
|
|
3776
|
+
* Expired entries are evicted lazily on access AND periodically by a
|
|
3777
|
+
* background sweep. The sweep timer is `unref()`'d so it never keeps the
|
|
3778
|
+
* Node process alive on its own.
|
|
3779
|
+
*/
|
|
3780
|
+
declare class MemoryStore implements SessionStore {
|
|
3781
|
+
private readonly map;
|
|
3782
|
+
private readonly sweep;
|
|
3783
|
+
/**
|
|
3784
|
+
* @param sweepIntervalMs How often to scan the map for expired entries.
|
|
3785
|
+
* Defaults to 60s. Pass `0` to disable the timer entirely (tests).
|
|
3786
|
+
*/
|
|
3787
|
+
constructor(sweepIntervalMs?: number);
|
|
3788
|
+
get(id: string): Promise<Record<string, unknown> | null>;
|
|
3789
|
+
set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void>;
|
|
3790
|
+
destroy(id: string): Promise<void>;
|
|
3791
|
+
touch(id: string, ttlSeconds: number): Promise<void>;
|
|
3792
|
+
/**
|
|
3793
|
+
* Stop the background sweep timer. Useful in tests / graceful shutdown.
|
|
3794
|
+
* After this call the store still works but expired entries are only
|
|
3795
|
+
* evicted on access.
|
|
3796
|
+
*/
|
|
3797
|
+
stop(): void;
|
|
3798
|
+
/** @internal Test helper: number of live (non-expired) entries. */
|
|
3799
|
+
size(): number;
|
|
3800
|
+
private purge;
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
/** Re-export the underlying `ws` `WebSocket` type for convenience. */
|
|
3804
|
+
type WebSocket = WebSocket$1;
|
|
3805
|
+
/**
|
|
3806
|
+
* Handler invoked when a client successfully upgrades to a WebSocket.
|
|
3807
|
+
*
|
|
3808
|
+
* `socket` is the `ws.WebSocket` instance. `ctx` is a minimal `IngeniumContext`
|
|
3809
|
+
* populated from the upgrade `IncomingMessage` — the body / response writers
|
|
3810
|
+
* are not meaningful for WS handlers (the upgrade has already happened).
|
|
3811
|
+
*/
|
|
3812
|
+
type WebSocketHandler = (socket: WebSocket$1, ctx: IngeniumContext) => void | Promise<void>;
|
|
3813
|
+
/** Per-handler options forwarded to `WebSocketServer({ noServer: true, ... })`. */
|
|
3814
|
+
interface WebSocketHandlerOptions {
|
|
3815
|
+
/** Max payload size (bytes) for incoming frames. */
|
|
3816
|
+
maxPayload?: number;
|
|
3817
|
+
/** Enable permessage-deflate. Defaults to false (matches `ws` default). */
|
|
3818
|
+
perMessageDeflate?: boolean;
|
|
3819
|
+
}
|
|
3820
|
+
/** Bag passed to integrators (advanced). */
|
|
3821
|
+
interface WsIntegrator {
|
|
3822
|
+
(httpServer: node_http.Server): void;
|
|
3823
|
+
}
|
|
3824
|
+
/** Shape of the per-app registrar exposed to `enableWebSockets`. */
|
|
3825
|
+
interface WsRegistrar {
|
|
3826
|
+
add(path: string, handler: WebSocketHandler, options?: WebSocketHandlerOptions): void;
|
|
3827
|
+
attach(httpServer: node_http.Server): void;
|
|
3828
|
+
close(): Promise<void>;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
/**
|
|
3832
|
+
* WebSocket registrar — the small piece of state that holds path → handler
|
|
3833
|
+
* mappings and knows how to wire `'upgrade'` on a Node `http.Server`.
|
|
3834
|
+
*
|
|
3835
|
+
* Design: the `ws` package is loaded lazily via dynamic `import('ws')` so
|
|
3836
|
+
* apps that never use WebSockets pay no cost (no module load, no peer-dep
|
|
3837
|
+
* requirement). The first call to `attach()` resolves the import.
|
|
3838
|
+
*/
|
|
3839
|
+
|
|
3840
|
+
/**
|
|
3841
|
+
* Attempt to detect whether `ws` is installed. Used by the test suite to
|
|
3842
|
+
* `describe.skipIf` the WS suite when the optional peer dep is missing.
|
|
3843
|
+
*/
|
|
3844
|
+
declare function peerHasWs(): Promise<boolean>;
|
|
3845
|
+
/**
|
|
3846
|
+
* Build a registrar bound to an app. The registrar is intentionally
|
|
3847
|
+
* decoupled from `IngeniumApp` — the app calls `add()` from `app.ws()`, and
|
|
3848
|
+
* `enableWebSockets()` (or the app's `listen()` integration) calls `attach()`
|
|
3849
|
+
* once the underlying `http.Server` is created.
|
|
3850
|
+
*/
|
|
3851
|
+
declare function createWebSocketRegistrar(): WsRegistrar;
|
|
3852
|
+
|
|
3853
|
+
/**
|
|
3854
|
+
* WebSocket-aware variant of `NodeAdapter`. Mirrors the behavior of
|
|
3855
|
+
* `transport/node.ts` (request handling, socket tracking, graceful close)
|
|
3856
|
+
* but exposes the underlying `http.Server` via an `onServerReady` callback
|
|
3857
|
+
* so the WS registrar can `.on('upgrade', …)` it.
|
|
3858
|
+
*
|
|
3859
|
+
* We did not modify the core `NodeAdapter` because the core has no awareness
|
|
3860
|
+
* of WebSockets; this adapter is opt-in via `enableWebSockets()`.
|
|
3861
|
+
*/
|
|
3862
|
+
|
|
3863
|
+
type OnServerReady = (httpServer: Server$1) => void;
|
|
3864
|
+
declare class WsNodeAdapter implements Transport {
|
|
3865
|
+
private hooks;
|
|
3866
|
+
private readonly onServerReady;
|
|
3867
|
+
constructor(onServerReady: OnServerReady);
|
|
3868
|
+
attach(hooks: TransportHooks): void;
|
|
3869
|
+
listen(port: number, host?: string): Promise<ListeningServer>;
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
/**
|
|
3873
|
+
* WebSocket adapter for Ingenium (optional `ws` peer dependency).
|
|
3874
|
+
*
|
|
3875
|
+
* # Usage
|
|
3876
|
+
* ```ts
|
|
3877
|
+
* import { ingenium } from 'ingenium'
|
|
3878
|
+
* import { enableWebSockets } from 'ingenium/ws'
|
|
3879
|
+
*
|
|
3880
|
+
* const app = ingenium()
|
|
3881
|
+
* enableWebSockets(app)
|
|
3882
|
+
* app.ws('/echo', (sock) => {
|
|
3883
|
+
* sock.on('message', (m) => sock.send(m))
|
|
3884
|
+
* })
|
|
3885
|
+
* await app.listen(3000)
|
|
3886
|
+
* ```
|
|
3887
|
+
*
|
|
3888
|
+
* # Why a monkey-patch?
|
|
3889
|
+
* `enableWebSockets(app)` augments the app instance with `app.ws()` and
|
|
3890
|
+
* wraps `app.listen()` so the registrar gets attached to the underlying
|
|
3891
|
+
* `http.Server` once it's bound. We chose this over extending `IngeniumApp` to
|
|
3892
|
+
* avoid pulling `./ws/middleware.ts` into the core import graph (which would
|
|
3893
|
+
* create a soft dep on `ws` types from every `app.ts` consumer). This is a
|
|
3894
|
+
* known pattern in WS-extending frameworks (e.g. `express-ws`).
|
|
3895
|
+
*
|
|
3896
|
+
* The trade-off: TypeScript can't statically see `app.ws` unless the
|
|
3897
|
+
* augmentation below is loaded. Importing this module both registers the
|
|
3898
|
+
* runtime patch AND adds the type augmentation to the global `IngeniumApp`.
|
|
3899
|
+
*/
|
|
3900
|
+
|
|
3901
|
+
declare module '../app.ts' {
|
|
3902
|
+
interface IngeniumApp {
|
|
3903
|
+
ws(path: string, handler: WebSocketHandler, options?: WebSocketHandlerOptions): IngeniumApp;
|
|
3904
|
+
upgradeWith(integrator: WsIntegrator): IngeniumApp;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
/** Options for `enableWebSockets`. Reserved for future use. */
|
|
3908
|
+
interface EnableWebSocketsOptions {
|
|
3909
|
+
/**
|
|
3910
|
+
* When `true`, eagerly probes for the `ws` peer dependency at install
|
|
3911
|
+
* time and prints a warning if it is missing. Default: `false` (we wait
|
|
3912
|
+
* until the first upgrade attempt).
|
|
3913
|
+
*/
|
|
3914
|
+
warnOnMissingPeer?: boolean;
|
|
3915
|
+
}
|
|
3916
|
+
/**
|
|
3917
|
+
* Augment a `IngeniumApp` with WebSocket support. Idempotent — calling more than
|
|
3918
|
+
* once on the same app is a no-op.
|
|
3919
|
+
*/
|
|
3920
|
+
declare function enableWebSockets(app: IngeniumApp, opts?: EnableWebSocketsOptions): void;
|
|
3921
|
+
|
|
3922
|
+
/**
|
|
3923
|
+
* Registry for the framework's lifecycle hooks. Implements the `Hooks`
|
|
3924
|
+
* interface that plugins call into via `app.hooks`.
|
|
3925
|
+
*
|
|
3926
|
+
* # Execution model
|
|
3927
|
+
*
|
|
3928
|
+
* `runOn*` methods invoke listeners **sequentially** in registration order,
|
|
3929
|
+
* awaiting each one before invoking the next. This is intentional:
|
|
3930
|
+
*
|
|
3931
|
+
* - Predictable ordering: a hook registered first ALWAYS observes state
|
|
3932
|
+
* before a hook registered later. Plugins can rely on this.
|
|
3933
|
+
* - Backpressure: an async hook (e.g. fetching a session) blocks
|
|
3934
|
+
* subsequent hooks, ensuring downstream hooks see decorated state.
|
|
3935
|
+
* - Errors short-circuit `runOnRequest`/`runOnResponse`/`runOnCompose` —
|
|
3936
|
+
* they propagate to the caller (the request enters the error boundary).
|
|
3937
|
+
*
|
|
3938
|
+
* `runOnError` is the exception: it wraps each listener in a try/catch and
|
|
3939
|
+
* swallows throws, because observers must not mask the original error.
|
|
3940
|
+
*
|
|
3941
|
+
* # Reading order
|
|
3942
|
+
*
|
|
3943
|
+
* Within a single `run*` call, listeners run in the order they were added.
|
|
3944
|
+
* Across hook types within one request, the order is fixed by `app.handle`:
|
|
3945
|
+
*
|
|
3946
|
+
* onRequest -> (decorators applied) -> dispatch -> onResponse
|
|
3947
|
+
* \-> onError (on throw)
|
|
3948
|
+
*
|
|
3949
|
+
* # Hot-path note
|
|
3950
|
+
*
|
|
3951
|
+
* Each `runOn*` returns immediately if no listeners are registered. Callers
|
|
3952
|
+
* should additionally check `hasAny()` (or the per-hook `has*()` helpers) to
|
|
3953
|
+
* skip the `await` entirely on the zero-plugin path.
|
|
3954
|
+
*/
|
|
3955
|
+
declare class HooksRegistry implements Hooks {
|
|
3956
|
+
private readonly _onRoute;
|
|
3957
|
+
private readonly _onCompose;
|
|
3958
|
+
private readonly _onRequest;
|
|
3959
|
+
private readonly _onResponse;
|
|
3960
|
+
private readonly _onError;
|
|
3961
|
+
onRoute(fn: OnRouteHook): void;
|
|
3962
|
+
onCompose(fn: OnComposeHook): void;
|
|
3963
|
+
onRequest(fn: OnRequestHook): void;
|
|
3964
|
+
onResponse(fn: OnResponseHook): void;
|
|
3965
|
+
onError(fn: OnErrorHook): void;
|
|
3966
|
+
/** True when any request-time hook is registered. */
|
|
3967
|
+
hasAny(): boolean;
|
|
3968
|
+
hasOnRequest(): boolean;
|
|
3969
|
+
hasOnResponse(): boolean;
|
|
3970
|
+
hasOnError(): boolean;
|
|
3971
|
+
hasOnRoute(): boolean;
|
|
3972
|
+
hasOnCompose(): boolean;
|
|
3973
|
+
/** Synchronous — `onRoute` is invoked during composition for each route. */
|
|
3974
|
+
runOnRoute(event: RegistrationEvent): void;
|
|
3975
|
+
runOnCompose(): Promise<void>;
|
|
3976
|
+
runOnRequest(ctx: IngeniumContext): Promise<void>;
|
|
3977
|
+
runOnResponse(ctx: IngeniumContext): Promise<void>;
|
|
3978
|
+
/** Observation only. Throws inside listeners are swallowed. */
|
|
3979
|
+
runOnError(err: unknown, ctx: IngeniumContext): Promise<void>;
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
/**
|
|
3983
|
+
* Per-app registry of decorators. Decorators are NOT installed onto
|
|
3984
|
+
* `IngeniumContext.prototype` — that would mutate a shared class and leak across
|
|
3985
|
+
* apps in the same process. Instead, `applyTo(ctx)` writes them onto each
|
|
3986
|
+
* pooled context instance at request start.
|
|
3987
|
+
*
|
|
3988
|
+
* # Lazy vs eager — perf trade-off
|
|
3989
|
+
*
|
|
3990
|
+
* - **Lazy** (`decorate`): installed via `Object.defineProperty` with a
|
|
3991
|
+
* getter. The getter computes on first access, then redefines itself as
|
|
3992
|
+
* a plain data property holding the resolved value (define-self pattern).
|
|
3993
|
+
* Subsequent reads cost a normal property access — no getter call. Use
|
|
3994
|
+
* this for values that may not be needed (e.g. `ctx.user` on public
|
|
3995
|
+
* routes), and for values whose computation is non-trivial (DB lookups,
|
|
3996
|
+
* token decoding).
|
|
3997
|
+
*
|
|
3998
|
+
* - **Eager** (`decorateRequest`): factory is invoked at request start,
|
|
3999
|
+
* value assigned directly. Use this for cheap values that virtually every
|
|
4000
|
+
* handler will read (e.g. `ctx.startedAt = Date.now()`). Avoids the
|
|
4001
|
+
* per-property getter-redefinition overhead.
|
|
4002
|
+
*
|
|
4003
|
+
* # Pool reuse
|
|
4004
|
+
*
|
|
4005
|
+
* Pooled contexts are reset between requests; the `IngeniumContext.reset()`
|
|
4006
|
+
* method does not know about decorator names, so each request re-applies
|
|
4007
|
+
* via `applyTo(ctx)`. Lazy `defineProperty` overwrites the previous slot
|
|
4008
|
+
* configuration cleanly; eager assignment overwrites the previous value.
|
|
4009
|
+
* No leakage between requests.
|
|
4010
|
+
*/
|
|
4011
|
+
declare class DecoratorRegistry {
|
|
4012
|
+
private readonly lazy;
|
|
4013
|
+
private readonly eager;
|
|
4014
|
+
/** Register a lazy decorator. Computed on first access; cached thereafter. */
|
|
4015
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): void;
|
|
4016
|
+
/** Register an eager decorator. Factory runs at the start of every request. */
|
|
4017
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): void;
|
|
4018
|
+
/** True when any decorator is registered (lets the hot path skip work). */
|
|
4019
|
+
hasAny(): boolean;
|
|
4020
|
+
/**
|
|
4021
|
+
* Install all registered decorators onto a single context instance.
|
|
4022
|
+
* Called by `app.handle` after `onRequest` hooks and before dispatch.
|
|
4023
|
+
*/
|
|
4024
|
+
applyTo(ctx: IngeniumContext): void;
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
/**
|
|
4028
|
+
* `ScopedApp` — registration facade returned to the callback of
|
|
4029
|
+
* `app.scope(prefix, registrar)`. Translates every registration call into a
|
|
4030
|
+
* prefix-qualified registration on the underlying `IngeniumApp`, leveraging
|
|
4031
|
+
* the existing Router scoping primitives (`use-prefix`, prefix-prepended
|
|
4032
|
+
* paths) so the COMPOSE-TIME machinery does all the heavy lifting and the
|
|
4033
|
+
* per-request hot path stays untouched.
|
|
4034
|
+
*
|
|
4035
|
+
* # Design
|
|
4036
|
+
*
|
|
4037
|
+
* - Holds a reference to the root `IngeniumApp` plus the ABSOLUTE prefix
|
|
4038
|
+
* (already includes any outer scope prefix). Nested scopes just construct
|
|
4039
|
+
* a new `ScopedApp` with `parent.prefix + sub`.
|
|
4040
|
+
* - `scope.use(mw)` becomes `app.use(absolutePrefix, mw)` — the existing
|
|
4041
|
+
* `Router.use(prefix, mw)` plumbing produces a `use-prefix` registration,
|
|
4042
|
+
* and `flattenRouter` emits a `scopedMiddleware` entry that `app.compose()`
|
|
4043
|
+
* intersects against route paths via `pathStartsWith`.
|
|
4044
|
+
* - `scope.get(path, handler)` becomes `app.method('GET', absolutePrefix + path, handler)`.
|
|
4045
|
+
* - `scope.register(plugin, opts)` invokes the plugin with `this` as the
|
|
4046
|
+
* target. Any `target.use(...)` inside the plugin body is therefore
|
|
4047
|
+
* scope-prefixed; the plugin can't accidentally leak global middleware.
|
|
4048
|
+
* - `scope.scope(sub, fn)` constructs a child `ScopedApp` and runs `fn`
|
|
4049
|
+
* against it.
|
|
4050
|
+
*
|
|
4051
|
+
* # Out of scope for V1 (documented footguns)
|
|
4052
|
+
*
|
|
4053
|
+
* - **Decorators**: `scope.decorate(...)` / `scope.decorateRequest(...)`
|
|
4054
|
+
* forward to the root app and decorate EVERY request, not just requests
|
|
4055
|
+
* under the scope's prefix. The reason is structural: decorators install
|
|
4056
|
+
* onto pooled `IngeniumContext` instances at request start, BEFORE the
|
|
4057
|
+
* route is matched — there's no path information available at that point
|
|
4058
|
+
* without re-shaping the dispatch path. Per-scope decorators would require
|
|
4059
|
+
* either (a) a runtime path check on every property access, or (b) a
|
|
4060
|
+
* separate decorator registry per scope keyed by matched route — both of
|
|
4061
|
+
* which move work onto the hot path and complicate the pool. For V1 we
|
|
4062
|
+
* accept the footgun and emit a one-shot `process.emitWarning` in
|
|
4063
|
+
* non-production environments to surface it.
|
|
4064
|
+
* - **Hooks**: `scope.hooks` returns the SAME registry the root app uses.
|
|
4065
|
+
* Hook registration is global. A plugin that wants scope-aware hook
|
|
4066
|
+
* behavior should inspect `ctx.path` inside the hook body.
|
|
4067
|
+
*/
|
|
4068
|
+
|
|
4069
|
+
/**
|
|
4070
|
+
* @internal Friend-access surface a `ScopedApp` needs from its parent
|
|
4071
|
+
* `IngeniumApp`. Kept narrow so refactors don't accidentally widen the
|
|
4072
|
+
* coupling. The methods are implemented on `IngeniumApp` itself.
|
|
4073
|
+
*/
|
|
4074
|
+
interface ScopeHost {
|
|
4075
|
+
use(mw: IngeniumMiddleware): IngeniumApp;
|
|
4076
|
+
use(prefix: string, mw: IngeniumMiddleware | Router): IngeniumApp;
|
|
4077
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4078
|
+
method(method: HttpMethod, path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): IngeniumApp;
|
|
4079
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): IngeniumApp;
|
|
4080
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): IngeniumApp;
|
|
4081
|
+
readonly hooks: Hooks;
|
|
4082
|
+
/** @internal Marks the app's compose-cache dirty. */
|
|
4083
|
+
_markDirty(): void;
|
|
4084
|
+
}
|
|
4085
|
+
/**
|
|
4086
|
+
* A `ScopedApp` is the registration target passed to the `app.scope(prefix, registrar)`
|
|
4087
|
+
* callback. It exposes the registration surface a plugin needs (`use`, verbs,
|
|
4088
|
+
* `register`, `decorate`, `before`/`after`, nested `scope`) but NOT the
|
|
4089
|
+
* dispatch surface (`compose`, `handle`, `listen`) — those still belong to
|
|
4090
|
+
* the root app.
|
|
4091
|
+
*
|
|
4092
|
+
* Instances are cheap: a couple of fields and method-call forwarding. Do not
|
|
4093
|
+
* cache them across recompose boundaries — they hold a reference to the
|
|
4094
|
+
* `IngeniumApp` and rely on its mutable router journal.
|
|
4095
|
+
*/
|
|
4096
|
+
declare class ScopedApp implements PluginTarget {
|
|
4097
|
+
/** @internal The root app this scope translates registrations onto. */
|
|
4098
|
+
private readonly _app;
|
|
4099
|
+
/** @internal Absolute prefix (already includes any outer scope's prefix). */
|
|
4100
|
+
private readonly _prefix;
|
|
4101
|
+
/** @internal Construct via `app.scope(...)`; not meant to be `new`'d directly. */
|
|
4102
|
+
constructor(app: ScopeHost, prefix: string);
|
|
4103
|
+
/** Absolute prefix this scope rewrites against (for debugging / introspection). */
|
|
4104
|
+
get prefix(): string;
|
|
4105
|
+
/** Lifecycle hooks. SHARED with the root app — hooks are global by design. */
|
|
4106
|
+
get hooks(): Hooks;
|
|
4107
|
+
use(mw: IngeniumMiddleware): this;
|
|
4108
|
+
use(subPrefix: string, mw: IngeniumMiddleware | Router): this;
|
|
4109
|
+
get(path: string, handler: IngeniumHandler): this;
|
|
4110
|
+
get(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4111
|
+
post(path: string, handler: IngeniumHandler): this;
|
|
4112
|
+
post(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4113
|
+
put(path: string, handler: IngeniumHandler): this;
|
|
4114
|
+
put(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4115
|
+
patch(path: string, handler: IngeniumHandler): this;
|
|
4116
|
+
patch(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4117
|
+
delete(path: string, handler: IngeniumHandler): this;
|
|
4118
|
+
delete(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4119
|
+
head(path: string, handler: IngeniumHandler): this;
|
|
4120
|
+
head(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4121
|
+
options(path: string, handler: IngeniumHandler): this;
|
|
4122
|
+
options(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4123
|
+
method(method: HttpMethod, path: string, handler: IngeniumHandler): this;
|
|
4124
|
+
method(method: HttpMethod, path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this;
|
|
4125
|
+
/**
|
|
4126
|
+
* Chainable per-path builder. The builder closes over `this.method`, which
|
|
4127
|
+
* already does the scope-prefix join — so the same builder works identically
|
|
4128
|
+
* on the root app and inside a scope. Typed params via `ExtractParams<P>`
|
|
4129
|
+
* narrow against the RELATIVE path the user wrote, matching what the bare
|
|
4130
|
+
* verb form does.
|
|
4131
|
+
*/
|
|
4132
|
+
route<P extends string>(path: P): RouteBuilder<P>;
|
|
4133
|
+
/**
|
|
4134
|
+
* Register a lazy decorator. **WARNING:** decorators are GLOBAL even when
|
|
4135
|
+
* registered inside a scope — they apply to every request regardless of
|
|
4136
|
+
* the scope's prefix. The first call from inside any scope in a process
|
|
4137
|
+
* emits a `process.emitWarning` (non-production only). See file header.
|
|
4138
|
+
*/
|
|
4139
|
+
decorate<T>(name: string, factory: LazyDecorator<T>): this;
|
|
4140
|
+
/**
|
|
4141
|
+
* Register an eager decorator. **WARNING:** see {@link ScopedApp.decorate}.
|
|
4142
|
+
*/
|
|
4143
|
+
decorateRequest<T>(name: string, factory: EagerDecorator<T>): this;
|
|
4144
|
+
/**
|
|
4145
|
+
* Register a plugin against THIS scope. The plugin receives the `ScopedApp`
|
|
4146
|
+
* as its `target`, so any `target.use(...)` inside the plugin body is
|
|
4147
|
+
* automatically prefix-scoped.
|
|
4148
|
+
*/
|
|
4149
|
+
register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>;
|
|
4150
|
+
register(plugin: IngeniumPlugin<void>): Promise<this>;
|
|
4151
|
+
/**
|
|
4152
|
+
* Open a nested scope. `subPrefix` is relative to this scope's prefix.
|
|
4153
|
+
* The registrar may be async; the call returns a Promise that resolves
|
|
4154
|
+
* once the registrar finishes if it returned one, otherwise resolves
|
|
4155
|
+
* synchronously to `this`. We type-erase to `this` to match the
|
|
4156
|
+
* `PluginTarget` interface, which can't express the sync-or-async return
|
|
4157
|
+
* without polluting every caller.
|
|
4158
|
+
*/
|
|
4159
|
+
scope(subPrefix: string, registrar: (scope: PluginTarget) => void): this;
|
|
4160
|
+
/**
|
|
4161
|
+
* Register a `before` filter scoped to this scope's prefix. If a pattern
|
|
4162
|
+
* is given it's appended to the scope's prefix (so `scope('/api').before('/users', h)`
|
|
4163
|
+
* matches `/api/users` and below). If omitted, the filter applies to the
|
|
4164
|
+
* scope's full subtree.
|
|
4165
|
+
*/
|
|
4166
|
+
before(handler: IngeniumMiddleware): this;
|
|
4167
|
+
before(pattern: string, handler: IngeniumMiddleware): this;
|
|
4168
|
+
/** Register an `after` filter scoped to this scope's prefix. See {@link ScopedApp.before}. */
|
|
4169
|
+
after(handler: IngeniumMiddleware): this;
|
|
4170
|
+
after(pattern: string, handler: IngeniumMiddleware): this;
|
|
4171
|
+
}
|
|
4172
|
+
|
|
4173
|
+
/**
|
|
4174
|
+
* Sinatra-style top-level shorthand.
|
|
4175
|
+
*
|
|
4176
|
+
* Lets users skip the app object entirely:
|
|
4177
|
+
*
|
|
4178
|
+
* ```ts
|
|
4179
|
+
* import { get, post, listen } from 'ingenium'
|
|
4180
|
+
*
|
|
4181
|
+
* get('/', () => 'hi')
|
|
4182
|
+
* get('/users/:id', (ctx) => ({ id: ctx.params.id }))
|
|
4183
|
+
* post('/echo', async (ctx) => ctx.body.json())
|
|
4184
|
+
*
|
|
4185
|
+
* await listen(3000)
|
|
4186
|
+
* ```
|
|
4187
|
+
*
|
|
4188
|
+
* All exported verbs route to a lazy singleton `IngeniumApp` created on first
|
|
4189
|
+
* call. The instance is retained for the lifetime of the process; tests can
|
|
4190
|
+
* call `_resetDefaultApp()` to drop it (this throws in production).
|
|
4191
|
+
*/
|
|
4192
|
+
|
|
4193
|
+
/**
|
|
4194
|
+
* Get the lazy default app. Created on first call, retained for the
|
|
4195
|
+
* lifetime of the process (or until `_resetDefaultApp()` is invoked).
|
|
4196
|
+
*
|
|
4197
|
+
* The same instance is returned on every subsequent call, so all
|
|
4198
|
+
* top-level verb functions and `listen()` operate on a single coherent
|
|
4199
|
+
* registration journal.
|
|
4200
|
+
*/
|
|
4201
|
+
declare function defaultApp(): IngeniumApp;
|
|
4202
|
+
/**
|
|
4203
|
+
* Reset the default app — for tests only. The next call to any top-level
|
|
4204
|
+
* function will lazily create a fresh `IngeniumApp`. Throws when
|
|
4205
|
+
* `NODE_ENV === 'production'` so accidental production calls are loud.
|
|
4206
|
+
*/
|
|
4207
|
+
declare function _resetDefaultApp(): void;
|
|
4208
|
+
declare function get(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4209
|
+
declare function post(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4210
|
+
declare function put(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4211
|
+
declare function patch(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4212
|
+
/**
|
|
4213
|
+
* Default-app shorthand for `app.delete(path, handler)`.
|
|
4214
|
+
* Exported as `del` because `delete` is a reserved word in JavaScript and
|
|
4215
|
+
* cannot be used as a top-level identifier. `index.ts` re-exports this as
|
|
4216
|
+
* `{ del as delete }` so the public name is `delete`.
|
|
4217
|
+
*/
|
|
4218
|
+
declare function del(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4219
|
+
declare function head(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4220
|
+
declare function options(path: string, handler: IngeniumHandler): IngeniumApp;
|
|
4221
|
+
/**
|
|
4222
|
+
* Mount middleware on the default app. Same overload set as `app.use`:
|
|
4223
|
+
* - `use(mw)` — global
|
|
4224
|
+
* - `use(prefix, mw | Router)` — prefix-scoped
|
|
4225
|
+
*/
|
|
4226
|
+
declare function use(mw: IngeniumMiddleware): IngeniumApp;
|
|
4227
|
+
declare function use(prefix: string, mw: IngeniumMiddleware | Router): IngeniumApp;
|
|
4228
|
+
/** Default-app shorthand for `app.onError(handler)`. */
|
|
4229
|
+
declare function onError(handler: IngeniumErrorHandler): IngeniumApp;
|
|
4230
|
+
/**
|
|
4231
|
+
* Bind the default app to a port. Returns a `ListeningServer` whose
|
|
4232
|
+
* `.close()` shuts down the underlying transport. Pass `0` for an
|
|
4233
|
+
* ephemeral port (useful in tests).
|
|
4234
|
+
*/
|
|
4235
|
+
declare function listen(port: number, host?: string): Promise<ListeningServer>;
|
|
4236
|
+
declare function before(handler: IngeniumMiddleware): IngeniumApp;
|
|
4237
|
+
declare function before(pattern: string, handler: IngeniumMiddleware): IngeniumApp;
|
|
4238
|
+
declare function after(handler: IngeniumMiddleware): IngeniumApp;
|
|
4239
|
+
declare function after(pattern: string, handler: IngeniumMiddleware): IngeniumApp;
|
|
4240
|
+
|
|
4241
|
+
/**
|
|
4242
|
+
* Ingenium — Express DX, Hono/Fastify throughput.
|
|
4243
|
+
*
|
|
4244
|
+
* @packageDocumentation
|
|
4245
|
+
*/
|
|
4246
|
+
|
|
4247
|
+
declare const ingenium: IngeniumFactory & {
|
|
4248
|
+
json: typeof jsonMiddleware;
|
|
4249
|
+
urlencoded: typeof urlencodedMiddleware;
|
|
4250
|
+
static: typeof staticMiddleware;
|
|
4251
|
+
cors: typeof corsMiddleware;
|
|
4252
|
+
csrf: typeof csrfMiddleware;
|
|
4253
|
+
sse: typeof sse;
|
|
4254
|
+
rateLimit: typeof rateLimit;
|
|
4255
|
+
problemDetails: typeof problemDetailsMiddleware;
|
|
4256
|
+
idempotency: typeof idempotencyMiddleware;
|
|
4257
|
+
jwt: typeof jwtMiddleware;
|
|
4258
|
+
apiKey: typeof apiKeyMiddleware;
|
|
4259
|
+
openapiHandler: typeof openapiHandler;
|
|
4260
|
+
};
|
|
4261
|
+
|
|
4262
|
+
export { type ApiKeyLogger, type ApiKeyOptions, type ApiKeyValidator, type CachedResponse, type CloseOptions, type ComposedHandler, type CookieGetOptions, type CookieSetOptions, type CorsOptions, type CorsOrigin, type CorsOriginFn, type CronHandler, type CronMatch, type CronOptions, CronRegistry, type CsrfCookieOptions, type CsrfOptions, type CsrfStorage, type CsrfValueReader, type Decorator, DecoratorRegistry, type EagerDecorator, type EnableWebSocketsOptions, type ExtractParams, type FailedJob, type FormatHandlers, type FormattableCtx, type ForwardedInfo, type GenerateOpenApiOptions, HTTP_METHODS, type HeaderBag, type Hooks, HooksRegistry, Http2Adapter, type Http2AdapterOptions, Http2cAdapter, type HttpMethod, IdempotencyMemoryStore, type IdempotencyOptions, type IdempotencyStore, IngeniumApp, type IngeniumAppOptions, IngeniumBadRequestError, IngeniumBody, IngeniumContext, IngeniumContextPool, type IngeniumCookies, IngeniumCronJob, IngeniumCsrfError, IngeniumError, type IngeniumErrorHandler, IngeniumHaltError, type IngeniumHandler, IngeniumHeaderInjectionError, IngeniumMethodNotAllowedError, type IngeniumMiddleware, IngeniumNotFoundError, IngeniumPayloadTooLargeError, type IngeniumPlugin, type IngeniumQuery, IngeniumQueue, IngeniumTimeoutError, IngeniumUnauthorizedError, IngeniumUnserializableError, IngeniumValidationError, type InjectRequest, type InjectResponse, type JobHandle, type JsonEtagCtx, type JsonEtagOptions, type JwtAlgorithm, type JwtHeader, type JwtKey, type JwtLogger, type JwtOptions, type JwtSecret, type JwtSecretResolver, type JwtTokenReader, type JwtVerified, type LazyDecorator, type ListeningServer, type MatchMiss, type MatchResult, MemoryQueueStore, type MultipartFile, type MultipartOptions, type MultipartResult, type NegotiableCtx, NodeAdapter, type OnComposeHook, type OnErrorHook, type OnRequestHook, type OnResponseHook, type OnRouteHook, type Components as OpenApiComponents, type Info as OpenApiInfo, type Response as OpenApiResponse, type Schema as OpenApiSchema, type SecurityRequirement as OpenApiSecurityRequirement, type SecurityScheme as OpenApiSecurityScheme, type Server as OpenApiServer, type OpenApiSpec, type Tag as OpenApiTag, type Operation, type Parameter, type ParseSchema, type ParsedAccept, type PathItem, type PluginTarget, type ProblemDetails, type ProblemDetailsOptions, type QueueOptions, QueueRegistry, type QueueStore, type QueueWorker, MemoryStore$1 as RateLimitMemoryStore, type RateLimitOptions, type RateLimitStore, type RegisteredQueue, type RegistrationEvent, type RequestBody, type ResponseBody, type RetryPolicy, RouteBuilder, type RouteDescriptor, type RouteOptions, Router, RouterTrie, type SafeJsonStringifyOptions, type SafeParseSchema, ScopedApp, type Session, type SessionCookieOptions, MemoryStore as SessionMemoryStore, type SessionOptions, type SessionStore, type ShutdownOptions, type SseEvent, type SseStream, type StandardFailureResult, type StandardIssue, type StandardPathSegment, type StandardResult, type StandardSchemaV1, type StandardSchemaV1Props, type StandardSuccessResult, type StaticOptions, type Transport, type TransportHooks, TrieNode, type TrustProxy, type WebSocket, type WebSocketHandler, type WebSocketHandlerOptions, type WsIntegrator, WsNodeAdapter, type WsRegistrar, _resetDefaultApp, accepts, acceptsCharsets, acceptsEncodings, acceptsLanguages, after, apiKeyMiddleware, before, clearJwksCache, compose, composeWithHandler, computeEtag, corsMiddleware as cors_, createWebSocketRegistrar, csrfMiddleware, ingenium as default, defaultApp, del as delete, enableWebSockets, expandShorthand, fetchJwks, formatResponse, generateOpenApi, get, gracefulShutdown, head, idempotencyMiddleware, ingenium, isFresh, isStandardSchema, jwtMiddleware, listen, nextFireFrom, onError, openapiHandler, options, parseAcceptHeader, parseCronSpec, patch, peerHasWs, post, problemDetailsMiddleware, put, rateLimit, resolveForwarded, respondJsonWithEtag, safeJsonStringify, selectBest, sessionMiddleware, sortByPreference, sse, startKeepAlive, staticMiddleware as static_, toProblemDetails, use, verifyJwt };
|