ingenium 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- package/src/ws/ws-node-adapter.ts +162 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
4
|
+
import type { HttpMethod } from '../router/types.ts'
|
|
5
|
+
import { createByteLimit } from '../body/limit.ts'
|
|
6
|
+
import type { CloseOptions, ListeningServer, Transport, TransportHooks } from './types.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Node.js `node:http` transport. Owns a single `http.Server`; on each
|
|
10
|
+
* request, populates a pooled `IngeniumContext` directly from the
|
|
11
|
+
* `IncomingMessage` (no WinterCG translation), awaits dispatch, then writes
|
|
12
|
+
* the context's response state to the `ServerResponse`.
|
|
13
|
+
*/
|
|
14
|
+
export class NodeAdapter implements Transport {
|
|
15
|
+
private hooks: TransportHooks | null = null
|
|
16
|
+
|
|
17
|
+
attach(hooks: TransportHooks): void {
|
|
18
|
+
this.hooks = hooks
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async listen(port: number, host = '127.0.0.1'): Promise<ListeningServer> {
|
|
22
|
+
if (!this.hooks) throw new Error('NodeAdapter.listen() called before attach()')
|
|
23
|
+
const hooks = this.hooks
|
|
24
|
+
|
|
25
|
+
const server = createServer((req, res) => {
|
|
26
|
+
handleRequest(req, res, hooks).catch((err) => {
|
|
27
|
+
// Last-resort safety net — the dispatch loop should have caught everything.
|
|
28
|
+
if (!res.headersSent) {
|
|
29
|
+
res.statusCode = 500
|
|
30
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
31
|
+
res.end(JSON.stringify({ error: 'Internal Server Error', code: 'INTERNAL_ERROR' }))
|
|
32
|
+
} else {
|
|
33
|
+
res.end()
|
|
34
|
+
}
|
|
35
|
+
process.emitWarning(`ingenium: dispatch leaked: ${(err as Error).message ?? String(err)}`)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Track every open socket so close() can drain (and, if asked, force-kill)
|
|
40
|
+
// idle keep-alive connections that `server.close()` alone would leave open.
|
|
41
|
+
const sockets = new Set<Socket>()
|
|
42
|
+
server.on('connection', (socket) => {
|
|
43
|
+
sockets.add(socket)
|
|
44
|
+
socket.on('close', () => sockets.delete(socket))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return new Promise<ListeningServer>((resolve, reject) => {
|
|
48
|
+
server.once('error', reject)
|
|
49
|
+
server.listen(port, host, () => {
|
|
50
|
+
const addr = server.address()
|
|
51
|
+
if (!addr || typeof addr === 'string') {
|
|
52
|
+
reject(new Error('Failed to determine bound address'))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
resolve({
|
|
56
|
+
port: addr.port,
|
|
57
|
+
host: addr.address,
|
|
58
|
+
close: (opts?: CloseOptions) =>
|
|
59
|
+
new Promise<void>((res, rej) => {
|
|
60
|
+
let settled = false
|
|
61
|
+
let timer: NodeJS.Timeout | null = null
|
|
62
|
+
|
|
63
|
+
server.close((err) => {
|
|
64
|
+
if (timer) clearTimeout(timer)
|
|
65
|
+
if (settled) return
|
|
66
|
+
settled = true
|
|
67
|
+
err ? rej(err) : res()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const timeoutMs = opts?.gracefulTimeoutMs
|
|
71
|
+
if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs)) {
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
// Force-close any sockets still hanging around (idle
|
|
74
|
+
// keep-alives or slow handlers). server.close()'s callback
|
|
75
|
+
// will fire once they're destroyed.
|
|
76
|
+
for (const socket of sockets) socket.destroy()
|
|
77
|
+
}, Math.max(0, timeoutMs))
|
|
78
|
+
// Don't keep the event loop alive just for the force-close timer.
|
|
79
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleRequest(req: IncomingMessage, res: ServerResponse, hooks: TransportHooks): Promise<void> {
|
|
89
|
+
// Normalize once: TransportHooks types maxRequestBytes as optional for
|
|
90
|
+
// backward-compat; framework dispatch always sets it. Older fixtures may
|
|
91
|
+
// not — treat undefined as "no cap" (Infinity).
|
|
92
|
+
const maxBytes = hooks.maxRequestBytes ?? Number.POSITIVE_INFINITY
|
|
93
|
+
|
|
94
|
+
// Content-Length pre-check: if the client declares a body larger than the
|
|
95
|
+
// ceiling, reject IMMEDIATELY without acquiring a context or buffering
|
|
96
|
+
// anything. Chunked requests (no Content-Length) and Content-Length: 0
|
|
97
|
+
// fall through to the byte-limit Transform below, which catches
|
|
98
|
+
// mid-stream overruns.
|
|
99
|
+
if (rejectIfContentLengthTooBig(req, res, maxBytes)) return
|
|
100
|
+
|
|
101
|
+
const ctx = hooks.acquire()
|
|
102
|
+
try {
|
|
103
|
+
populateContext(ctx, req, maxBytes)
|
|
104
|
+
await hooks.dispatch(ctx)
|
|
105
|
+
writeResponse(ctx, res)
|
|
106
|
+
} finally {
|
|
107
|
+
hooks.release(ctx)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns `true` (and writes a 413 response) if the request advertises a
|
|
113
|
+
* Content-Length greater than `maxRequestBytes`. Returns `false` for missing,
|
|
114
|
+
* invalid, or in-range Content-Length values — those cases are handled by
|
|
115
|
+
* the byte-limit Transform downstream.
|
|
116
|
+
*/
|
|
117
|
+
function rejectIfContentLengthTooBig(
|
|
118
|
+
req: IncomingMessage,
|
|
119
|
+
res: ServerResponse,
|
|
120
|
+
maxRequestBytes: number,
|
|
121
|
+
): boolean {
|
|
122
|
+
if (!Number.isFinite(maxRequestBytes)) return false
|
|
123
|
+
const raw = req.headers['content-length']
|
|
124
|
+
if (typeof raw !== 'string' || raw.length === 0) return false
|
|
125
|
+
const n = Number(raw)
|
|
126
|
+
if (!Number.isFinite(n)) return false
|
|
127
|
+
if (n <= maxRequestBytes) return false
|
|
128
|
+
|
|
129
|
+
res.statusCode = 413
|
|
130
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
131
|
+
res.setHeader('connection', 'close')
|
|
132
|
+
res.end(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
error: `Request body exceeded ${maxRequestBytes} bytes`,
|
|
135
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
// Hint the kernel to drop any pending body bytes; we never read them.
|
|
139
|
+
req.socket?.destroy()
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function populateContext(ctx: IngeniumContext, req: IncomingMessage, maxRequestBytes: number): void {
|
|
144
|
+
ctx.method = (req.method ?? 'GET') as HttpMethod
|
|
145
|
+
ctx.url = req.url ?? '/'
|
|
146
|
+
// Split path / query without allocating a URL object.
|
|
147
|
+
const url = ctx.url
|
|
148
|
+
const qIdx = url.indexOf('?')
|
|
149
|
+
if (qIdx >= 0) {
|
|
150
|
+
ctx.path = url.slice(0, qIdx)
|
|
151
|
+
ctx.rawQuery = url.slice(qIdx + 1)
|
|
152
|
+
} else {
|
|
153
|
+
ctx.path = url
|
|
154
|
+
ctx.rawQuery = ''
|
|
155
|
+
}
|
|
156
|
+
ctx.headers = req.headers
|
|
157
|
+
ctx.remoteAddress = req.socket?.remoteAddress ?? '127.0.0.1'
|
|
158
|
+
// Detect TLS via the socket's `encrypted` flag (set by tls.TLSSocket).
|
|
159
|
+
ctx.baseProtocol = (req.socket as { encrypted?: boolean })?.encrypted ? 'https' : 'http'
|
|
160
|
+
|
|
161
|
+
// Wire body lazily — the source stream is only consumed if a body method is called.
|
|
162
|
+
const cl = req.headers['content-length']
|
|
163
|
+
const contentLength = cl ? Number(cl) : undefined
|
|
164
|
+
const ct = req.headers['content-type']
|
|
165
|
+
// Wrap the raw IncomingMessage in a transport-level byte-limit so the cap
|
|
166
|
+
// applies to EVERY consumer, including `ctx.body.stream()`. We skip the
|
|
167
|
+
// wrap in three provably-safe cases:
|
|
168
|
+
//
|
|
169
|
+
// 1. The request is structurally body-less (GET/HEAD/OPTIONS or
|
|
170
|
+
// Content-Length: 0). No body to cap.
|
|
171
|
+
// 2. The cap is disabled (Number.POSITIVE_INFINITY).
|
|
172
|
+
// 3. Content-Length is declared AND ≤ cap. The pre-check
|
|
173
|
+
// (`rejectIfContentLengthTooBig`) already verified this; node:http
|
|
174
|
+
// itself enforces the declared length and stops reading at the
|
|
175
|
+
// byte count, so the body cannot exceed the cap. The Transform
|
|
176
|
+
// would be redundant defense in this path.
|
|
177
|
+
//
|
|
178
|
+
// Chunked encoding (no Content-Length) keeps the Transform — that's
|
|
179
|
+
// where the cap actually matters, because the client controls the
|
|
180
|
+
// stream length without any prior declaration.
|
|
181
|
+
const noBody =
|
|
182
|
+
contentLength === 0 ||
|
|
183
|
+
ctx.method === 'GET' ||
|
|
184
|
+
ctx.method === 'HEAD' ||
|
|
185
|
+
ctx.method === 'OPTIONS'
|
|
186
|
+
const knownSafe =
|
|
187
|
+
contentLength !== undefined &&
|
|
188
|
+
Number.isFinite(contentLength) &&
|
|
189
|
+
contentLength <= maxRequestBytes
|
|
190
|
+
if (noBody || !Number.isFinite(maxRequestBytes) || knownSafe) {
|
|
191
|
+
ctx.body._attach(req, ct, Number.isFinite(contentLength) ? contentLength : undefined)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Cap unknown-length (chunked) bodies with a byte-limit Transform. `pipe()`
|
|
196
|
+
// does NOT forward `'error'` events, so when the chunked path in
|
|
197
|
+
// `IngeniumBody.buffer` re-pipes this Transform into a SECOND limiter and only
|
|
198
|
+
// listens on the downstream pipe, the cap error here would (a) be an
|
|
199
|
+
// unhandled-error crash and (b) never reach that downstream — so the
|
|
200
|
+
// consumer's promise would hang. Attach a guard `'error'` listener and
|
|
201
|
+
// forward the error to every stream this Transform was piped into. We leave
|
|
202
|
+
// `req`/its socket alone so the response (413) can still flush.
|
|
203
|
+
const limited = createByteLimit(maxRequestBytes)
|
|
204
|
+
const downstream = new Set<{ destroy(err?: Error): void; destroyed: boolean }>()
|
|
205
|
+
const origPipe = limited.pipe.bind(limited) as typeof limited.pipe
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
|
+
limited.pipe = function pipe(dest: any, ...rest: any[]) {
|
|
208
|
+
downstream.add(dest)
|
|
209
|
+
return origPipe(dest, ...rest)
|
|
210
|
+
} as typeof limited.pipe
|
|
211
|
+
limited.on('error', (err: Error) => {
|
|
212
|
+
for (const dest of downstream) {
|
|
213
|
+
if (!dest.destroyed) dest.destroy(err)
|
|
214
|
+
}
|
|
215
|
+
// Discard the rest of the inbound body so the socket can be reused/closed.
|
|
216
|
+
req.unpipe(limited)
|
|
217
|
+
req.on('error', () => {})
|
|
218
|
+
req.resume()
|
|
219
|
+
})
|
|
220
|
+
req.pipe(limited)
|
|
221
|
+
ctx.body._attach(limited, ct, Number.isFinite(contentLength) ? contentLength : undefined)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function writeResponse(ctx: IngeniumContext, res: ServerResponse): void {
|
|
225
|
+
const body = ctx._body
|
|
226
|
+
const headers = ctx._headers
|
|
227
|
+
|
|
228
|
+
// Compute content-length where we know it. Mutating ctx._headers is safe
|
|
229
|
+
// because the context is being released to the pool right after this call.
|
|
230
|
+
switch (body.kind) {
|
|
231
|
+
case 'string':
|
|
232
|
+
if (headers['content-length'] === undefined) {
|
|
233
|
+
headers['content-length'] = String(Buffer.byteLength(body.data))
|
|
234
|
+
}
|
|
235
|
+
break
|
|
236
|
+
case 'buffer':
|
|
237
|
+
if (headers['content-length'] === undefined) {
|
|
238
|
+
headers['content-length'] = String(body.data.length)
|
|
239
|
+
}
|
|
240
|
+
break
|
|
241
|
+
case 'none':
|
|
242
|
+
case 'stream':
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Single writeHead call instead of `statusCode = ...; setHeader × N`.
|
|
247
|
+
// node:http has a fast path that flushes status line + headers in one
|
|
248
|
+
// serialization pass — measurably faster than the per-header setHeader
|
|
249
|
+
// sequence on hot endpoints.
|
|
250
|
+
if (body.kind === 'stream') {
|
|
251
|
+
res.writeHead(ctx._statusCode, headers)
|
|
252
|
+
body.data.pipe(res)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
res.writeHead(ctx._statusCode, headers)
|
|
256
|
+
if (body.kind === 'none') {
|
|
257
|
+
res.end()
|
|
258
|
+
} else {
|
|
259
|
+
res.end(body.data)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful shutdown helper. Wires POSIX signal handlers to drain a
|
|
3
|
+
* {@link ListeningServer}, run a user cleanup hook, then exit.
|
|
4
|
+
*
|
|
5
|
+
* Most production deployments (Kubernetes, systemd, PM2, ECS, Fly, …) send
|
|
6
|
+
* SIGTERM when they want a process to stop. By default Node simply dies on
|
|
7
|
+
* SIGTERM, which kills in-flight requests and leaves keep-alive sockets
|
|
8
|
+
* dangling. Calling {@link gracefulShutdown} after `app.listen()` opts the
|
|
9
|
+
* process into a clean drain instead.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ListeningServer } from './types.ts'
|
|
13
|
+
|
|
14
|
+
/** Options for {@link gracefulShutdown}. */
|
|
15
|
+
export interface ShutdownOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Maximum time (ms) to wait for sockets to drain before they are forcibly
|
|
18
|
+
* destroyed. Defaults to `10_000` (10s) — matches Kubernetes' default
|
|
19
|
+
* `terminationGracePeriodSeconds` headroom.
|
|
20
|
+
*/
|
|
21
|
+
gracefulTimeoutMs?: number
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Signals to listen for. Defaults to `['SIGTERM', 'SIGINT']`.
|
|
25
|
+
*/
|
|
26
|
+
signals?: NodeJS.Signals[]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* User cleanup hook — runs AFTER the server stops accepting new
|
|
30
|
+
* connections but BEFORE the process exits. Use for closing DB pools,
|
|
31
|
+
* flushing logs, etc. Awaited; throwing exits with code 1.
|
|
32
|
+
*/
|
|
33
|
+
onShutdown?: () => void | Promise<void>
|
|
34
|
+
|
|
35
|
+
/** Logger used to announce shutdown lifecycle events. Defaults to `console.log`. */
|
|
36
|
+
logger?: (msg: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wire signal handlers that gracefully shut down `server` on SIGTERM/SIGINT
|
|
41
|
+
* (or whichever signals you pass). Returns an unsubscribe function that
|
|
42
|
+
* removes the listeners — mostly useful for tests.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const server = await app.listen(3000)
|
|
46
|
+
* gracefulShutdown(server, { onShutdown: async () => db.close() })
|
|
47
|
+
*/
|
|
48
|
+
export function gracefulShutdown(
|
|
49
|
+
server: ListeningServer,
|
|
50
|
+
opts: ShutdownOptions = {},
|
|
51
|
+
): () => void {
|
|
52
|
+
const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? 10_000
|
|
53
|
+
const signals: NodeJS.Signals[] = opts.signals ?? ['SIGTERM', 'SIGINT']
|
|
54
|
+
const log = opts.logger ?? ((msg: string) => console.log(msg))
|
|
55
|
+
|
|
56
|
+
let shuttingDown = false
|
|
57
|
+
|
|
58
|
+
const handler = (signal: NodeJS.Signals): void => {
|
|
59
|
+
if (shuttingDown) {
|
|
60
|
+
// Second signal during an in-progress drain → bail immediately.
|
|
61
|
+
log(`ingenium: received ${signal} during shutdown — forcing exit`)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
shuttingDown = true
|
|
66
|
+
log(`ingenium: received ${signal}, shutting down (timeout ${gracefulTimeoutMs}ms)`)
|
|
67
|
+
|
|
68
|
+
void (async () => {
|
|
69
|
+
try {
|
|
70
|
+
if (opts.onShutdown) await opts.onShutdown()
|
|
71
|
+
await server.close({ gracefulTimeoutMs })
|
|
72
|
+
log('ingenium: shutdown complete')
|
|
73
|
+
process.exit(0)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log(`ingenium: shutdown failed: ${(err as Error)?.message ?? String(err)}`)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
})()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const signal of signals) process.on(signal, handler)
|
|
82
|
+
|
|
83
|
+
return (): void => {
|
|
84
|
+
for (const signal of signals) process.off(signal, handler)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
2
|
+
|
|
3
|
+
/** A function the framework hands to the transport — call it per request. */
|
|
4
|
+
export type TransportDispatch = (ctx: IngeniumContext) => Promise<void>
|
|
5
|
+
|
|
6
|
+
/** A function the transport calls to acquire a context from the pool. */
|
|
7
|
+
export type TransportAcquire = () => IngeniumContext
|
|
8
|
+
|
|
9
|
+
/** A function the transport calls to release a context back to the pool. */
|
|
10
|
+
export type TransportRelease = (ctx: IngeniumContext) => void
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The hooks a transport uses to interact with the framework. The transport
|
|
14
|
+
* owns the request/response objects from its underlying server (node:http,
|
|
15
|
+
* Bun.serve, etc.), populates a `IngeniumContext` from each request, awaits the
|
|
16
|
+
* `dispatch` callback, then writes the context's response state to the wire.
|
|
17
|
+
*/
|
|
18
|
+
export interface TransportHooks {
|
|
19
|
+
acquire: TransportAcquire
|
|
20
|
+
release: TransportRelease
|
|
21
|
+
dispatch: TransportDispatch
|
|
22
|
+
/**
|
|
23
|
+
* Hard ceiling (bytes) on the total request body. Adapters SHOULD wrap the
|
|
24
|
+
* inbound body stream in `createByteLimit(maxRequestBytes)` before handing
|
|
25
|
+
* it to `ctx.body._attach(...)`, AND reject with a 413 immediately when
|
|
26
|
+
* the request advertises a `Content-Length` greater than this value (no
|
|
27
|
+
* need to read the body). `Number.POSITIVE_INFINITY` disables the cap.
|
|
28
|
+
*
|
|
29
|
+
* Optional for backward compatibility with adapters / test fixtures that
|
|
30
|
+
* predate this hook. The framework's `app.listen()` always populates the
|
|
31
|
+
* field (default 2 MiB); consumers that read it should treat `undefined`
|
|
32
|
+
* as "no cap" (`Number.POSITIVE_INFINITY`).
|
|
33
|
+
*/
|
|
34
|
+
maxRequestBytes?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options accepted by {@link ListeningServer.close}. */
|
|
38
|
+
export interface CloseOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Maximum time (ms) to wait for keep-alive sockets to drain naturally
|
|
41
|
+
* before they are forcibly destroyed. When omitted (or undefined), no
|
|
42
|
+
* force-close occurs and `close()` waits indefinitely for sockets to
|
|
43
|
+
* finish — this matches the historical Node `server.close()` behavior.
|
|
44
|
+
*/
|
|
45
|
+
gracefulTimeoutMs?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A transport-agnostic listening server handle. */
|
|
49
|
+
export interface ListeningServer {
|
|
50
|
+
/** Bound port (resolved if `port: 0` was passed). */
|
|
51
|
+
port: number
|
|
52
|
+
/** The bound host. */
|
|
53
|
+
host: string
|
|
54
|
+
/**
|
|
55
|
+
* Stop accepting new connections; resolves when in-flight requests
|
|
56
|
+
* finish. If `gracefulTimeoutMs` is provided, idle keep-alive sockets
|
|
57
|
+
* still open after that many milliseconds are forcibly destroyed.
|
|
58
|
+
*/
|
|
59
|
+
close(opts?: CloseOptions): Promise<void>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A transport binds the Ingenium dispatch loop to a concrete server
|
|
64
|
+
* runtime (Node's `node:http`, Bun.serve, etc).
|
|
65
|
+
*/
|
|
66
|
+
export interface Transport {
|
|
67
|
+
/** Wire up the transport with framework-side hooks. Called once by `app.listen()`. */
|
|
68
|
+
attach(hooks: TransportHooks): void
|
|
69
|
+
|
|
70
|
+
/** Bind to a port and start accepting requests. */
|
|
71
|
+
listen(port: number, host?: string): Promise<ListeningServer>
|
|
72
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `safeJsonStringify(value, opts?)` — a lenient `JSON.stringify` that never
|
|
3
|
+
* throws on circular references or `BigInt` values.
|
|
4
|
+
*
|
|
5
|
+
* Behavior:
|
|
6
|
+
* - Circular references → replaced with the string `'[Circular]'`.
|
|
7
|
+
* - `BigInt` values → serialized as a JSON string (e.g. `1n` → `"1"`).
|
|
8
|
+
* This preserves precision and is reversible by the caller; if you need a
|
|
9
|
+
* different convention, pass your own `replacer`.
|
|
10
|
+
* - Symbol values → omitted (matches `JSON.stringify` default).
|
|
11
|
+
* - Functions → omitted (matches `JSON.stringify` default).
|
|
12
|
+
*
|
|
13
|
+
* Intended for opt-in use by callers who want lenient behavior — the
|
|
14
|
+
* default `ctx.json()` path remains strict and surfaces a
|
|
15
|
+
* `IngeniumUnserializableError` so the bug is visible.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { safeJsonStringify } from 'ingenium'
|
|
19
|
+
* ctx.send(safeJsonStringify(value), 200)
|
|
20
|
+
* ctx.set('content-type', 'application/json; charset=utf-8')
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Options for `safeJsonStringify`. */
|
|
24
|
+
export interface SafeJsonStringifyOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Pass-through to `JSON.stringify`'s third argument — number of spaces or
|
|
27
|
+
* indent string for pretty-printing. Defaults to no indentation.
|
|
28
|
+
*/
|
|
29
|
+
space?: string | number
|
|
30
|
+
/**
|
|
31
|
+
* Optional user replacer applied AFTER the cycle/BigInt sanitization. If
|
|
32
|
+
* provided, behaves like `JSON.stringify`'s second argument.
|
|
33
|
+
*/
|
|
34
|
+
replacer?: (key: string, value: unknown) => unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stringify `value` without throwing on circular structures or `BigInt`s.
|
|
39
|
+
* See module doc for the exact substitution rules.
|
|
40
|
+
*/
|
|
41
|
+
export function safeJsonStringify(
|
|
42
|
+
value: unknown,
|
|
43
|
+
opts: SafeJsonStringifyOptions = {},
|
|
44
|
+
): string {
|
|
45
|
+
const seen = new WeakSet<object>()
|
|
46
|
+
const userReplacer = opts.replacer
|
|
47
|
+
|
|
48
|
+
const replacer = (key: string, val: unknown): unknown => {
|
|
49
|
+
let v: unknown = val
|
|
50
|
+
if (typeof v === 'bigint') {
|
|
51
|
+
// Preserve precision by emitting as a JSON string.
|
|
52
|
+
v = v.toString()
|
|
53
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
54
|
+
if (seen.has(v as object)) return '[Circular]'
|
|
55
|
+
seen.add(v as object)
|
|
56
|
+
}
|
|
57
|
+
if (userReplacer) v = userReplacer(key, v)
|
|
58
|
+
return v
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `JSON.stringify` returns `undefined` for top-level `undefined`,
|
|
62
|
+
// functions, and symbols — normalize to the literal string 'undefined'
|
|
63
|
+
// so the return type contract (`string`) holds.
|
|
64
|
+
const out = JSON.stringify(value, replacer, opts.space)
|
|
65
|
+
return out === undefined ? 'undefined' : out
|
|
66
|
+
}
|
package/src/ws/index.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket adapter for Ingenium (optional `ws` peer dependency).
|
|
3
|
+
*
|
|
4
|
+
* # Usage
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { ingenium } from 'ingenium'
|
|
7
|
+
* import { enableWebSockets } from 'ingenium/ws'
|
|
8
|
+
*
|
|
9
|
+
* const app = ingenium()
|
|
10
|
+
* enableWebSockets(app)
|
|
11
|
+
* app.ws('/echo', (sock) => {
|
|
12
|
+
* sock.on('message', (m) => sock.send(m))
|
|
13
|
+
* })
|
|
14
|
+
* await app.listen(3000)
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* # Why a monkey-patch?
|
|
18
|
+
* `enableWebSockets(app)` augments the app instance with `app.ws()` and
|
|
19
|
+
* wraps `app.listen()` so the registrar gets attached to the underlying
|
|
20
|
+
* `http.Server` once it's bound. We chose this over extending `IngeniumApp` to
|
|
21
|
+
* avoid pulling `./ws/middleware.ts` into the core import graph (which would
|
|
22
|
+
* create a soft dep on `ws` types from every `app.ts` consumer). This is a
|
|
23
|
+
* known pattern in WS-extending frameworks (e.g. `express-ws`).
|
|
24
|
+
*
|
|
25
|
+
* The trade-off: TypeScript can't statically see `app.ws` unless the
|
|
26
|
+
* augmentation below is loaded. Importing this module both registers the
|
|
27
|
+
* runtime patch AND adds the type augmentation to the global `IngeniumApp`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { IngeniumApp } from '../app.ts'
|
|
31
|
+
import type { ListeningServer, Transport } from '../transport/types.ts'
|
|
32
|
+
import { createWebSocketRegistrar, peerHasWs } from './middleware.ts'
|
|
33
|
+
import { WsNodeAdapter } from './ws-node-adapter.ts'
|
|
34
|
+
import type {
|
|
35
|
+
WebSocketHandler,
|
|
36
|
+
WebSocketHandlerOptions,
|
|
37
|
+
WsIntegrator,
|
|
38
|
+
WsRegistrar,
|
|
39
|
+
} from './types.ts'
|
|
40
|
+
|
|
41
|
+
export type {
|
|
42
|
+
WebSocketHandler,
|
|
43
|
+
WebSocketHandlerOptions,
|
|
44
|
+
WsIntegrator,
|
|
45
|
+
WsRegistrar,
|
|
46
|
+
WebSocket,
|
|
47
|
+
} from './types.ts'
|
|
48
|
+
export { createWebSocketRegistrar, peerHasWs } from './middleware.ts'
|
|
49
|
+
|
|
50
|
+
// ───── Type augmentation ────────────────────────────────────────────────────
|
|
51
|
+
// Declared on IngeniumApp so `app.ws(...)` and `app.upgradeWith(...)` typecheck
|
|
52
|
+
// for any consumer that imports from 'ingenium/ws'.
|
|
53
|
+
declare module '../app.ts' {
|
|
54
|
+
interface IngeniumApp {
|
|
55
|
+
ws(path: string, handler: WebSocketHandler, options?: WebSocketHandlerOptions): IngeniumApp
|
|
56
|
+
upgradeWith(integrator: WsIntegrator): IngeniumApp
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Per-app state attached by `enableWebSockets`. Internal. */
|
|
61
|
+
interface WsAppState {
|
|
62
|
+
registrar: WsRegistrar
|
|
63
|
+
integrators: WsIntegrator[]
|
|
64
|
+
enabled: true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const APP_STATE: WeakMap<IngeniumApp, WsAppState> = new WeakMap()
|
|
68
|
+
|
|
69
|
+
/** Options for `enableWebSockets`. Reserved for future use. */
|
|
70
|
+
export interface EnableWebSocketsOptions {
|
|
71
|
+
/**
|
|
72
|
+
* When `true`, eagerly probes for the `ws` peer dependency at install
|
|
73
|
+
* time and prints a warning if it is missing. Default: `false` (we wait
|
|
74
|
+
* until the first upgrade attempt).
|
|
75
|
+
*/
|
|
76
|
+
warnOnMissingPeer?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Augment a `IngeniumApp` with WebSocket support. Idempotent — calling more than
|
|
81
|
+
* once on the same app is a no-op.
|
|
82
|
+
*/
|
|
83
|
+
export function enableWebSockets(app: IngeniumApp, opts: EnableWebSocketsOptions = {}): void {
|
|
84
|
+
if (APP_STATE.has(app)) return
|
|
85
|
+
|
|
86
|
+
const registrar = createWebSocketRegistrar()
|
|
87
|
+
const state: WsAppState = { registrar, integrators: [], enabled: true }
|
|
88
|
+
APP_STATE.set(app, state)
|
|
89
|
+
|
|
90
|
+
if (opts.warnOnMissingPeer) {
|
|
91
|
+
void peerHasWs().then((ok) => {
|
|
92
|
+
if (!ok) {
|
|
93
|
+
process.emitWarning(
|
|
94
|
+
'ingenium: enableWebSockets() called but `ws` is not installed. ' +
|
|
95
|
+
'Install it with `npm install ws`.',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Attach the new methods. We assign with a cast because the augmentation
|
|
102
|
+
// above only exists at the type layer.
|
|
103
|
+
;(app as unknown as { ws: IngeniumApp['ws'] }).ws = function (
|
|
104
|
+
path: string,
|
|
105
|
+
handler: WebSocketHandler,
|
|
106
|
+
options?: WebSocketHandlerOptions,
|
|
107
|
+
): IngeniumApp {
|
|
108
|
+
state.registrar.add(path, handler, options)
|
|
109
|
+
return app
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
;(app as unknown as { upgradeWith: IngeniumApp['upgradeWith'] }).upgradeWith = function (
|
|
113
|
+
integrator: WsIntegrator,
|
|
114
|
+
): IngeniumApp {
|
|
115
|
+
state.integrators.push(integrator)
|
|
116
|
+
return app
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Swap in a WebSocket-aware Node transport. We do this via bracket-access
|
|
120
|
+
// because `IngeniumApp#transport` is `private` (TypeScript-only — `private`
|
|
121
|
+
// doesn't actually hide the field at runtime). If the user injected a
|
|
122
|
+
// custom transport via `IngeniumAppOptions.transport`, we leave it alone and
|
|
123
|
+
// log a warning — they're responsible for calling `registrar.attach()`
|
|
124
|
+
// themselves via `app.upgradeWith(...)`.
|
|
125
|
+
const appAny = app as unknown as { transport: Transport }
|
|
126
|
+
const existing = appAny.transport
|
|
127
|
+
const isDefault = existing.constructor?.name === 'NodeAdapter'
|
|
128
|
+
|
|
129
|
+
if (isDefault) {
|
|
130
|
+
appAny.transport = new WsNodeAdapter((httpServer) => {
|
|
131
|
+
state.registrar.attach(httpServer)
|
|
132
|
+
for (const integrator of state.integrators) integrator(httpServer)
|
|
133
|
+
})
|
|
134
|
+
} else {
|
|
135
|
+
process.emitWarning(
|
|
136
|
+
'ingenium.ws: a custom Transport is in use — WebSockets will only be wired ' +
|
|
137
|
+
'if you call `app.upgradeWith((httpServer) => registrar.attach(httpServer))` from your transport.',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Wrap close() of the eventual ListeningServer so the registrar tears
|
|
142
|
+
// down its WebSocketServers first — otherwise `server.close()` hangs
|
|
143
|
+
// forever waiting on the open WS sockets.
|
|
144
|
+
const originalListen = app.listen.bind(app)
|
|
145
|
+
;(app as unknown as { listen: IngeniumApp['listen'] }).listen = async function (
|
|
146
|
+
port: number,
|
|
147
|
+
host?: string,
|
|
148
|
+
): Promise<ListeningServer> {
|
|
149
|
+
const server = await originalListen(port, host)
|
|
150
|
+
const originalClose = server.close.bind(server)
|
|
151
|
+
return {
|
|
152
|
+
port: server.port,
|
|
153
|
+
host: server.host,
|
|
154
|
+
close: async (closeOpts) => {
|
|
155
|
+
await state.registrar.close()
|
|
156
|
+
await originalClose(closeOpts)
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Re-export the WS-aware Node transport for advanced users who want to
|
|
163
|
+
// construct it manually (e.g. when wiring a custom Transport stack).
|
|
164
|
+
export { WsNodeAdapter } from './ws-node-adapter.ts'
|