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,178 @@
1
+ /**
2
+ * WebSocket registrar — the small piece of state that holds path → handler
3
+ * mappings and knows how to wire `'upgrade'` on a Node `http.Server`.
4
+ *
5
+ * Design: the `ws` package is loaded lazily via dynamic `import('ws')` so
6
+ * apps that never use WebSockets pay no cost (no module load, no peer-dep
7
+ * requirement). The first call to `attach()` resolves the import.
8
+ */
9
+
10
+ import type { Server as HttpServer, IncomingMessage } from 'node:http'
11
+ import type { Socket } from 'node:net'
12
+ import { IngeniumContext } from '../context/context.ts'
13
+ import type { HttpMethod } from '../router/types.ts'
14
+ import type {
15
+ WebSocketHandler,
16
+ WebSocketHandlerOptions,
17
+ WsRegistrar,
18
+ WsRoute,
19
+ } from './types.ts'
20
+
21
+ /**
22
+ * Attempt to detect whether `ws` is installed. Used by the test suite to
23
+ * `describe.skipIf` the WS suite when the optional peer dep is missing.
24
+ */
25
+ export async function peerHasWs(): Promise<boolean> {
26
+ try {
27
+ await import('ws')
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Build a registrar bound to an app. The registrar is intentionally
36
+ * decoupled from `IngeniumApp` — the app calls `add()` from `app.ws()`, and
37
+ * `enableWebSockets()` (or the app's `listen()` integration) calls `attach()`
38
+ * once the underlying `http.Server` is created.
39
+ */
40
+ export function createWebSocketRegistrar(): WsRegistrar {
41
+ const routes: Map<string, WsRoute> = new Map()
42
+ let attachedServer: HttpServer | null = null
43
+ // The `ws` `WebSocketServer` instance, lazy-initialized on first upgrade.
44
+ // We use one server per registered path so per-handler options apply.
45
+ const wssByPath: Map<string, unknown> = new Map()
46
+ // We keep a reference to the `ws` module after the first dynamic import.
47
+ let wsModule: typeof import('ws') | null = null
48
+
49
+ // Single shared upgrade listener — installed exactly once.
50
+ let upgradeListener: ((req: IncomingMessage, socket: Socket, head: Buffer) => void) | null = null
51
+
52
+ function add(path: string, handler: WebSocketHandler, options: WebSocketHandlerOptions = {}): void {
53
+ if (routes.has(path)) {
54
+ throw new Error(`ingenium.ws: path "${path}" already has a WebSocket handler`)
55
+ }
56
+ routes.set(path, { path, handler, options })
57
+ }
58
+
59
+ function attach(httpServer: HttpServer): void {
60
+ if (attachedServer === httpServer) return // idempotent
61
+ if (attachedServer !== null) {
62
+ throw new Error('ingenium.ws: registrar already attached to a different http.Server')
63
+ }
64
+ attachedServer = httpServer
65
+
66
+ upgradeListener = (req, socket, head) => {
67
+ // Parse the path from the upgrade request URL. We only look at the
68
+ // pathname — query strings are exposed via `ctx.rawQuery` for handlers
69
+ // that care.
70
+ const url = req.url ?? '/'
71
+ const qIdx = url.indexOf('?')
72
+ const path = qIdx >= 0 ? url.slice(0, qIdx) : url
73
+
74
+ const route = routes.get(path)
75
+ if (!route) {
76
+ // No handler for this path — close the socket cleanly. The
77
+ // 404-equivalent for WebSockets is just refusing the upgrade.
78
+ socket.destroy()
79
+ return
80
+ }
81
+
82
+ // Lazy-load `ws`. On the first upgrade, dynamically import. If `ws`
83
+ // isn't installed, give a clear actionable error and tear the socket
84
+ // down — apps that wired `app.ws(...)` without installing the peer
85
+ // dep should learn about it the moment a client tries to connect.
86
+ void (async () => {
87
+ try {
88
+ if (wsModule === null) wsModule = await import('ws')
89
+ } catch (err) {
90
+ process.emitWarning(
91
+ 'ingenium: app.ws() was called but the `ws` package is not installed. ' +
92
+ 'Install it with `npm install ws` (and `@types/ws` for TypeScript).',
93
+ )
94
+ socket.destroy()
95
+ return
96
+ }
97
+
98
+ let wss = wssByPath.get(route.path) as
99
+ | InstanceType<typeof import('ws').WebSocketServer>
100
+ | undefined
101
+ if (!wss) {
102
+ wss = new wsModule.WebSocketServer({
103
+ noServer: true,
104
+ maxPayload: route.options.maxPayload,
105
+ perMessageDeflate: route.options.perMessageDeflate ?? false,
106
+ })
107
+ wssByPath.set(route.path, wss)
108
+ }
109
+
110
+ wss.handleUpgrade(req, socket, head, (ws) => {
111
+ const ctx = buildMinimalContext(req, path)
112
+ try {
113
+ const ret = route.handler(ws, ctx)
114
+ if (ret && typeof (ret as Promise<unknown>).then === 'function') {
115
+ ;(ret as Promise<unknown>).catch((err) => {
116
+ process.emitWarning(
117
+ `ingenium.ws: handler for ${path} rejected: ${(err as Error)?.message ?? String(err)}`,
118
+ )
119
+ try { ws.close(1011, 'handler error') } catch { /* socket may already be dead */ }
120
+ })
121
+ }
122
+ } catch (err) {
123
+ process.emitWarning(
124
+ `ingenium.ws: handler for ${path} threw: ${(err as Error)?.message ?? String(err)}`,
125
+ )
126
+ try { ws.close(1011, 'handler error') } catch { /* ignore */ }
127
+ }
128
+ })
129
+ })()
130
+ }
131
+
132
+ httpServer.on('upgrade', upgradeListener)
133
+ }
134
+
135
+ async function close(): Promise<void> {
136
+ // Detach the upgrade listener so a re-listen on the same server doesn't
137
+ // double-up handlers.
138
+ if (attachedServer && upgradeListener) {
139
+ attachedServer.off('upgrade', upgradeListener)
140
+ }
141
+ upgradeListener = null
142
+ attachedServer = null
143
+
144
+ // Close every per-path WebSocketServer. `ws.WebSocketServer.close(cb)`
145
+ // fires once all clients have disconnected; we await each in parallel.
146
+ const closes: Promise<void>[] = []
147
+ for (const wss of wssByPath.values()) {
148
+ const server = wss as InstanceType<typeof import('ws').WebSocketServer>
149
+ // Forcibly terminate any still-open clients so close() resolves
150
+ // promptly during test teardown.
151
+ for (const client of server.clients) {
152
+ try { client.terminate() } catch { /* ignore */ }
153
+ }
154
+ closes.push(new Promise<void>((resolve) => server.close(() => resolve())))
155
+ }
156
+ wssByPath.clear()
157
+ await Promise.all(closes)
158
+ }
159
+
160
+ return { add, attach, close }
161
+ }
162
+
163
+ /**
164
+ * Build a minimal `IngeniumContext` for a WebSocket handler. We don't run the
165
+ * full request pipeline (no middleware, no decorators) because the upgrade
166
+ * has already taken place — the handler owns the socket from here.
167
+ */
168
+ function buildMinimalContext(req: IncomingMessage, path: string): IngeniumContext {
169
+ const ctx = new IngeniumContext()
170
+ ctx.method = (req.method ?? 'GET') as HttpMethod
171
+ ctx.url = req.url ?? '/'
172
+ ctx.path = path
173
+ const url = ctx.url
174
+ const qIdx = url.indexOf('?')
175
+ ctx.rawQuery = qIdx >= 0 ? url.slice(qIdx + 1) : ''
176
+ ctx.headers = req.headers
177
+ return ctx
178
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public types for the optional WebSocket adapter. The `ws` package is an
3
+ * OPTIONAL peer dependency — these types are erased at runtime, so this file
4
+ * compiles even when `ws` is not installed.
5
+ */
6
+
7
+ import type { IncomingMessage } from 'node:http'
8
+ // Type-only — TypeScript erases this; safe even without `ws` installed.
9
+ import type { WebSocket as WsWebSocket } from 'ws'
10
+ import type { IngeniumContext } from '../context/context.ts'
11
+
12
+ /** Re-export the underlying `ws` `WebSocket` type for convenience. */
13
+ export type WebSocket = WsWebSocket
14
+
15
+ /**
16
+ * Handler invoked when a client successfully upgrades to a WebSocket.
17
+ *
18
+ * `socket` is the `ws.WebSocket` instance. `ctx` is a minimal `IngeniumContext`
19
+ * populated from the upgrade `IncomingMessage` — the body / response writers
20
+ * are not meaningful for WS handlers (the upgrade has already happened).
21
+ */
22
+ export type WebSocketHandler = (socket: WsWebSocket, ctx: IngeniumContext) => void | Promise<void>
23
+
24
+ /** Per-handler options forwarded to `WebSocketServer({ noServer: true, ... })`. */
25
+ export interface WebSocketHandlerOptions {
26
+ /** Max payload size (bytes) for incoming frames. */
27
+ maxPayload?: number
28
+ /** Enable permessage-deflate. Defaults to false (matches `ws` default). */
29
+ perMessageDeflate?: boolean
30
+ }
31
+
32
+ /** Internal: a registered handler entry. */
33
+ export interface WsRoute {
34
+ path: string
35
+ handler: WebSocketHandler
36
+ options: WebSocketHandlerOptions
37
+ }
38
+
39
+ /** Bag passed to integrators (advanced). */
40
+ export interface WsIntegrator {
41
+ (httpServer: import('node:http').Server): void
42
+ }
43
+
44
+ /** Shape of the per-app registrar exposed to `enableWebSockets`. */
45
+ export interface WsRegistrar {
46
+ add(path: string, handler: WebSocketHandler, options?: WebSocketHandlerOptions): void
47
+ attach(httpServer: import('node:http').Server): void
48
+ close(): Promise<void>
49
+ }
50
+
51
+ /** Re-export so consumers can build minimal contexts in tests. */
52
+ export type { IncomingMessage }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * WebSocket-aware variant of `NodeAdapter`. Mirrors the behavior of
3
+ * `transport/node.ts` (request handling, socket tracking, graceful close)
4
+ * but exposes the underlying `http.Server` via an `onServerReady` callback
5
+ * so the WS registrar can `.on('upgrade', …)` it.
6
+ *
7
+ * We did not modify the core `NodeAdapter` because the core has no awareness
8
+ * of WebSockets; this adapter is opt-in via `enableWebSockets()`.
9
+ */
10
+
11
+ import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http'
12
+ import type { Socket } from 'node:net'
13
+ import { Buffer } from 'node:buffer'
14
+ import type { IngeniumContext } from '../context/context.ts'
15
+ import type { HttpMethod } from '../router/types.ts'
16
+ import type {
17
+ CloseOptions,
18
+ ListeningServer,
19
+ Transport,
20
+ TransportHooks,
21
+ } from '../transport/types.ts'
22
+
23
+ export type OnServerReady = (httpServer: HttpServer) => void
24
+
25
+ export class WsNodeAdapter implements Transport {
26
+ private hooks: TransportHooks | null = null
27
+ private readonly onServerReady: OnServerReady
28
+
29
+ constructor(onServerReady: OnServerReady) {
30
+ this.onServerReady = onServerReady
31
+ }
32
+
33
+ attach(hooks: TransportHooks): void {
34
+ this.hooks = hooks
35
+ }
36
+
37
+ async listen(port: number, host = '127.0.0.1'): Promise<ListeningServer> {
38
+ if (!this.hooks) throw new Error('WsNodeAdapter.listen() called before attach()')
39
+ const hooks = this.hooks
40
+
41
+ const server = createServer((req, res) => {
42
+ handleRequest(req, res, hooks).catch((err) => {
43
+ if (!res.headersSent) {
44
+ res.statusCode = 500
45
+ res.setHeader('content-type', 'application/json; charset=utf-8')
46
+ res.end(JSON.stringify({ error: 'Internal Server Error', code: 'INTERNAL_ERROR' }))
47
+ } else {
48
+ res.end()
49
+ }
50
+ process.emitWarning(`ingenium: dispatch leaked: ${(err as Error).message ?? String(err)}`)
51
+ })
52
+ })
53
+
54
+ // Hand the http.Server to the WS registrar BEFORE listen() resolves —
55
+ // this guarantees upgrade listeners are wired before any client can
56
+ // connect.
57
+ this.onServerReady(server)
58
+
59
+ const sockets = new Set<Socket>()
60
+ server.on('connection', (socket) => {
61
+ sockets.add(socket)
62
+ socket.on('close', () => sockets.delete(socket))
63
+ })
64
+
65
+ return new Promise<ListeningServer>((resolve, reject) => {
66
+ server.once('error', reject)
67
+ server.listen(port, host, () => {
68
+ const addr = server.address()
69
+ if (!addr || typeof addr === 'string') {
70
+ reject(new Error('Failed to determine bound address'))
71
+ return
72
+ }
73
+ resolve({
74
+ port: addr.port,
75
+ host: addr.address,
76
+ close: (opts?: CloseOptions) =>
77
+ new Promise<void>((res, rej) => {
78
+ let settled = false
79
+ let timer: NodeJS.Timeout | null = null
80
+
81
+ server.close((err) => {
82
+ if (timer) clearTimeout(timer)
83
+ if (settled) return
84
+ settled = true
85
+ err ? rej(err) : res()
86
+ })
87
+
88
+ const timeoutMs = opts?.gracefulTimeoutMs
89
+ if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs)) {
90
+ timer = setTimeout(() => {
91
+ for (const socket of sockets) socket.destroy()
92
+ }, Math.max(0, timeoutMs))
93
+ if (typeof timer.unref === 'function') timer.unref()
94
+ }
95
+ }),
96
+ })
97
+ })
98
+ })
99
+ }
100
+ }
101
+
102
+ async function handleRequest(req: IncomingMessage, res: ServerResponse, hooks: TransportHooks): Promise<void> {
103
+ const ctx = hooks.acquire()
104
+ try {
105
+ populateContext(ctx, req)
106
+ await hooks.dispatch(ctx)
107
+ writeResponse(ctx, res)
108
+ } finally {
109
+ hooks.release(ctx)
110
+ }
111
+ }
112
+
113
+ function populateContext(ctx: IngeniumContext, req: IncomingMessage): void {
114
+ ctx.method = (req.method ?? 'GET') as HttpMethod
115
+ ctx.url = req.url ?? '/'
116
+ const url = ctx.url
117
+ const qIdx = url.indexOf('?')
118
+ if (qIdx >= 0) {
119
+ ctx.path = url.slice(0, qIdx)
120
+ ctx.rawQuery = url.slice(qIdx + 1)
121
+ } else {
122
+ ctx.path = url
123
+ ctx.rawQuery = ''
124
+ }
125
+ ctx.headers = req.headers
126
+
127
+ const cl = req.headers['content-length']
128
+ const contentLength = cl ? Number(cl) : undefined
129
+ const ct = req.headers['content-type']
130
+ ctx.body._attach(req, ct, Number.isFinite(contentLength) ? contentLength : undefined)
131
+ }
132
+
133
+ function writeResponse(ctx: IngeniumContext, res: ServerResponse): void {
134
+ res.statusCode = ctx._statusCode
135
+
136
+ for (const name in ctx._headers) {
137
+ const value = ctx._headers[name]
138
+ if (value !== undefined) res.setHeader(name, value)
139
+ }
140
+
141
+ const body = ctx._body
142
+ switch (body.kind) {
143
+ case 'none':
144
+ res.end()
145
+ break
146
+ case 'string':
147
+ if (!res.hasHeader('content-length')) {
148
+ res.setHeader('content-length', Buffer.byteLength(body.data))
149
+ }
150
+ res.end(body.data)
151
+ break
152
+ case 'buffer':
153
+ if (!res.hasHeader('content-length')) {
154
+ res.setHeader('content-length', body.data.length)
155
+ }
156
+ res.end(body.data)
157
+ break
158
+ case 'stream':
159
+ body.data.pipe(res)
160
+ break
161
+ }
162
+ }