@nifrajs/node 0.1.0-alpha.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/src/index.ts ADDED
@@ -0,0 +1,954 @@
1
+ /**
2
+ * Run a nifra app (or any Web-`fetch` handler) on Node's `http` server.
3
+ *
4
+ * import { serve } from "@nifrajs/node"
5
+ * import { server } from "@nifrajs/core"
6
+ * const app = server().get("/", () => ({ ok: true }))
7
+ * serve(app, { port: 3000 })
8
+ *
9
+ * nifra's lifecycle is `app.fetch(Request): Response | Promise<Response>` — pure Web Standards —
10
+ * so this adapter just bridges Node's stream-based `(req, res)` to/from Web
11
+ * `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
12
+ */
13
+ import { open } from "node:fs/promises"
14
+ import {
15
+ createServer,
16
+ type IncomingHttpHeaders,
17
+ type IncomingMessage,
18
+ type ServerResponse,
19
+ } from "node:http"
20
+ import { extname, resolve, sep } from "node:path"
21
+ import { type Duplex, Readable } from "node:stream"
22
+ import { fileURLToPath } from "node:url"
23
+
24
+ /** Anything exposing a Web `fetch` handler — a nifra `app`, for instance. */
25
+ export interface FetchHandler {
26
+ fetch(request: Request): Response | Promise<Response>
27
+ /** Nifra apps also expose this WS-upgrade seam; present → this adapter serves `app.ws()` routes via
28
+ * the optional `ws` package (lazy-imported on the first upgrade). Absent (a plain `{ fetch }`
29
+ * handler) → HTTP only, and an upgrade request gets a 404. */
30
+ resolveWebSocketUpgrade?(request: Request): WsUpgradeOutcome | Promise<WsUpgradeOutcome>
31
+ }
32
+
33
+ // --- WebSocket types: structurally mirrored from @nifrajs/core (this adapter has no @nifrajs/core
34
+ // dependency — see NodeServeOutcome above). Kept in lockstep by the WS integration test. ---
35
+
36
+ /** A received frame, normalized: text → `string`, binary → `Uint8Array`. */
37
+ type NifraWsData = string | Uint8Array
38
+
39
+ /** The portable socket a nifra WS handler sees (mirror of core's `NifraWebSocket`). */
40
+ interface NifraWs {
41
+ send(data: string | ArrayBufferView | ArrayBuffer): void
42
+ close(code?: number, reason?: string): void
43
+ readonly readyState: number
44
+ subscribe(topic: string): void
45
+ unsubscribe(topic: string): void
46
+ data: unknown
47
+ readonly raw: unknown
48
+ }
49
+
50
+ /** Mirror of core's `TopicRegistry` surface — the app's pub/sub the adapter wires `ws.subscribe` to. */
51
+ interface WsPubSub {
52
+ subscribe(topic: string, ws: NifraWs): void
53
+ unsubscribe(topic: string, ws: NifraWs): void
54
+ unsubscribeAll(ws: NifraWs): void
55
+ }
56
+
57
+ /** A nifra WS route's lifecycle (mirror of core's `WebSocketHandler` — the post-upgrade callbacks). */
58
+ interface NifraWsHandler {
59
+ open?(ws: NifraWs): void | Promise<void>
60
+ message?(ws: NifraWs, data: NifraWsData): void | Promise<void>
61
+ close?(ws: NifraWs, code: number, reason: string): void | Promise<void>
62
+ error?(ws: NifraWs, error: unknown): void | Promise<void>
63
+ }
64
+
65
+ /** Mirror of core's `WebSocketUpgradeOutcome` — what `resolveWebSocketUpgrade` returns. */
66
+ type WsUpgradeOutcome =
67
+ | { readonly kind: "pass" }
68
+ | { readonly kind: "reject"; readonly response: Response }
69
+ | {
70
+ readonly kind: "upgrade"
71
+ readonly handler: NifraWsHandler
72
+ readonly data: unknown
73
+ readonly pubsub: WsPubSub
74
+ }
75
+
76
+ /** Structural view of the `ws` package's `WebSocket` (no `@types/ws` dependency — see `loadWsServer`). */
77
+ interface WsSocket {
78
+ send(data: string | ArrayBufferView | ArrayBuffer): void
79
+ close(code?: number, reason?: string): void
80
+ readonly readyState: number
81
+ on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): void
82
+ on(event: "close", listener: (code: number, reason: Buffer) => void): void
83
+ on(event: "error", listener: (error: Error) => void): void
84
+ }
85
+
86
+ /** Structural view of the `ws` package's `WebSocketServer` (noServer mode). */
87
+ interface WsServer {
88
+ handleUpgrade(
89
+ request: IncomingMessage,
90
+ socket: Duplex,
91
+ head: Buffer,
92
+ callback: (ws: WsSocket) => void,
93
+ ): void
94
+ }
95
+
96
+ export type RequestProtocol = "http" | "https"
97
+ export type RequestProtocolOption =
98
+ | RequestProtocol
99
+ | ((request: IncomingMessage) => RequestProtocol)
100
+ type RequestProtocolResolver = (request: IncomingMessage) => RequestProtocol
101
+
102
+ /**
103
+ * The node-direct render returned by a nifra app's `resolveNode` — structurally mirrored here so this
104
+ * adapter stays decoupled from `@nifrajs/core` (it bridges *any* handler exposing this seam, and has no
105
+ * runtime dependency on nifra). A `kind: "json"` outcome is a plain-data result we serialize straight to
106
+ * the socket; a `kind: "body"` outcome is a marked buffered response body (notably @nifrajs/web's
107
+ * non-deferred SSR HTML). Both skip undici/Web body draining. Everything else carries a `Response` we
108
+ * write the usual Web way. Kept in lockstep with `@nifrajs/core`'s `NodeServeOutcome` by the integration
109
+ * test (`serve.test.ts`), which runs a real nifra app end-to-end through this path.
110
+ */
111
+ type NodeServeOutcome =
112
+ | { readonly kind: "response"; readonly response: Response }
113
+ | {
114
+ readonly kind: "json"
115
+ readonly status: number
116
+ readonly headers: Readonly<Record<string, string>> | undefined
117
+ readonly cookies: readonly string[] | undefined
118
+ readonly body: string | null
119
+ }
120
+ | {
121
+ readonly kind: "body"
122
+ readonly status: number
123
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
124
+ readonly body: string | Uint8Array
125
+ }
126
+
127
+ interface NodeRequestSource {
128
+ readonly method: string
129
+ readonly url: string
130
+ readonly headers: Headers
131
+ header(name: string): string | null
132
+ readonly body: ReadableStream<Uint8Array> | null
133
+ arrayBuffer(): Promise<ArrayBuffer>
134
+ json(): Promise<unknown>
135
+ readonly request: Request
136
+ }
137
+
138
+ /** A `FetchHandler` that *also* exposes the node-direct fast path (every nifra app does). May resolve
139
+ * **synchronously** (a bare route + sync handler allocates no promise) — we `await` it regardless. */
140
+ interface NodeFastHandler extends FetchHandler {
141
+ resolveNode(request: Request): NodeServeOutcome | Promise<NodeServeOutcome>
142
+ resolveNodeSource(source: NodeRequestSource): NodeServeOutcome | Promise<NodeServeOutcome>
143
+ }
144
+
145
+ /**
146
+ * The `Content-Type` the host runtime's `Response.json` emits — Node's undici uses `application/json`,
147
+ * Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
148
+ * fast path is byte-for-byte identical to the `Response`-building path on whatever runtime hosts us.
149
+ */
150
+ const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json"
151
+
152
+ const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}'
153
+ const EMPTY_BUFFER = Buffer.alloc(0)
154
+ const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
155
+
156
+ /**
157
+ * Serve static files from a directory (e.g. the client build) under a URL prefix — so a self-hosted
158
+ * Node deploy doesn't need a CDN or a hand-rolled `/assets/*` handler. (On Cloudflare/Vercel the
159
+ * platform serves assets; this is for `node server.js`.)
160
+ */
161
+ export interface ServeStaticOptions {
162
+ /** Directory to read files from — an absolute path or a `file://` URL (`new URL("./assets/", import.meta.url)`). */
163
+ readonly dir: string | URL
164
+ /** URL prefix these files are served under. Default `"/assets"`. Use `"/"` to serve the whole dir. */
165
+ readonly prefix?: string
166
+ /** Emit `cache-control: public, max-age=31536000, immutable` — correct for content-hashed files. Default `true`. */
167
+ readonly immutable?: boolean
168
+ /** Extra headers merged onto every served file. */
169
+ readonly headers?: Readonly<Record<string, string>>
170
+ }
171
+
172
+ export interface ServeOptions {
173
+ readonly port: number
174
+ readonly hostname?: string
175
+ /**
176
+ * Protocol used when the adapter constructs `Request.url`.
177
+ *
178
+ * `@nifrajs/node` creates a plain Node `http` server, so the safe default is `"http"`. Deployments behind
179
+ * TLS termination can set `"https"` (or a trusted infra-aware function) so app code that reads
180
+ * `request.url` sees the public scheme. Forwarded headers are not trusted implicitly.
181
+ */
182
+ readonly protocol?: RequestProtocolOption
183
+ /**
184
+ * Install SIGTERM/SIGINT handlers that call `stop()` for a graceful drain on
185
+ * `docker stop` / Ctrl-C. Off by default — taking over process signals is opt-in,
186
+ * mirroring nifra's Bun `listen({ gracefulSignals })`.
187
+ *
188
+ * The app-level request timeout (`server({ requestTimeoutMs })` → 503) and body cap
189
+ * are *not* set here — they live inside `app.fetch`, so they already apply through
190
+ * this adapter. Slow-client protection is Node's built-in `requestTimeout` (300s) /
191
+ * `headersTimeout` (60s) defaults.
192
+ */
193
+ readonly signals?: boolean
194
+ /**
195
+ * Serve static files from disk for matching GET/HEAD requests *before* the app runs — non-matching
196
+ * requests fall through to `app.fetch` with the node-direct fast path intact (no perf regression on
197
+ * SSR/API routes). Replaces the hand-rolled `/assets/*` reader in self-hosted entries.
198
+ */
199
+ readonly static?: ServeStaticOptions
200
+ }
201
+
202
+ export interface NodeServer {
203
+ /** The bound port (resolved when `port: 0` is requested). */
204
+ readonly port: number
205
+ /**
206
+ * Stop accepting connections, let in-flight requests drain (up to `drainMs`), then
207
+ * force-close stragglers + idle keep-alives. Mirrors nifra's Bun `stop()`.
208
+ */
209
+ stop(options?: { drainMs?: number }): Promise<void>
210
+ }
211
+
212
+ const DEFAULT_DRAIN_MS = 10_000
213
+ const DRAIN_POLL_MS = 10
214
+
215
+ const STATIC_CONTENT_TYPES: Readonly<Record<string, string>> = {
216
+ ".js": "text/javascript; charset=utf-8",
217
+ ".mjs": "text/javascript; charset=utf-8",
218
+ ".css": "text/css; charset=utf-8",
219
+ ".html": "text/html; charset=utf-8",
220
+ ".json": "application/json; charset=utf-8",
221
+ ".map": "application/json; charset=utf-8",
222
+ ".svg": "image/svg+xml",
223
+ ".png": "image/png",
224
+ ".jpg": "image/jpeg",
225
+ ".jpeg": "image/jpeg",
226
+ ".gif": "image/gif",
227
+ ".webp": "image/webp",
228
+ ".avif": "image/avif",
229
+ ".ico": "image/x-icon",
230
+ ".woff": "font/woff",
231
+ ".woff2": "font/woff2",
232
+ ".ttf": "font/ttf",
233
+ ".otf": "font/otf",
234
+ ".wasm": "application/wasm",
235
+ ".txt": "text/plain; charset=utf-8",
236
+ ".webmanifest": "application/manifest+json",
237
+ }
238
+
239
+ interface StaticState {
240
+ readonly root: string
241
+ readonly prefix: string // normalized: "/assets" (no trailing slash) or "/"
242
+ readonly immutable: boolean
243
+ readonly headers: Readonly<Record<string, string>> | undefined
244
+ }
245
+
246
+ function staticStateOf(options: ServeStaticOptions): StaticState {
247
+ const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir))
248
+ const raw = options.prefix ?? "/assets"
249
+ const prefix = raw === "/" ? "/" : `/${raw.replace(/^\/+|\/+$/g, "")}`
250
+ return { root, prefix, immutable: options.immutable !== false, headers: options.headers }
251
+ }
252
+
253
+ /**
254
+ * Resolve a request URL to a file under the served root — **synchronously**, so non-matching requests
255
+ * stay on the app's sync fast path. Returns `"pass"` (let the app handle it), a rejection `Response`
256
+ * (malformed encoding / NUL / `..` traversal out of root), or a confined absolute file path to read.
257
+ */
258
+ function staticMatch(
259
+ state: StaticState,
260
+ rawUrl: string,
261
+ ): "pass" | { readonly reject: Response } | { readonly file: string } {
262
+ const query = rawUrl.indexOf("?")
263
+ const path = query === -1 ? rawUrl : rawUrl.slice(0, query)
264
+ const underPrefix =
265
+ state.prefix === "/" ? true : path === state.prefix || path.startsWith(`${state.prefix}/`)
266
+ if (!underPrefix) return "pass"
267
+ let rel: string
268
+ try {
269
+ rel = decodeURIComponent(state.prefix === "/" ? path : path.slice(state.prefix.length))
270
+ } catch {
271
+ return { reject: new Response("Bad Request", { status: 400 }) }
272
+ }
273
+ rel = rel.replace(/^\/+/, "")
274
+ if (rel === "" || rel.endsWith("/")) return "pass" // a directory request → the app decides
275
+ if (rel.includes("\0")) return { reject: new Response("Bad Request", { status: 400 }) }
276
+ const file = resolve(state.root, rel)
277
+ // Confine to the served directory — block `..` from escaping root.
278
+ if (file !== state.root && !file.startsWith(state.root + sep)) {
279
+ return { reject: new Response("Forbidden", { status: 403 }) }
280
+ }
281
+ return { file }
282
+ }
283
+
284
+ async function readStatic(
285
+ file: string,
286
+ state: StaticState,
287
+ method: string,
288
+ ): Promise<NodeServeOutcome> {
289
+ let handle: Awaited<ReturnType<typeof open>> | undefined
290
+ try {
291
+ handle = await open(file, "r")
292
+ const stat = await handle.stat()
293
+ if (!stat.isFile()) {
294
+ await handle.close()
295
+ return { kind: "response", response: new Response("Not Found", { status: 404 }) }
296
+ }
297
+ const headers: Record<string, string> = { ...state.headers }
298
+ headers["content-type"] =
299
+ STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
300
+ headers["content-length"] = String(stat.size)
301
+ if (state.immutable && headers["cache-control"] === undefined) {
302
+ headers["cache-control"] = "public, max-age=31536000, immutable"
303
+ }
304
+ if (method === "HEAD") {
305
+ await handle.close()
306
+ return { kind: "response", response: new Response(null, { headers }) }
307
+ }
308
+ const stream = handle.createReadStream()
309
+ return {
310
+ kind: "response",
311
+ response: new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, { headers }),
312
+ }
313
+ } catch {
314
+ await handle?.close().catch(() => {})
315
+ // Missing/unreadable under the static prefix → 404 (don't fall through to an SSR 404 page).
316
+ return { kind: "response", response: new Response("Not Found", { status: 404 }) }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Serve a Web-`fetch` app on a Node `http` server. Resolves once bound — Node binds
322
+ * the port asynchronously, so awaiting gives you the real port (matters for `port: 0`).
323
+ */
324
+ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeServer> {
325
+ let inFlight = 0
326
+ let closed = false
327
+ const protocol = protocolResolver(options.protocol)
328
+ const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined
329
+ const server = createServer((nodeReq, nodeRes) => {
330
+ inFlight += 1
331
+ try {
332
+ const handled = handle(app, nodeReq, nodeRes, protocol, staticState)
333
+ if (handled instanceof Promise) {
334
+ void handled.finally(() => {
335
+ inFlight -= 1
336
+ })
337
+ return
338
+ }
339
+ inFlight -= 1
340
+ } catch {
341
+ writeInternalError(nodeRes)
342
+ inFlight -= 1
343
+ }
344
+ })
345
+
346
+ // WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
347
+ // the optional `ws` package — lazy-imported (and the server lazily built) on the FIRST real WS
348
+ // upgrade, so a non-WS Node app never loads `ws`.
349
+ const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
350
+ if (resolveWs !== undefined) {
351
+ let wssPromise: Promise<WsServer | undefined> | undefined
352
+ server.on("upgrade", (nodeReq, socket, head) => {
353
+ void handleUpgrade(resolveWs, protocol, nodeReq, socket, head, () => {
354
+ wssPromise ??= loadWsServer()
355
+ return wssPromise
356
+ })
357
+ })
358
+ }
359
+
360
+ // Opt-in: own SIGTERM/SIGINT so `docker stop` / Ctrl-C drains in-flight requests
361
+ // before exit. `stop` is hoisted, so `onSignal` can reference it.
362
+ const onSignal = (): void => {
363
+ void stop()
364
+ }
365
+
366
+ async function stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
367
+ if (closed) return // idempotent
368
+ closed = true
369
+ if (options.signals === true) {
370
+ // Remove our own handlers so repeated serve()/stop() cycles don't leak listeners.
371
+ process.removeListener("SIGTERM", onSignal)
372
+ process.removeListener("SIGINT", onSignal)
373
+ }
374
+ server.close() // stop accepting new connections; existing requests continue
375
+ const deadline = Date.now() + drainMs
376
+ while (inFlight > 0 && Date.now() < deadline) {
377
+ await new Promise((r) => setTimeout(r, DRAIN_POLL_MS))
378
+ }
379
+ server.closeAllConnections() // force-close stragglers + idle keep-alive sockets
380
+ }
381
+
382
+ return new Promise((resolve) => {
383
+ server.listen(options.port, options.hostname, () => {
384
+ const address = server.address()
385
+ const port = address !== null && typeof address === "object" ? address.port : options.port
386
+ if (options.signals === true) {
387
+ process.once("SIGTERM", onSignal)
388
+ process.once("SIGINT", onSignal)
389
+ }
390
+ resolve({ port, stop })
391
+ })
392
+ })
393
+ }
394
+
395
+ function handle(
396
+ app: FetchHandler,
397
+ nodeReq: IncomingMessage,
398
+ nodeRes: ServerResponse,
399
+ getProtocol: RequestProtocolResolver,
400
+ staticState: StaticState | undefined,
401
+ ): void | Promise<void> {
402
+ let protocol: RequestProtocol
403
+ try {
404
+ protocol = getProtocol(nodeReq)
405
+ } catch {
406
+ writeInternalError(nodeRes)
407
+ return
408
+ }
409
+
410
+ // Static files first (GET/HEAD). The match is synchronous, so a non-asset request never leaves the
411
+ // app's sync fast path below; only a prefix hit reads from disk (and async-writes the file).
412
+ if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
413
+ const matched = staticMatch(staticState, nodeReq.url ?? "/")
414
+ if (matched !== "pass") {
415
+ if ("reject" in matched) return writeNodeResponse(matched.reject, nodeRes)
416
+ return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
417
+ (outcome) => writeNodeOutcome(outcome, nodeRes),
418
+ () => writeInternalError(nodeRes),
419
+ )
420
+ }
421
+ }
422
+
423
+ // Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
424
+ // write straight to the socket — skipping the undici `Response` build + body drain (the bulk of the
425
+ // Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
426
+ // `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
427
+ // behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
428
+ const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
429
+ if (typeof resolveNodeSource === "function") {
430
+ try {
431
+ const outcome = resolveNodeSource.call(app, toNodeRequestSource(nodeReq, protocol))
432
+ return outcome instanceof Promise
433
+ ? outcome.then(
434
+ (settled) => writeNodeOutcome(settled, nodeRes),
435
+ () => writeInternalError(nodeRes),
436
+ )
437
+ : writeNodeOutcome(outcome, nodeRes)
438
+ } catch {
439
+ writeInternalError(nodeRes)
440
+ return
441
+ }
442
+ }
443
+
444
+ const request = toWebRequest(nodeReq, protocol)
445
+ const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
446
+ if (typeof resolveNode === "function") {
447
+ try {
448
+ const outcome = resolveNode.call(app, request)
449
+ return outcome instanceof Promise
450
+ ? outcome.then(
451
+ (settled) => writeNodeOutcome(settled, nodeRes),
452
+ () => writeInternalError(nodeRes),
453
+ )
454
+ : writeNodeOutcome(outcome, nodeRes)
455
+ } catch {
456
+ writeInternalError(nodeRes)
457
+ return
458
+ }
459
+ }
460
+
461
+ try {
462
+ const response = app.fetch(request)
463
+ return response instanceof Promise
464
+ ? response.then(
465
+ (settled) => writeNodeResponse(settled, nodeRes),
466
+ () => writeInternalError(nodeRes),
467
+ )
468
+ : writeNodeResponse(response, nodeRes)
469
+ } catch {
470
+ // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
471
+ writeInternalError(nodeRes)
472
+ return
473
+ }
474
+ }
475
+
476
+ function writeNodeOutcome(
477
+ outcome: NodeServeOutcome,
478
+ nodeRes: ServerResponse,
479
+ ): void | Promise<void> {
480
+ if (outcome.kind === "json") {
481
+ writeJsonOutcome(outcome, nodeRes)
482
+ return
483
+ }
484
+ if (outcome.kind === "body") {
485
+ writeBodyOutcome(outcome, nodeRes)
486
+ return
487
+ }
488
+ return writeNodeResponse(outcome.response, nodeRes)
489
+ }
490
+
491
+ /** A flat 500 with no leaked detail — the adapter's last-resort guard if a handler throws. */
492
+ function writeInternalError(nodeRes: ServerResponse): void {
493
+ nodeRes.writeHead(500, { "content-type": "application/json" })
494
+ nodeRes.end(INTERNAL_ERROR_BODY)
495
+ }
496
+
497
+ /**
498
+ * Serialize a node-direct JSON outcome straight to the socket — no undici `Response`, no stream drain.
499
+ * Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
500
+ * match undici's `Headers` normalization, the JSON `Content-Type` matches the host runtime's, and each
501
+ * queued cookie is emitted as its own `Set-Cookie` line (never comma-joined).
502
+ */
503
+ function writeJsonOutcome(
504
+ outcome: Extract<NodeServeOutcome, { kind: "json" }>,
505
+ nodeRes: ServerResponse,
506
+ ): void {
507
+ const headers: Record<string, string | string[]> = {}
508
+ if (outcome.headers !== undefined) {
509
+ for (const [key, value] of Object.entries(outcome.headers)) headers[key.toLowerCase()] = value
510
+ }
511
+ // A `null` body is a 204/no-content render — `new Response(null)` carries no Content-Type, so we add
512
+ // none either; a non-null body is JSON, matching `Response.json`'s Content-Type.
513
+ if (outcome.body !== null) headers["content-type"] = JSON_CONTENT_TYPE
514
+ if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
515
+ headers["set-cookie"] = [...outcome.cookies]
516
+ }
517
+ nodeRes.writeHead(outcome.status, headers)
518
+ if (outcome.body !== null) nodeRes.end(outcome.body)
519
+ else nodeRes.end()
520
+ }
521
+
522
+ /**
523
+ * Write a node-direct buffered body outcome straight to the socket. This is the Response-shaped
524
+ * sibling of `writeJsonOutcome`: headers/status were already normalized by core, and the body is the
525
+ * exact marked payload from the Response producer, so there is no Web body reader to drain.
526
+ */
527
+ function writeBodyOutcome(
528
+ outcome: Extract<NodeServeOutcome, { kind: "body" }>,
529
+ nodeRes: ServerResponse,
530
+ ): void {
531
+ const headers: Record<string, string | string[]> = {}
532
+ if (outcome.headers !== undefined) {
533
+ for (const [key, value] of Object.entries(outcome.headers)) {
534
+ headers[key] = typeof value === "string" ? value : [...value]
535
+ }
536
+ }
537
+ nodeRes.writeHead(outcome.status, headers)
538
+ nodeRes.end(outcome.body)
539
+ }
540
+
541
+ function protocolResolver(option: RequestProtocolOption | undefined): RequestProtocolResolver {
542
+ if (option === undefined) return () => "http"
543
+ if (typeof option === "function") return (req) => normalizeProtocol(option(req))
544
+ const protocol = normalizeProtocol(option)
545
+ return () => protocol
546
+ }
547
+
548
+ function normalizeProtocol(value: string): RequestProtocol {
549
+ if (value === "http" || value === "https") return value
550
+ throw new Error(
551
+ `@nifrajs/node: protocol must be "http" or "https" (got ${JSON.stringify(value)})`,
552
+ )
553
+ }
554
+
555
+ function toWebRequest(req: IncomingMessage, protocol: RequestProtocol): Request {
556
+ const host = req.headers.host ?? "localhost"
557
+ const url = `${protocol}://${host}${req.url ?? "/"}`
558
+ const method = req.method ?? "GET"
559
+ return makeWebRequest(req, method, url, headersFromNode(req.headers))
560
+ }
561
+
562
+ function toNodeRequestSource(req: IncomingMessage, protocol: RequestProtocol): NodeRequestSource {
563
+ return new LazyNodeRequestSource(req, protocol)
564
+ }
565
+
566
+ /**
567
+ * Lazy view over a Node `IncomingMessage`. Methods live on the prototype (not re-allocated per
568
+ * request), and every materialization is deferred: the `Headers` object, the Web `ReadableStream`
569
+ * body, and the undici `Request` are each built only when first read. A bare GET that never touches
570
+ * `c.req` allocates just this instance.
571
+ */
572
+ class LazyNodeRequestSource implements NodeRequestSource {
573
+ readonly method: string
574
+ readonly url: string
575
+
576
+ private headersValue: Headers | undefined
577
+ private bodyValue: ReadableStream<Uint8Array> | null | undefined
578
+ private requestValue: Request | undefined
579
+ private consumedBody: Buffer | undefined
580
+ private readBodyPromise: Promise<Buffer> | undefined
581
+ private readonly nodeReq: IncomingMessage
582
+
583
+ constructor(nodeReq: IncomingMessage, protocol: RequestProtocol) {
584
+ this.nodeReq = nodeReq
585
+ this.method = nodeReq.method ?? "GET"
586
+ const host = nodeReq.headers.host ?? "localhost"
587
+ this.url = `${protocol}://${host}${nodeReq.url ?? "/"}`
588
+ }
589
+
590
+ get headers(): Headers {
591
+ this.headersValue ??= headersFromNode(this.nodeReq.headers)
592
+ return this.headersValue
593
+ }
594
+
595
+ // Read straight off Node's already-lowercased header bag (comma-joining multi-values to match
596
+ // `Headers.get`) so the body-cap path can check content-type/length without building a `Headers`.
597
+ header(name: string): string | null {
598
+ const value = this.nodeReq.headers[name.toLowerCase()]
599
+ if (value === undefined) return null
600
+ return Array.isArray(value) ? value.join(", ") : value
601
+ }
602
+
603
+ get body(): ReadableStream<Uint8Array> | null {
604
+ if (this.method === "GET" || this.method === "HEAD") return null
605
+ if (this.consumedBody !== undefined) return this.request.body
606
+ this.bodyValue ??= Readable.toWeb(this.nodeReq) as ReadableStream<Uint8Array>
607
+ return this.bodyValue
608
+ }
609
+
610
+ arrayBuffer(): Promise<ArrayBuffer> {
611
+ if (this.requestValue !== undefined) return this.requestValue.arrayBuffer()
612
+ return this.readNodeBody().then(
613
+ (buffer) =>
614
+ buffer.buffer.slice(
615
+ buffer.byteOffset,
616
+ buffer.byteOffset + buffer.byteLength,
617
+ ) as ArrayBuffer,
618
+ )
619
+ }
620
+
621
+ json(): Promise<unknown> {
622
+ if (this.requestValue !== undefined) return this.requestValue.json()
623
+ return this.readNodeBody().then((buffer) => JSON.parse(buffer.toString("utf8")) as unknown)
624
+ }
625
+
626
+ get request(): Request {
627
+ if (this.requestValue !== undefined) return this.requestValue
628
+ if (this.consumedBody !== undefined) {
629
+ this.requestValue = makeWebRequest(
630
+ this.nodeReq,
631
+ this.method,
632
+ this.url,
633
+ this.headers,
634
+ this.consumedBody,
635
+ )
636
+ // Preserve one-shot body semantics if user code asks for `c.req` after nifra already consumed it.
637
+ void this.requestValue.arrayBuffer().catch(() => {})
638
+ return this.requestValue
639
+ }
640
+ this.requestValue = makeWebRequest(this.nodeReq, this.method, this.url, this.headers, this.body)
641
+ return this.requestValue
642
+ }
643
+
644
+ // Buffer the request body once. A single-chunk body (the common case) skips the array + `concat`;
645
+ // a client abort / socket error rejects (via `error`/`aborted`), which the body-cap callers catch
646
+ // and map to a flat 400.
647
+ private readNodeBody(): Promise<Buffer> {
648
+ if (this.consumedBody !== undefined) return Promise.resolve(this.consumedBody)
649
+ this.readBodyPromise ??= new Promise<Buffer>((resolve, reject) => {
650
+ let first: Buffer | undefined
651
+ let chunks: Buffer[] | undefined
652
+ let total = 0
653
+ const finish = (): void => {
654
+ cleanup()
655
+ this.consumedBody =
656
+ chunks === undefined ? (first ?? EMPTY_BUFFER) : Buffer.concat(chunks, total)
657
+ resolve(this.consumedBody)
658
+ }
659
+ const fail = (err: Error): void => {
660
+ cleanup()
661
+ reject(err)
662
+ }
663
+ const onData = (chunk: Buffer | string): void => {
664
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk
665
+ if (first === undefined) {
666
+ first = buffer
667
+ } else {
668
+ chunks ??= [first]
669
+ chunks.push(buffer)
670
+ }
671
+ total += buffer.byteLength
672
+ }
673
+ const onAborted = (): void => fail(new Error("request aborted"))
674
+ const cleanup = (): void => {
675
+ this.nodeReq.removeListener("data", onData)
676
+ this.nodeReq.removeListener("end", finish)
677
+ this.nodeReq.removeListener("error", fail)
678
+ this.nodeReq.removeListener("aborted", onAborted)
679
+ this.nodeReq.removeListener("close", onClose)
680
+ }
681
+ // `aborted` is deprecated on newer Node; `close`-without-`end` is the forward-compatible signal
682
+ // that the connection dropped mid-body. Either way the read rejects instead of hanging forever.
683
+ const onClose = (): void => {
684
+ if (this.consumedBody === undefined) fail(new Error("request closed before body completed"))
685
+ }
686
+ this.nodeReq.on("data", onData)
687
+ this.nodeReq.once("end", finish)
688
+ this.nodeReq.once("error", fail)
689
+ this.nodeReq.once("aborted", onAborted)
690
+ this.nodeReq.once("close", onClose)
691
+ })
692
+ return this.readBodyPromise
693
+ }
694
+ }
695
+
696
+ function headersFromNode(input: IncomingHttpHeaders): Headers {
697
+ const headers = new Headers()
698
+ for (const [key, value] of Object.entries(input)) {
699
+ if (value === undefined) continue
700
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value)
701
+ }
702
+ return headers
703
+ }
704
+
705
+ function makeWebRequest(
706
+ req: IncomingMessage,
707
+ method: string,
708
+ url: string,
709
+ headers: Headers,
710
+ body?: ReadableStream<Uint8Array> | Uint8Array | null,
711
+ ): Request {
712
+ const init: RequestInit & { duplex?: "half" } = { method, headers }
713
+ if (method !== "GET" && method !== "HEAD") {
714
+ // Stream the body in; `duplex: "half"` is required for a streamed request body.
715
+ init.body = body ?? (Readable.toWeb(req) as ReadableStream<Uint8Array>)
716
+ init.duplex = "half"
717
+ }
718
+ return new Request(url, init)
719
+ }
720
+
721
+ function waitForDrain(nodeRes: ServerResponse): Promise<boolean> {
722
+ if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
723
+ return Promise.resolve(false)
724
+ }
725
+ return new Promise((resolve) => {
726
+ const cleanup = (): void => {
727
+ nodeRes.removeListener("drain", onDrain)
728
+ nodeRes.removeListener("close", onClose)
729
+ nodeRes.removeListener("error", onError)
730
+ }
731
+ const onDrain = (): void => {
732
+ cleanup()
733
+ resolve(true)
734
+ }
735
+ const onClose = (): void => {
736
+ cleanup()
737
+ resolve(false)
738
+ }
739
+ const onError = (): void => {
740
+ cleanup()
741
+ resolve(false)
742
+ }
743
+ nodeRes.once("drain", onDrain)
744
+ nodeRes.once("close", onClose)
745
+ nodeRes.once("error", onError)
746
+ if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
747
+ cleanup()
748
+ resolve(false)
749
+ }
750
+ })
751
+ }
752
+
753
+ function writeNodeResponse(response: Response, nodeRes: ServerResponse): void | Promise<void> {
754
+ const headers: Record<string, string | string[]> = {}
755
+ response.headers.forEach((value, key) => {
756
+ headers[key] = value
757
+ })
758
+ // `Headers.forEach` comma-joins repeated `Set-Cookie` into one value — wrong, since a cookie's
759
+ // `Expires` attribute itself contains a comma (so a client can't safely split it back). Emit one
760
+ // header line per cookie via the un-joined `getSetCookie()` array (a response can set several:
761
+ // e.g. a session cookie + a CSRF cookie).
762
+ const setCookies = response.headers.getSetCookie?.()
763
+ if (setCookies !== undefined && setCookies.length > 0) headers["set-cookie"] = setCookies
764
+ nodeRes.writeHead(response.status, headers)
765
+ const directBody = nodeResponseBody(response)
766
+ if (directBody !== undefined) {
767
+ nodeRes.end(directBody)
768
+ return
769
+ }
770
+ if (response.body === null) {
771
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
772
+ return
773
+ }
774
+ return writeNodeResponseBody(response, nodeRes)
775
+ }
776
+
777
+ async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse): Promise<void> {
778
+ const reader = response.body!.getReader()
779
+ try {
780
+ for (;;) {
781
+ const { done, value } = await reader.read()
782
+ if (done) break
783
+ if (!nodeRes.write(value)) {
784
+ if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
785
+ try {
786
+ await reader.cancel()
787
+ } catch {}
788
+ return
789
+ }
790
+ const drained = await waitForDrain(nodeRes)
791
+ if (!drained || nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
792
+ try {
793
+ await reader.cancel()
794
+ } catch {}
795
+ return
796
+ }
797
+ }
798
+ }
799
+ } catch {
800
+ await reader.cancel().catch(() => {})
801
+ if (!nodeRes.headersSent) writeInternalError(nodeRes)
802
+ else nodeRes.destroy()
803
+ return
804
+ }
805
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
806
+ }
807
+
808
+ function nodeResponseBody(response: Response): string | Uint8Array | undefined {
809
+ if (response.bodyUsed) return undefined
810
+ const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
811
+ return typeof body === "string" || body instanceof Uint8Array ? body : undefined
812
+ }
813
+
814
+ // --- WebSocket bridge: Node has no built-in WS server, so upgrades go through the OPTIONAL `ws`
815
+ // package, lazy-imported on the first upgrade (non-WS apps never load it). ---
816
+
817
+ type WsServerCtor = new (options: { noServer: true }) => WsServer
818
+
819
+ /** A non-literal specifier so TS treats `import(...)` as `any` — `ws` is an optional peer with no
820
+ * `@types/ws` dependency here (the surface is structurally typed via {@link WsServer}/{@link WsSocket}). */
821
+ const WS_MODULE_SPECIFIER = "ws"
822
+
823
+ /** Lazily build a noServer `ws` `WebSocketServer`, or `undefined` if `ws` isn't installed. */
824
+ async function loadWsServer(): Promise<WsServer | undefined> {
825
+ try {
826
+ const mod = (await import(WS_MODULE_SPECIFIER)) as {
827
+ WebSocketServer?: WsServerCtor
828
+ default?: { WebSocketServer?: WsServerCtor }
829
+ }
830
+ const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer
831
+ return Ctor === undefined ? undefined : new Ctor({ noServer: true })
832
+ } catch {
833
+ return undefined // `ws` not installed — caller responds 501
834
+ }
835
+ }
836
+
837
+ const WS_STATUS_TEXT: Readonly<Record<number, string>> = {
838
+ 400: "Bad Request",
839
+ 401: "Unauthorized",
840
+ 403: "Forbidden",
841
+ 404: "Not Found",
842
+ 426: "Upgrade Required",
843
+ 500: "Internal Server Error",
844
+ 501: "Not Implemented",
845
+ }
846
+
847
+ /** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
848
+ * to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
849
+ async function handleUpgrade(
850
+ resolveWs: (request: Request) => WsUpgradeOutcome | Promise<WsUpgradeOutcome>,
851
+ getProtocol: RequestProtocolResolver,
852
+ nodeReq: IncomingMessage,
853
+ socket: Duplex,
854
+ head: Buffer,
855
+ getWss: () => Promise<WsServer | undefined>,
856
+ ): Promise<void> {
857
+ let outcome: WsUpgradeOutcome
858
+ try {
859
+ outcome = await resolveWs(toWebRequest(nodeReq, getProtocol(nodeReq)))
860
+ } catch {
861
+ writeUpgradeRejection(socket, 500, "internal_error")
862
+ return
863
+ }
864
+ if (outcome.kind === "pass") {
865
+ writeUpgradeRejection(socket, 404, "not_found") // upgrade to a path with no WS route
866
+ return
867
+ }
868
+ if (outcome.kind === "reject") {
869
+ await writeRejectionResponse(socket, outcome.response)
870
+ return
871
+ }
872
+ const wss = await getWss()
873
+ if (wss === undefined) {
874
+ writeUpgradeRejection(socket, 501, "websocket_unavailable") // `ws` not installed
875
+ return
876
+ }
877
+ const { handler, data, pubsub } = outcome
878
+ wss.handleUpgrade(nodeReq, socket, head, (ws) => attachNodeWebSocket(ws, handler, data, pubsub))
879
+ }
880
+
881
+ /** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
882
+ * of core's `attachWebSocket` — binary frames normalize to `Uint8Array`; a thrown/rejected callback
883
+ * routes to `error()` and never crashes the connection. */
884
+ function attachNodeWebSocket(
885
+ ws: WsSocket,
886
+ handler: NifraWsHandler,
887
+ data: unknown,
888
+ pubsub: WsPubSub,
889
+ ): void {
890
+ const nifra: NifraWs = {
891
+ send: (payload) => ws.send(payload),
892
+ close: (code, reason) => ws.close(code, reason),
893
+ get readyState() {
894
+ return ws.readyState
895
+ },
896
+ subscribe: (topic) => pubsub.subscribe(topic, nifra),
897
+ unsubscribe: (topic) => pubsub.unsubscribe(topic, nifra),
898
+ data,
899
+ raw: ws,
900
+ }
901
+ const reportError = (error: unknown): void => {
902
+ if (handler.error === undefined) return
903
+ try {
904
+ const r = handler.error(nifra, error)
905
+ if (r instanceof Promise) r.catch(() => {})
906
+ } catch {
907
+ /* the error handler itself failed — last resort, swallow */
908
+ }
909
+ }
910
+ const safe = (call: () => void | Promise<void>): void => {
911
+ try {
912
+ const r = call()
913
+ if (r instanceof Promise) r.catch(reportError)
914
+ } catch (error) {
915
+ reportError(error)
916
+ }
917
+ }
918
+ ws.on("message", (raw, isBinary) => {
919
+ const payload: NifraWsData = isBinary
920
+ ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
921
+ : raw.toString()
922
+ safe(() => handler.message?.(nifra, payload))
923
+ })
924
+ ws.on("close", (code, reason) => {
925
+ pubsub.unsubscribeAll(nifra) // drop topic subscriptions so the registry never holds a dead socket
926
+ safe(() => handler.close?.(nifra, code, reason.toString()))
927
+ })
928
+ ws.on("error", (error) => reportError(error))
929
+ safe(() => handler.open?.(nifra)) // open: the socket is already established here
930
+ }
931
+
932
+ /** Write a minimal JSON error response to a raw upgrade socket, then close it. */
933
+ function writeUpgradeRejection(socket: Duplex, status: number, error: string): void {
934
+ const body = JSON.stringify({ ok: false, error })
935
+ socket.write(
936
+ `HTTP/1.1 ${status} ${WS_STATUS_TEXT[status] ?? "Error"}\r\n` +
937
+ `Content-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` +
938
+ "Connection: close\r\n\r\n" +
939
+ body,
940
+ )
941
+ socket.destroy()
942
+ }
943
+
944
+ /** Serialize a nifra guard's rejection `Response` (e.g. a 401) to a raw upgrade socket, then close. */
945
+ async function writeRejectionResponse(socket: Duplex, response: Response): Promise<void> {
946
+ const body = await response.text()
947
+ let head = `HTTP/1.1 ${response.status} ${response.statusText || WS_STATUS_TEXT[response.status] || "Error"}\r\n`
948
+ response.headers.forEach((value, key) => {
949
+ if (key.toLowerCase() !== "content-length") head += `${key}: ${value}\r\n`
950
+ })
951
+ head += `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n`
952
+ socket.write(head + body)
953
+ socket.destroy()
954
+ }