ingenium 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,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
+ }
@@ -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'