@nifrajs/node 2.14.1 → 3.2.0

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 CHANGED
@@ -11,6 +11,7 @@
11
11
  * `Request`/`Response`, plus a Bun-`listen()`-style graceful `stop()`.
12
12
  */
13
13
  import { AsyncLocalStorage } from "node:async_hooks"
14
+ import { constants as FS } from "node:fs"
14
15
  import { open, realpath } from "node:fs/promises"
15
16
  import {
16
17
  createServer,
@@ -19,11 +20,13 @@ import {
19
20
  type ServerResponse,
20
21
  } from "node:http"
21
22
  import { extname, isAbsolute, relative, resolve, sep } from "node:path"
22
- import { type Duplex, Readable } from "node:stream"
23
+ import type { Duplex, Readable } from "node:stream"
23
24
  import { fileURLToPath } from "node:url"
24
25
  // srvx's lazy spec-shaped Response - see nodeOutcomeToResponse for why the bridge uses it.
25
26
  import { FastResponse } from "srvx/node"
27
+ import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.ts"
26
28
  import type { NodeServeOutcome } from "./generated/node-outcome.ts"
29
+ import { claimableWebStream, claimNodeStream } from "./node-stream.ts"
27
30
 
28
31
  /** The runtime platform a nifra app accepts as `fetch`'s 2nd arg - here, the observed socket peer. */
29
32
  interface NodePlatform {
@@ -126,6 +129,27 @@ interface NodeRequestSource {
126
129
  rawBodyReaders?(): NodeRawBodyReaders
127
130
  }
128
131
 
132
+ /** The request/response view handed to a mounted handler's optional native lane. It deliberately
133
+ * keeps the raw IncomingMessage so an upstream client receives the original Node stream. */
134
+ interface NodeNativeMountRequest {
135
+ readonly method: string
136
+ readonly url: string
137
+ readonly headers: IncomingHttpHeaders
138
+ readonly raw: IncomingMessage
139
+ }
140
+
141
+ type NodeNativeMountHandler = (
142
+ request: NodeNativeMountRequest,
143
+ response: ServerResponse,
144
+ platform?: NodePlatform,
145
+ ) => undefined | false | Promise<undefined | false>
146
+
147
+ interface NodeNativeMountSelection {
148
+ readonly handler: NodeNativeMountHandler
149
+ readonly path: string
150
+ readonly stripPrefix: boolean
151
+ }
152
+
129
153
  /**
130
154
  * Core's `RawBodyReaders`: the pre-cap reader surface the transport cap buffers through. Mirrored
131
155
  * structurally here rather than imported, matching how the rest of this adapter states core's
@@ -149,7 +173,7 @@ interface NodeContextSet {
149
173
  interface NodeOutcomeRuntime {
150
174
  toOutcome(result: unknown, set: NodeContextSet): NodeServeOutcome
151
175
  toResponse(outcome: NodeServeOutcome): Response
152
- fromResponse(response: Response): NodeServeOutcome
176
+ fromResponse(response: Response | NodeResponseResult): NodeServeOutcome
153
177
  timeout(): NodeServeOutcome
154
178
  /** The Content-Type the json render writes implicitly - surfaced to core's native hook walk. */
155
179
  readonly jsonContentType?: string
@@ -169,6 +193,8 @@ interface NodeFastHandler extends FetchHandler {
169
193
  ): NodeServeOutcome | Promise<NodeServeOutcome>
170
194
  }
171
195
 
196
+ const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount)
197
+
172
198
  /**
173
199
  * The `Content-Type` the host runtime's `Response.json` emits - Node's undici uses `application/json`,
174
200
  * Bun uses `application/json;charset=utf-8`. Probed once at module load (zero per-request cost) so the
@@ -178,12 +204,12 @@ const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "appli
178
204
 
179
205
  const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}'
180
206
  const EMPTY_BUFFER = Buffer.alloc(0)
181
- const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
182
- const RESPONSE_RESULT = Symbol.for("nifra.response.result")
207
+ const NODE_RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
208
+ const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
183
209
  /** Core's proof that a header record's names are already the lowercase wire spelling - set once per
184
210
  * request by the native response walk, which had to look at the same keys anyway. Declared by key
185
211
  * rather than imported: the same cross-package convention as the two marks above. */
186
- const LOWERCASE_HEADER_KEYS = Symbol.for("nifra.headers.lowercase")
212
+ const LOWERCASE_HEADER_KEYS = Symbol.for(NODE_BRIDGE_MARKER_KEYS.lowercaseHeaderKeys)
187
213
 
188
214
  function isBodylessStatus(status: number): boolean {
189
215
  return status === 204 || status === 205 || status === 304
@@ -204,6 +230,20 @@ function normalizeBodylessResponse(response: Response): Response {
204
230
  const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
205
231
  toOutcome(result, set) {
206
232
  if (isResponseResult(result)) {
233
+ // A plain render never builds a `Response` - same `kind: "json"` lane a handler's return takes.
234
+ const plain = result.plain
235
+ if (plain !== undefined) {
236
+ return {
237
+ kind: "json",
238
+ status: plain.status,
239
+ headers: plainRenderHeaders(plain.headers, set._headers),
240
+ cookies: set._cookies,
241
+ body:
242
+ plain.body === undefined || isBodylessStatus(plain.status)
243
+ ? null
244
+ : JSON.stringify(plain.body),
245
+ }
246
+ }
207
247
  const body = result.toNodeBody?.()
208
248
  if (body !== undefined) {
209
249
  return {
@@ -245,6 +285,25 @@ interface NodeResponseResult {
245
285
  readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
246
286
  readonly body: string | Uint8Array
247
287
  }
288
+ /** Core's `PlainRender` - a response still in value form, rendered without building a `Response`.
289
+ * Structural, like the rest of this interface: the marker is a `Symbol.for`, so a value from any
290
+ * copy of core matches. */
291
+ readonly plain?: {
292
+ readonly status: number
293
+ readonly headers?: Readonly<Record<string, string>>
294
+ readonly body: unknown
295
+ }
296
+ }
297
+
298
+ /** Mirror of core's `plainRenderHeaders`: the render's own headers on top of `c.set.headers`, always
299
+ * a fresh record when it has any, because the writers below mutate what they are handed and a
300
+ * `status(...)` value is commonly hoisted and reused across requests. */
301
+ function plainRenderHeaders(
302
+ own: Readonly<Record<string, string>> | undefined,
303
+ ambient: Record<string, string> | undefined,
304
+ ): Record<string, string> | undefined {
305
+ if (own === undefined) return ambient
306
+ return ambient === undefined ? { ...own } : { ...ambient, ...own }
248
307
  }
249
308
 
250
309
  function isResponseResult(value: unknown): value is NodeResponseResult {
@@ -453,8 +512,28 @@ function appendCookiesToResponse(
453
512
  return response
454
513
  }
455
514
 
456
- function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
457
- response = normalizeBodylessResponse(response)
515
+ /** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
516
+ * every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
517
+ * return takes, with no `Response` built and none drained; there is no `c.set` at these sites, which
518
+ * is why they are wrapped rather than finalized. */
519
+ function nodeOutcomeFromResponse(result: Response | NodeResponseResult): NodeServeOutcome {
520
+ if (!(result instanceof Response)) {
521
+ const plain = result.plain
522
+ if (plain !== undefined) {
523
+ return {
524
+ kind: "json",
525
+ status: plain.status,
526
+ headers: plain.headers === undefined ? undefined : { ...plain.headers },
527
+ cookies: undefined,
528
+ body:
529
+ plain.body === undefined || isBodylessStatus(plain.status)
530
+ ? null
531
+ : JSON.stringify(plain.body),
532
+ }
533
+ }
534
+ return nodeOutcomeFromResponse(result.toResponse())
535
+ }
536
+ const response = normalizeBodylessResponse(result)
458
537
  if (!response.bodyUsed) {
459
538
  const body = (response as { readonly [NODE_RESPONSE_BODY]?: unknown })[NODE_RESPONSE_BODY]
460
539
  if (typeof body === "string" || body instanceof Uint8Array) {
@@ -829,19 +908,23 @@ async function readStatic(
829
908
  ): Promise<NodeServeOutcome> {
830
909
  let handle: Awaited<ReturnType<typeof open>> | undefined
831
910
  try {
832
- handle = await open(file, "r")
833
- const stat = await handle.stat()
834
- if (!stat.isFile()) {
835
- await handle.close()
836
- return { kind: "response", response: new Response("Not Found", { status: 404 }) }
837
- }
838
911
  // Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
839
- // points outside it. Re-confirm the real path is contained before streaming the bytes.
912
+ // points outside it. Resolve first, check containment on the RESOLVED name, and open that -
913
+ // never open the requested path and re-resolve it afterwards. A local attacker who can write
914
+ // inside the served tree wins that ordering: swap a link between the open and the lookup and the
915
+ // descriptor already streaming refers to an external file while the lookup answers with a
916
+ // contained path. `O_NOFOLLOW` closes the remainder on the final component - `realpath` returned
917
+ // a name with no links left in it, so one appearing before the open is an attack, not a layout.
840
918
  const [realFile, realRoot] = await Promise.all([realpath(file), realpath(state.root)])
841
919
  if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
842
- await handle.close()
843
920
  return { kind: "response", response: new Response("Forbidden", { status: 403 }) }
844
921
  }
922
+ handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW)
923
+ const stat = await handle.stat()
924
+ if (!stat.isFile()) {
925
+ await handle.close()
926
+ return { kind: "response", response: new Response("Not Found", { status: 404 }) }
927
+ }
845
928
  const headers: Record<string, string> = { ...state.headers }
846
929
  headers["content-type"] =
847
930
  STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
@@ -858,7 +941,11 @@ async function readStatic(
858
941
  const stream = handle.createReadStream()
859
942
  return {
860
943
  kind: "response",
861
- response: new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, { headers }),
944
+ // Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
945
+ // response reaches the writer untouched, and read as an ordinary Web stream by anything that
946
+ // gets to it first (a middleware that rewrites or compresses the body, say), which refuses
947
+ // the claim and takes the conversion instead.
948
+ response: new Response(claimableWebStream(stream), { headers }),
862
949
  }
863
950
  } catch {
864
951
  await handle?.close().catch(() => {})
@@ -913,23 +1000,73 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
913
1000
  const protocol = protocolResolver(options.protocol)
914
1001
  const hostPolicy = hostPolicyOf(options)
915
1002
  const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined
1003
+ // Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
1004
+ // discard the response for an already-dispatched request when an understated Content-Length
1005
+ // leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
1006
+ // connection-scoped and close only after active responses finish, preserving response ordering.
1007
+ const activeResponses = new WeakMap<object, Set<ServerResponse>>()
1008
+ const parserErrorSockets = new WeakSet<object>()
916
1009
  const server = createServer((nodeReq, nodeRes) => {
1010
+ const socket = nodeReq.socket
1011
+ let responses = activeResponses.get(socket)
1012
+ if (responses === undefined) {
1013
+ responses = new Set<ServerResponse>()
1014
+ activeResponses.set(socket, responses)
1015
+ }
1016
+ responses.add(nodeRes)
1017
+ const releaseResponse = (): void => {
1018
+ const current = activeResponses.get(socket)
1019
+ if (current === undefined) return
1020
+ current.delete(nodeRes)
1021
+ if (current.size === 0) activeResponses.delete(socket)
1022
+ }
1023
+ nodeRes.once("finish", releaseResponse)
1024
+ nodeRes.once("close", releaseResponse)
917
1025
  inFlight += 1
918
1026
  try {
919
1027
  const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy)
920
1028
  if (handled instanceof Promise) {
921
- void handled.finally(() => {
922
- inFlight -= 1
923
- })
1029
+ // `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
1030
+ // observes, and Node terminates the process on an unhandled rejection by default. Every
1031
+ // writer already ends its own failures (`failWrite`), so anything arriving here is a fault
1032
+ // no response can still be built from - the connection is the only thing left to end.
1033
+ void handled
1034
+ .catch(() => failWrite(nodeRes))
1035
+ .finally(() => {
1036
+ inFlight -= 1
1037
+ })
924
1038
  return
925
1039
  }
926
1040
  inFlight -= 1
927
1041
  } catch {
928
- writeInternalError(nodeRes)
1042
+ failWrite(nodeRes)
929
1043
  inFlight -= 1
930
1044
  }
931
1045
  })
932
1046
 
1047
+ server.on("clientError", (_error, socket) => {
1048
+ if (socket.destroyed) return
1049
+ const responses = activeResponses.get(socket)
1050
+ if (responses !== undefined && responses.size > 0) {
1051
+ if (parserErrorSockets.has(socket)) return
1052
+ parserErrorSockets.add(socket)
1053
+ const closeWhenDrained = (): void => {
1054
+ const current = activeResponses.get(socket)
1055
+ if (current === undefined || current.size === 0) socket.destroy()
1056
+ }
1057
+ for (const response of responses) {
1058
+ if (response.writableEnded || response.destroyed) closeWhenDrained()
1059
+ else response.once("finish", closeWhenDrained)
1060
+ response.once("close", closeWhenDrained)
1061
+ }
1062
+ return
1063
+ }
1064
+ const body = "Bad Request"
1065
+ socket.end(
1066
+ `HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`,
1067
+ )
1068
+ })
1069
+
933
1070
  // WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
934
1071
  // the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
935
1072
  // upgrade, so a non-WS Node app never loads `ws`.
@@ -992,6 +1129,66 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
992
1129
  })
993
1130
  }
994
1131
 
1132
+ /** Run the existing Node-direct/Web bridge after a native mount either was not selected or declined
1133
+ * because its optional transport was unavailable. Kept as a top-level helper so ordinary requests
1134
+ * do not allocate a per-request closure merely to preserve the fallback. */
1135
+ function runNodeSource(
1136
+ app: FetchHandler,
1137
+ source: NodeRequestSource,
1138
+ nodeReq: IncomingMessage,
1139
+ nodeRes: ServerResponse,
1140
+ protocol: RequestProtocol,
1141
+ host: string,
1142
+ platform: NodePlatform | undefined,
1143
+ ): void | Promise<void> {
1144
+ const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
1145
+ if (typeof resolveNodeSource === "function") {
1146
+ try {
1147
+ const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME)
1148
+ return outcome instanceof Promise
1149
+ ? outcome.then(
1150
+ (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1151
+ () => failWrite(nodeRes),
1152
+ )
1153
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1154
+ } catch {
1155
+ failWrite(nodeRes)
1156
+ return
1157
+ }
1158
+ }
1159
+
1160
+ const request = toWebRequest(nodeReq, protocol, host)
1161
+ const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
1162
+ if (typeof resolveNode === "function") {
1163
+ try {
1164
+ const outcome = resolveNode.call(app, request, platform)
1165
+ return outcome instanceof Promise
1166
+ ? outcome.then(
1167
+ (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1168
+ () => failWrite(nodeRes),
1169
+ )
1170
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1171
+ } catch {
1172
+ failWrite(nodeRes)
1173
+ return
1174
+ }
1175
+ }
1176
+
1177
+ try {
1178
+ const response = app.fetch(request, platform)
1179
+ return response instanceof Promise
1180
+ ? response.then(
1181
+ (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1182
+ () => failWrite(nodeRes),
1183
+ )
1184
+ : writeResponseSafely(response, nodeRes, nodeReq.method)
1185
+ } catch {
1186
+ // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
1187
+ failWrite(nodeRes)
1188
+ return
1189
+ }
1190
+ }
1191
+
995
1192
  function handle(
996
1193
  app: FetchHandler,
997
1194
  nodeReq: IncomingMessage,
@@ -1032,38 +1229,65 @@ function handle(
1032
1229
  if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
1033
1230
  const matched = staticMatch(staticState, nodeReq.url ?? "/")
1034
1231
  if (matched !== "pass") {
1035
- if ("reject" in matched) return writeNodeResponse(matched.reject, nodeRes, nodeReq.method)
1232
+ if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
1036
1233
  return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
1037
- (outcome) => writeNodeOutcome(outcome, nodeRes, nodeReq.method),
1038
- () => writeInternalError(nodeRes),
1234
+ (outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
1235
+ () => failWrite(nodeRes),
1039
1236
  )
1040
1237
  }
1041
1238
  }
1042
1239
 
1043
- // Fast path: a nifra app exposes `resolveNode`, which renders a plain-data result as primitives we
1044
- // write straight to the socket - skipping the undici `Response` build + body drain (the bulk of the
1045
- // Web-bridge cost on Node). A handler-returned `Response`/redirect, 404/405, error, timeout, or any
1046
- // `onResponse` hook comes back as `{ kind: "response" }` and takes the same Web path as before, so
1047
- // behavior is identical. A plain `{ fetch }` handler (no `resolveNode`) uses the Web path too.
1240
+ // A mounted handler may advertise a native Node lane. Core has already checked route precedence
1241
+ // and every global lifecycle gate before returning this selection. The handler writes directly to
1242
+ // `nodeRes`; `false` means its optional transport was unavailable, so the same untouched source
1243
+ // continues through the ordinary Web/direct path below.
1048
1244
  const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
1049
- if (typeof resolveNodeSource === "function") {
1050
- try {
1051
- const outcome = resolveNodeSource.call(
1052
- app,
1053
- toNodeRequestSource(nodeReq, protocol, host),
1054
- platform,
1055
- NODE_OUTCOME_RUNTIME,
1056
- )
1057
- return outcome instanceof Promise
1058
- ? outcome.then(
1059
- (settled) => writeNodeOutcome(settled, nodeRes, nodeReq.method),
1060
- () => writeInternalError(nodeRes),
1061
- )
1062
- : writeNodeOutcome(outcome, nodeRes, nodeReq.method)
1063
- } catch {
1064
- writeInternalError(nodeRes)
1065
- return
1245
+ const resolveNodeMount = (app as unknown as Record<symbol, unknown>)[RESOLVE_NODE_MOUNT]
1246
+ if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
1247
+ const nodeSource = toNodeRequestSource(nodeReq, protocol, host)
1248
+
1249
+ if (typeof resolveNodeMount === "function") {
1250
+ let selection: NodeNativeMountSelection | undefined
1251
+ try {
1252
+ selection = (
1253
+ resolveNodeMount as (
1254
+ source: NodeRequestSource,
1255
+ platform?: NodePlatform,
1256
+ ) => NodeNativeMountSelection | undefined
1257
+ ).call(app, nodeSource, platform)
1258
+ } catch {
1259
+ writeInternalError(nodeRes)
1260
+ return
1261
+ }
1262
+ if (selection !== undefined) {
1263
+ const nativeRequest: NodeNativeMountRequest = {
1264
+ method: nodeReq.method ?? "GET",
1265
+ url: selection.stripPrefix
1266
+ ? stripNodeMountPrefix(`${protocol}://${host}${nodeReq.url ?? "/"}`, selection.path)
1267
+ : `${protocol}://${host}${nodeReq.url ?? "/"}`,
1268
+ headers: nodeReq.headers,
1269
+ raw: nodeReq,
1270
+ }
1271
+ try {
1272
+ const outcome = selection.handler(nativeRequest, nodeRes, platform)
1273
+ if (outcome instanceof Promise) {
1274
+ return outcome.then(
1275
+ (handled) =>
1276
+ handled === false
1277
+ ? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
1278
+ : undefined,
1279
+ () => failWrite(nodeRes),
1280
+ )
1281
+ }
1282
+ if (outcome !== false) return
1283
+ } catch {
1284
+ failWrite(nodeRes)
1285
+ return
1286
+ }
1287
+ }
1066
1288
  }
1289
+
1290
+ return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
1067
1291
  }
1068
1292
 
1069
1293
  const request = toWebRequest(nodeReq, protocol, host)
@@ -1073,12 +1297,12 @@ function handle(
1073
1297
  const outcome = resolveNode.call(app, request, platform)
1074
1298
  return outcome instanceof Promise
1075
1299
  ? outcome.then(
1076
- (settled) => writeNodeOutcome(settled, nodeRes, nodeReq.method),
1077
- () => writeInternalError(nodeRes),
1300
+ (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1301
+ () => failWrite(nodeRes),
1078
1302
  )
1079
- : writeNodeOutcome(outcome, nodeRes, nodeReq.method)
1303
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1080
1304
  } catch {
1081
- writeInternalError(nodeRes)
1305
+ failWrite(nodeRes)
1082
1306
  return
1083
1307
  }
1084
1308
  }
@@ -1087,13 +1311,13 @@ function handle(
1087
1311
  const response = app.fetch(request, platform)
1088
1312
  return response instanceof Promise
1089
1313
  ? response.then(
1090
- (settled) => writeNodeResponse(settled, nodeRes, nodeReq.method),
1091
- () => writeInternalError(nodeRes),
1314
+ (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1315
+ () => failWrite(nodeRes),
1092
1316
  )
1093
- : writeNodeResponse(response, nodeRes, nodeReq.method)
1317
+ : writeResponseSafely(response, nodeRes, nodeReq.method)
1094
1318
  } catch {
1095
1319
  // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
1096
- writeInternalError(nodeRes)
1320
+ failWrite(nodeRes)
1097
1321
  return
1098
1322
  }
1099
1323
  }
@@ -1121,6 +1345,62 @@ function writeInternalError(nodeRes: ServerResponse): void {
1121
1345
  nodeRes.end(INTERNAL_ERROR_BODY)
1122
1346
  }
1123
1347
 
1348
+ /**
1349
+ * End a request whose WRITE failed, from anywhere a write can fail.
1350
+ *
1351
+ * `writeHead` throws on an invalid status or a header value carrying CR/LF - reachable whenever an
1352
+ * app reflects request data into `status(...)`/`c.set.headers`, which on the Web lane the `Headers`
1353
+ * constructor rejects instead. It also throws once the head is already out. Both have to end here:
1354
+ * on the async lanes the write runs inside a `.then` callback whose rejection nothing downstream
1355
+ * catches (`serve` only observes `finally`), and Node's default for an unhandled rejection is to
1356
+ * terminate the process - a route-shaped input turning into a server-wide DoS.
1357
+ *
1358
+ * A 500 while the head is unsent, otherwise a destroy: there is no way to correct a response whose
1359
+ * status line already shipped, and a half-written body must not be left for the client to parse.
1360
+ */
1361
+ function failWrite(nodeRes: ServerResponse): void {
1362
+ try {
1363
+ if (!nodeRes.headersSent) {
1364
+ writeInternalError(nodeRes)
1365
+ return
1366
+ }
1367
+ } catch {
1368
+ // The socket went away between the check and the write; fall through to the destroy.
1369
+ }
1370
+ nodeRes.destroy()
1371
+ }
1372
+
1373
+ /** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
1374
+ function writeOutcomeSafely(
1375
+ outcome: NodeServeOutcome,
1376
+ nodeRes: ServerResponse,
1377
+ method?: string,
1378
+ ): void | Promise<void> {
1379
+ try {
1380
+ const written = writeNodeOutcome(outcome, nodeRes, method)
1381
+ return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1382
+ } catch {
1383
+ failWrite(nodeRes)
1384
+ return
1385
+ }
1386
+ }
1387
+
1388
+ /** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
1389
+ * {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
1390
+ function writeResponseSafely(
1391
+ response: Response,
1392
+ nodeRes: ServerResponse,
1393
+ method?: string,
1394
+ ): void | Promise<void> {
1395
+ try {
1396
+ const written = writeNodeResponse(response, nodeRes, method)
1397
+ return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1398
+ } catch {
1399
+ failWrite(nodeRes)
1400
+ return
1401
+ }
1402
+ }
1403
+
1124
1404
  function writeBadRequest(nodeRes: ServerResponse): void {
1125
1405
  const body = "Bad Request"
1126
1406
  nodeRes.writeHead(400, {
@@ -1188,12 +1468,25 @@ function writeJsonOutcome(
1188
1468
  // without it Node falls back to chunked framing, which costs extra wire bytes and client parsing
1189
1469
  // on every response (and no other runtime chunks a buffered JSON body).
1190
1470
  if (outcome.body !== null) {
1191
- if (headers["content-type"] === undefined) headers["content-type"] = JSON_CONTENT_TYPE
1192
- headers["content-length"] = String(Buffer.byteLength(outcome.body))
1471
+ if (headers["content-type"] === undefined) headers["Content-Type"] = JSON_CONTENT_TYPE
1472
+ else {
1473
+ headers["Content-Type"] = headers["content-type"]
1474
+ delete headers["content-type"]
1475
+ }
1476
+ headers["Content-Length"] = String(Buffer.byteLength(outcome.body))
1477
+ delete headers["content-length"]
1193
1478
  } else if (isBodylessStatus(outcome.status)) {
1194
1479
  // 204/205/304 never carry a payload; discard a user/native-hook length even when the body is
1195
1480
  // already represented as null so the direct writer cannot advertise bytes that will not ship.
1196
1481
  delete headers["content-length"]
1482
+ delete headers["Content-Length"]
1483
+ } else if (!isHead && headers["content-length"] === undefined) {
1484
+ // A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
1485
+ // a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
1486
+ // with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
1487
+ // Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
1488
+ // which this lane does not know.
1489
+ headers["Content-Length"] = "0"
1197
1490
  }
1198
1491
  if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
1199
1492
  headers["set-cookie"] = [...outcome.cookies]
@@ -1284,6 +1577,14 @@ function toNodeRequestSource(
1284
1577
  : new LazyNodeRequestSource(req, method, protocol, host)
1285
1578
  }
1286
1579
 
1580
+ function stripNodeMountPrefix(url: string, prefix: string): string {
1581
+ if (prefix === "/") return url
1582
+ const target = new URL(url)
1583
+ const rest = target.pathname.slice(prefix.length)
1584
+ target.pathname = rest === "" ? "/" : rest
1585
+ return target.href
1586
+ }
1587
+
1287
1588
  /**
1288
1589
  * Split an origin-form request target ("/path?q#frag") into pathname + search without synthesizing
1289
1590
  * an absolute URL first. Mirrors `@nifrajs/core`'s `urlPartsOf` for the origin-form case (kept in
@@ -1390,7 +1691,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
1390
1691
  get body(): ReadableStream<Uint8Array> | null {
1391
1692
  if (this.method === "GET" || this.method === "HEAD") return null
1392
1693
  if (this.consumedBody !== undefined) return this.request.body
1393
- this.bodyValue ??= Readable.toWeb(this.nodeReq) as ReadableStream<Uint8Array>
1694
+ this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
1394
1695
  return this.bodyValue
1395
1696
  }
1396
1697
 
@@ -1467,7 +1768,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
1467
1768
  if (this.method === "GET" || this.method === "HEAD") return null
1468
1769
  const consumed = this.consumedBody
1469
1770
  if (consumed !== undefined) return streamOfBytes(consumed)
1470
- this.bodyValue ??= Readable.toWeb(this.nodeReq) as ReadableStream<Uint8Array>
1771
+ this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
1471
1772
  return this.bodyValue
1472
1773
  }
1473
1774
 
@@ -1643,7 +1944,7 @@ function makeWebRequest(
1643
1944
  const init: RequestInit & { duplex?: "half" } = { method, headers }
1644
1945
  if (method !== "GET" && method !== "HEAD") {
1645
1946
  // Stream the body in; `duplex: "half"` is required for a streamed request body.
1646
- init.body = body ?? (Readable.toWeb(req) as ReadableStream<Uint8Array>)
1947
+ init.body = body ?? claimableWebStream(req, "drain")
1647
1948
  init.duplex = "half"
1648
1949
  }
1649
1950
  return new Request(url, init)
@@ -1796,30 +2097,170 @@ function writeNodeResponse(
1796
2097
  // forEach into a fresh plain object plus a second cookie-specific pass. Must run before
1797
2098
  // `writeHead`: headers are already flushed by the time `writeHead` returns.
1798
2099
  nodeRes.setHeaders(response.headers)
1799
- nodeRes.writeHead(response.status)
1800
2100
  if (isHead) {
2101
+ nodeRes.writeHead(response.status)
1801
2102
  nodeRes.end()
1802
2103
  return
1803
2104
  }
1804
2105
  const directBody = nodeResponseBody(response)
1805
2106
  if (directBody !== undefined) {
2107
+ // Node computes the length itself for `end(data)` while the header is still unflushed, so this
2108
+ // lane already declares one. The three below do not get that for free.
2109
+ nodeRes.writeHead(response.status)
1806
2110
  nodeRes.end(directBody)
1807
2111
  return
1808
2112
  }
2113
+ // `end()` with no data is framed as chunked instead, so a body-less `Response` - a hand-rolled
2114
+ // redirect, above all - went out with a chunk terminator and no length where every Web-native
2115
+ // runtime sends `content-length: 0`. Declared before `writeHead`, the last point a header can
2116
+ // still be added. Excluded: HEAD, whose length describes the GET's body that this lane does not
2117
+ // know; a status that cannot carry a body; and a length the caller set for itself.
2118
+ const canDeclareLength =
2119
+ !isBodylessStatus(response.status) && !response.headers.has("content-length")
1809
2120
  if (response.body === null) {
2121
+ if (canDeclareLength) nodeRes.setHeader("content-length", "0")
2122
+ nodeRes.writeHead(response.status)
1810
2123
  if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
1811
2124
  return
1812
2125
  }
1813
- return writeNodeResponseBody(response, nodeRes)
2126
+ // A body that is a Web view over a Node stream (an upstream response relayed by
2127
+ // `@nifrajs/proxy/undici`, say) goes to the socket as the Node stream it already is, skipping the
2128
+ // per-chunk trip through Web objects that the reader loop below pays for. Length is the upstream's
2129
+ // business: it either forwarded a `content-length` or is genuinely streaming.
2130
+ const nodeBody = response.bodyUsed ? null : claimNodeStream(response.body)
2131
+ if (nodeBody !== null) {
2132
+ nodeRes.writeHead(response.status)
2133
+ return pipeNodeResponseBody(nodeBody, nodeRes)
2134
+ }
2135
+ return writeNodeResponseBody(response, nodeRes, canDeclareLength)
2136
+ }
2137
+
2138
+ /**
2139
+ * Send a claimed Node body straight to the socket.
2140
+ *
2141
+ * `pipe` rather than `pipeline`: the status line is already flushed by here, so the only teardown
2142
+ * still available is a destroy on both ends, and `pipeline`'s per-request `eos` machinery costs
2143
+ * more than it buys at this point - it measured as a net loss against the Web reader loop it
2144
+ * replaces, where plain `pipe` measured as a win on both GET and POST.
2145
+ *
2146
+ * The two destroys below are belt-and-braces, not the mechanism: for a body claimed from
2147
+ * `@nifrajs/proxy`, the request's abort signal already tears the upstream down when the client
2148
+ * hangs up, and an upstream that dies mid-body already reaches the client as a broken transfer
2149
+ * rather than a short `200`. They are kept because `pipe` itself guarantees neither, and the claim
2150
+ * seam is open to any transport - including one that wires no signal.
2151
+ */
2152
+ function pipeNodeResponseBody(body: Readable, nodeRes: ServerResponse): Promise<void> {
2153
+ return new Promise<void>((resolve) => {
2154
+ let settled = false
2155
+ const done = (): void => {
2156
+ if (settled) return
2157
+ settled = true
2158
+ if (!body.destroyed) body.destroy()
2159
+ resolve()
2160
+ }
2161
+ // Attached before anything here can destroy `body`, and with `on` rather than `once`, because
2162
+ // destroying an upstream body emits `error` (undici raises `RequestAbortedError` for a request
2163
+ // cut short) and an unhandled `error` on a Node stream terminates the process. Every path below
2164
+ // ends in a destroy, including the ones that run after this listener has already fired once.
2165
+ body.on("error", () => {
2166
+ // Only meaningful before the response completes; afterwards there is nothing left to abort.
2167
+ if (!settled && !nodeRes.destroyed && !nodeRes.writableEnded) nodeRes.destroy()
2168
+ done()
2169
+ })
2170
+ // The client is already gone: `pipe` would wait on `close`/`finish` that have both fired.
2171
+ if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
2172
+ done()
2173
+ return
2174
+ }
2175
+ nodeRes.once("close", done)
2176
+ nodeRes.once("finish", done)
2177
+ body.pipe(nodeRes)
2178
+ })
1814
2179
  }
1815
2180
 
1816
- async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse): Promise<void> {
2181
+ /** The two Web-stream shapes the body lane below needs, named structurally: this package builds
2182
+ * without the DOM lib, so `ReadableStreamReadResult` is not a name it can refer to. */
2183
+ type BodyChunk =
2184
+ | { readonly done: false; readonly value: Uint8Array }
2185
+ | { readonly done: true; readonly value?: undefined }
2186
+ type BodyReader = { read(): Promise<BodyChunk>; cancel(): Promise<void> }
2187
+
2188
+ /**
2189
+ * Send a Web `ReadableStream` body to the socket, declaring a length when the body turns out to be
2190
+ * one already-complete chunk.
2191
+ *
2192
+ * `new Response("hi")` hands its bytes over as a stream, exactly as a live producer does, so the
2193
+ * framing decision looks identical from here and Node picks chunked for both. The two are told apart
2194
+ * by reading one chunk and then giving the stream a microtask to say it is done: a source-backed body
2195
+ * - a string, a `Uint8Array`, a `Blob` - has already enqueued everything and closes inside it, while
2196
+ * a producer still generating does not.
2197
+ *
2198
+ * What is held is one chunk, never the whole body, so a large or endless stream is unaffected in
2199
+ * memory. What is spent is a microtask, and it costs a streaming response no bytes on the wire: Node
2200
+ * does not flush the header until the first write either way, so nothing was going out during it.
2201
+ */
2202
+ async function writeNodeResponseBody(
2203
+ response: Response,
2204
+ nodeRes: ServerResponse,
2205
+ canDeclareLength: boolean,
2206
+ ): Promise<void> {
1817
2207
  const reader = response.body!.getReader()
2208
+ let first: BodyChunk
2209
+ try {
2210
+ first = await reader.read()
2211
+ } catch {
2212
+ await reader.cancel().catch(() => {})
2213
+ if (!nodeRes.headersSent) writeInternalError(nodeRes)
2214
+ else nodeRes.destroy()
2215
+ return
2216
+ }
2217
+ if (first.done) {
2218
+ if (canDeclareLength) nodeRes.setHeader("content-length", "0")
2219
+ nodeRes.writeHead(response.status)
2220
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
2221
+ return
2222
+ }
2223
+ // Attached with `then` rather than awaited: the point is to observe whether it has settled by the
2224
+ // end of this turn, not to wait for it. The rejection handler is only here so a body that fails
2225
+ // between the two reads is not an unhandled rejection - the drain below awaits the same promise
2226
+ // and is where that failure is actually handled.
2227
+ const pending = reader.read()
2228
+ let settled: BodyChunk | undefined
2229
+ pending.then(
2230
+ (value) => {
2231
+ settled = value
2232
+ },
2233
+ () => {},
2234
+ )
2235
+ // A microtask, not a turn of the event loop. It is enough - a source-backed body has already
2236
+ // enqueued everything, so its close is a microtask away on Node and two on Bun, both of which this
2237
+ // covers - and it is short enough to leave the socket's write order alone: yielding a whole turn
2238
+ // here wedged a pipelined keep-alive connection under Bun's `node:http` shim, where the second
2239
+ // response never reached the wire.
2240
+ await new Promise<void>((resolve) => queueMicrotask(resolve))
2241
+ if (settled?.done === true && canDeclareLength && ArrayBuffer.isView(first.value)) {
2242
+ nodeRes.setHeader("content-length", String(first.value.byteLength))
2243
+ nodeRes.writeHead(response.status)
2244
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end(first.value)
2245
+ return
2246
+ }
2247
+ nodeRes.writeHead(response.status)
2248
+ return drainWebResponseBody(reader, nodeRes, first.value, pending)
2249
+ }
2250
+
2251
+ /** The streaming half of {@link writeNodeResponseBody}, resumed from the chunk and the outstanding
2252
+ * read the length probe already took off the stream. */
2253
+ async function drainWebResponseBody(
2254
+ reader: BodyReader,
2255
+ nodeRes: ServerResponse,
2256
+ firstChunk: Uint8Array,
2257
+ pending: Promise<BodyChunk>,
2258
+ ): Promise<void> {
2259
+ let chunk: Uint8Array | undefined = firstChunk
2260
+ let outstanding: Promise<BodyChunk> | undefined = pending
1818
2261
  try {
1819
2262
  for (;;) {
1820
- const { done, value } = await reader.read()
1821
- if (done) break
1822
- if (!nodeRes.write(value)) {
2263
+ if (!nodeRes.write(chunk)) {
1823
2264
  if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
1824
2265
  try {
1825
2266
  await reader.cancel()
@@ -1834,6 +2275,10 @@ async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse
1834
2275
  return
1835
2276
  }
1836
2277
  }
2278
+ const result = await (outstanding ?? reader.read())
2279
+ outstanding = undefined
2280
+ if (result.done) break
2281
+ chunk = result.value
1837
2282
  }
1838
2283
  } catch {
1839
2284
  await reader.cancel().catch(() => {})