@nifrajs/node 2.13.0 → 3.1.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(() => {})
@@ -918,14 +1005,20 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
918
1005
  try {
919
1006
  const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy)
920
1007
  if (handled instanceof Promise) {
921
- void handled.finally(() => {
922
- inFlight -= 1
923
- })
1008
+ // `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
1009
+ // observes, and Node terminates the process on an unhandled rejection by default. Every
1010
+ // writer already ends its own failures (`failWrite`), so anything arriving here is a fault
1011
+ // no response can still be built from - the connection is the only thing left to end.
1012
+ void handled
1013
+ .catch(() => failWrite(nodeRes))
1014
+ .finally(() => {
1015
+ inFlight -= 1
1016
+ })
924
1017
  return
925
1018
  }
926
1019
  inFlight -= 1
927
1020
  } catch {
928
- writeInternalError(nodeRes)
1021
+ failWrite(nodeRes)
929
1022
  inFlight -= 1
930
1023
  }
931
1024
  })
@@ -992,6 +1085,66 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
992
1085
  })
993
1086
  }
994
1087
 
1088
+ /** Run the existing Node-direct/Web bridge after a native mount either was not selected or declined
1089
+ * because its optional transport was unavailable. Kept as a top-level helper so ordinary requests
1090
+ * do not allocate a per-request closure merely to preserve the fallback. */
1091
+ function runNodeSource(
1092
+ app: FetchHandler,
1093
+ source: NodeRequestSource,
1094
+ nodeReq: IncomingMessage,
1095
+ nodeRes: ServerResponse,
1096
+ protocol: RequestProtocol,
1097
+ host: string,
1098
+ platform: NodePlatform | undefined,
1099
+ ): void | Promise<void> {
1100
+ const resolveNodeSource = (app as Partial<NodeFastHandler>).resolveNodeSource
1101
+ if (typeof resolveNodeSource === "function") {
1102
+ try {
1103
+ const outcome = resolveNodeSource.call(app, source, platform, NODE_OUTCOME_RUNTIME)
1104
+ return outcome instanceof Promise
1105
+ ? outcome.then(
1106
+ (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1107
+ () => failWrite(nodeRes),
1108
+ )
1109
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1110
+ } catch {
1111
+ failWrite(nodeRes)
1112
+ return
1113
+ }
1114
+ }
1115
+
1116
+ const request = toWebRequest(nodeReq, protocol, host)
1117
+ const resolveNode = (app as Partial<NodeFastHandler>).resolveNode
1118
+ if (typeof resolveNode === "function") {
1119
+ try {
1120
+ const outcome = resolveNode.call(app, request, platform)
1121
+ return outcome instanceof Promise
1122
+ ? outcome.then(
1123
+ (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1124
+ () => failWrite(nodeRes),
1125
+ )
1126
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1127
+ } catch {
1128
+ failWrite(nodeRes)
1129
+ return
1130
+ }
1131
+ }
1132
+
1133
+ try {
1134
+ const response = app.fetch(request, platform)
1135
+ return response instanceof Promise
1136
+ ? response.then(
1137
+ (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1138
+ () => failWrite(nodeRes),
1139
+ )
1140
+ : writeResponseSafely(response, nodeRes, nodeReq.method)
1141
+ } catch {
1142
+ // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
1143
+ failWrite(nodeRes)
1144
+ return
1145
+ }
1146
+ }
1147
+
995
1148
  function handle(
996
1149
  app: FetchHandler,
997
1150
  nodeReq: IncomingMessage,
@@ -1032,38 +1185,65 @@ function handle(
1032
1185
  if (staticState !== undefined && (nodeReq.method === "GET" || nodeReq.method === "HEAD")) {
1033
1186
  const matched = staticMatch(staticState, nodeReq.url ?? "/")
1034
1187
  if (matched !== "pass") {
1035
- if ("reject" in matched) return writeNodeResponse(matched.reject, nodeRes)
1188
+ if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
1036
1189
  return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then(
1037
- (outcome) => writeNodeOutcome(outcome, nodeRes),
1038
- () => writeInternalError(nodeRes),
1190
+ (outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
1191
+ () => failWrite(nodeRes),
1039
1192
  )
1040
1193
  }
1041
1194
  }
1042
1195
 
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.
1196
+ // A mounted handler may advertise a native Node lane. Core has already checked route precedence
1197
+ // and every global lifecycle gate before returning this selection. The handler writes directly to
1198
+ // `nodeRes`; `false` means its optional transport was unavailable, so the same untouched source
1199
+ // continues through the ordinary Web/direct path below.
1048
1200
  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),
1060
- () => writeInternalError(nodeRes),
1061
- )
1062
- : writeNodeOutcome(outcome, nodeRes)
1063
- } catch {
1064
- writeInternalError(nodeRes)
1065
- return
1201
+ const resolveNodeMount = (app as unknown as Record<symbol, unknown>)[RESOLVE_NODE_MOUNT]
1202
+ if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
1203
+ const nodeSource = toNodeRequestSource(nodeReq, protocol, host)
1204
+
1205
+ if (typeof resolveNodeMount === "function") {
1206
+ let selection: NodeNativeMountSelection | undefined
1207
+ try {
1208
+ selection = (
1209
+ resolveNodeMount as (
1210
+ source: NodeRequestSource,
1211
+ platform?: NodePlatform,
1212
+ ) => NodeNativeMountSelection | undefined
1213
+ ).call(app, nodeSource, platform)
1214
+ } catch {
1215
+ writeInternalError(nodeRes)
1216
+ return
1217
+ }
1218
+ if (selection !== undefined) {
1219
+ const nativeRequest: NodeNativeMountRequest = {
1220
+ method: nodeReq.method ?? "GET",
1221
+ url: selection.stripPrefix
1222
+ ? stripNodeMountPrefix(`${protocol}://${host}${nodeReq.url ?? "/"}`, selection.path)
1223
+ : `${protocol}://${host}${nodeReq.url ?? "/"}`,
1224
+ headers: nodeReq.headers,
1225
+ raw: nodeReq,
1226
+ }
1227
+ try {
1228
+ const outcome = selection.handler(nativeRequest, nodeRes, platform)
1229
+ if (outcome instanceof Promise) {
1230
+ return outcome.then(
1231
+ (handled) =>
1232
+ handled === false
1233
+ ? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
1234
+ : undefined,
1235
+ () => failWrite(nodeRes),
1236
+ )
1237
+ }
1238
+ if (outcome !== false) return
1239
+ } catch {
1240
+ failWrite(nodeRes)
1241
+ return
1242
+ }
1243
+ }
1066
1244
  }
1245
+
1246
+ return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
1067
1247
  }
1068
1248
 
1069
1249
  const request = toWebRequest(nodeReq, protocol, host)
@@ -1073,12 +1253,12 @@ function handle(
1073
1253
  const outcome = resolveNode.call(app, request, platform)
1074
1254
  return outcome instanceof Promise
1075
1255
  ? outcome.then(
1076
- (settled) => writeNodeOutcome(settled, nodeRes),
1077
- () => writeInternalError(nodeRes),
1256
+ (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1257
+ () => failWrite(nodeRes),
1078
1258
  )
1079
- : writeNodeOutcome(outcome, nodeRes)
1259
+ : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1080
1260
  } catch {
1081
- writeInternalError(nodeRes)
1261
+ failWrite(nodeRes)
1082
1262
  return
1083
1263
  }
1084
1264
  }
@@ -1087,13 +1267,13 @@ function handle(
1087
1267
  const response = app.fetch(request, platform)
1088
1268
  return response instanceof Promise
1089
1269
  ? response.then(
1090
- (settled) => writeNodeResponse(settled, nodeRes),
1091
- () => writeInternalError(nodeRes),
1270
+ (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1271
+ () => failWrite(nodeRes),
1092
1272
  )
1093
- : writeNodeResponse(response, nodeRes)
1273
+ : writeResponseSafely(response, nodeRes, nodeReq.method)
1094
1274
  } catch {
1095
1275
  // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
1096
- writeInternalError(nodeRes)
1276
+ failWrite(nodeRes)
1097
1277
  return
1098
1278
  }
1099
1279
  }
@@ -1101,16 +1281,18 @@ function handle(
1101
1281
  function writeNodeOutcome(
1102
1282
  outcome: NodeServeOutcome,
1103
1283
  nodeRes: ServerResponse,
1284
+ method?: string,
1104
1285
  ): void | Promise<void> {
1286
+ const isHead = method?.toUpperCase() === "HEAD"
1105
1287
  if (outcome.kind === "json") {
1106
- writeJsonOutcome(outcome, nodeRes)
1288
+ writeJsonOutcome(outcome, nodeRes, isHead)
1107
1289
  return
1108
1290
  }
1109
1291
  if (outcome.kind === "body") {
1110
- writeBodyOutcome(outcome, nodeRes)
1292
+ writeBodyOutcome(outcome, nodeRes, isHead)
1111
1293
  return
1112
1294
  }
1113
- return writeNodeResponse(outcome.response, nodeRes)
1295
+ return writeNodeResponse(outcome.response, nodeRes, method)
1114
1296
  }
1115
1297
 
1116
1298
  /** A flat 500 with no leaked detail - the adapter's last-resort guard if a handler throws. */
@@ -1119,6 +1301,62 @@ function writeInternalError(nodeRes: ServerResponse): void {
1119
1301
  nodeRes.end(INTERNAL_ERROR_BODY)
1120
1302
  }
1121
1303
 
1304
+ /**
1305
+ * End a request whose WRITE failed, from anywhere a write can fail.
1306
+ *
1307
+ * `writeHead` throws on an invalid status or a header value carrying CR/LF - reachable whenever an
1308
+ * app reflects request data into `status(...)`/`c.set.headers`, which on the Web lane the `Headers`
1309
+ * constructor rejects instead. It also throws once the head is already out. Both have to end here:
1310
+ * on the async lanes the write runs inside a `.then` callback whose rejection nothing downstream
1311
+ * catches (`serve` only observes `finally`), and Node's default for an unhandled rejection is to
1312
+ * terminate the process - a route-shaped input turning into a server-wide DoS.
1313
+ *
1314
+ * A 500 while the head is unsent, otherwise a destroy: there is no way to correct a response whose
1315
+ * status line already shipped, and a half-written body must not be left for the client to parse.
1316
+ */
1317
+ function failWrite(nodeRes: ServerResponse): void {
1318
+ try {
1319
+ if (!nodeRes.headersSent) {
1320
+ writeInternalError(nodeRes)
1321
+ return
1322
+ }
1323
+ } catch {
1324
+ // The socket went away between the check and the write; fall through to the destroy.
1325
+ }
1326
+ nodeRes.destroy()
1327
+ }
1328
+
1329
+ /** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
1330
+ function writeOutcomeSafely(
1331
+ outcome: NodeServeOutcome,
1332
+ nodeRes: ServerResponse,
1333
+ method?: string,
1334
+ ): void | Promise<void> {
1335
+ try {
1336
+ const written = writeNodeOutcome(outcome, nodeRes, method)
1337
+ return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1338
+ } catch {
1339
+ failWrite(nodeRes)
1340
+ return
1341
+ }
1342
+ }
1343
+
1344
+ /** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
1345
+ * {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
1346
+ function writeResponseSafely(
1347
+ response: Response,
1348
+ nodeRes: ServerResponse,
1349
+ method?: string,
1350
+ ): void | Promise<void> {
1351
+ try {
1352
+ const written = writeNodeResponse(response, nodeRes, method)
1353
+ return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1354
+ } catch {
1355
+ failWrite(nodeRes)
1356
+ return
1357
+ }
1358
+ }
1359
+
1122
1360
  function writeBadRequest(nodeRes: ServerResponse): void {
1123
1361
  const body = "Bad Request"
1124
1362
  nodeRes.writeHead(400, {
@@ -1149,6 +1387,7 @@ function allHeaderKeysLowercase(record: Readonly<Record<string, unknown>>): bool
1149
1387
  function writeJsonOutcome(
1150
1388
  outcome: Extract<NodeServeOutcome, { kind: "json" }>,
1151
1389
  nodeRes: ServerResponse,
1390
+ isHead = false,
1152
1391
  ): void {
1153
1392
  // The outcome's record is the request's own (`c.set.headers`, already mutated by any native
1154
1393
  // response hooks), and its writers - middleware twins and the framework's own additions - emit
@@ -1191,13 +1430,23 @@ function writeJsonOutcome(
1191
1430
  // 204/205/304 never carry a payload; discard a user/native-hook length even when the body is
1192
1431
  // already represented as null so the direct writer cannot advertise bytes that will not ship.
1193
1432
  delete headers["content-length"]
1433
+ } else if (!isHead && headers["content-length"] === undefined) {
1434
+ // A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
1435
+ // a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
1436
+ // with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
1437
+ // Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
1438
+ // which this lane does not know.
1439
+ headers["content-length"] = "0"
1194
1440
  }
1195
1441
  if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
1196
1442
  headers["set-cookie"] = [...outcome.cookies]
1197
1443
  }
1198
1444
  nodeRes.writeHead(outcome.status, headers)
1199
- if (outcome.body !== null) nodeRes.end(outcome.body)
1200
- else nodeRes.end()
1445
+ if (isHead || outcome.body === null) {
1446
+ nodeRes.end()
1447
+ } else {
1448
+ nodeRes.end(outcome.body)
1449
+ }
1201
1450
  }
1202
1451
 
1203
1452
  /**
@@ -1208,6 +1457,7 @@ function writeJsonOutcome(
1208
1457
  function writeBodyOutcome(
1209
1458
  outcome: Extract<NodeServeOutcome, { kind: "body" }>,
1210
1459
  nodeRes: ServerResponse,
1460
+ isHead = false,
1211
1461
  ): void {
1212
1462
  // Body outcomes already carry response-normalized header names and values. Node consumes the same
1213
1463
  // mutable runtime shapes (`string` / `string[]`); avoid cloning this record and every Set-Cookie
@@ -1236,7 +1486,8 @@ function writeBodyOutcome(
1236
1486
  }
1237
1487
  nodeRes.writeHead(outcome.status, headers)
1238
1488
  }
1239
- nodeRes.end(outcome.body)
1489
+ if (isHead) nodeRes.end()
1490
+ else nodeRes.end(outcome.body)
1240
1491
  }
1241
1492
 
1242
1493
  const byteLengthOf = (body: string | Uint8Array): number =>
@@ -1276,6 +1527,14 @@ function toNodeRequestSource(
1276
1527
  : new LazyNodeRequestSource(req, method, protocol, host)
1277
1528
  }
1278
1529
 
1530
+ function stripNodeMountPrefix(url: string, prefix: string): string {
1531
+ if (prefix === "/") return url
1532
+ const target = new URL(url)
1533
+ const rest = target.pathname.slice(prefix.length)
1534
+ target.pathname = rest === "" ? "/" : rest
1535
+ return target.href
1536
+ }
1537
+
1279
1538
  /**
1280
1539
  * Split an origin-form request target ("/path?q#frag") into pathname + search without synthesizing
1281
1540
  * an absolute URL first. Mirrors `@nifrajs/core`'s `urlPartsOf` for the origin-form case (kept in
@@ -1382,7 +1641,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
1382
1641
  get body(): ReadableStream<Uint8Array> | null {
1383
1642
  if (this.method === "GET" || this.method === "HEAD") return null
1384
1643
  if (this.consumedBody !== undefined) return this.request.body
1385
- this.bodyValue ??= Readable.toWeb(this.nodeReq) as ReadableStream<Uint8Array>
1644
+ this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
1386
1645
  return this.bodyValue
1387
1646
  }
1388
1647
 
@@ -1459,7 +1718,7 @@ class LazyNodeRequestSource implements NodeRequestSource {
1459
1718
  if (this.method === "GET" || this.method === "HEAD") return null
1460
1719
  const consumed = this.consumedBody
1461
1720
  if (consumed !== undefined) return streamOfBytes(consumed)
1462
- this.bodyValue ??= Readable.toWeb(this.nodeReq) as ReadableStream<Uint8Array>
1721
+ this.bodyValue ??= claimableWebStream(this.nodeReq, "drain")
1463
1722
  return this.bodyValue
1464
1723
  }
1465
1724
 
@@ -1635,7 +1894,7 @@ function makeWebRequest(
1635
1894
  const init: RequestInit & { duplex?: "half" } = { method, headers }
1636
1895
  if (method !== "GET" && method !== "HEAD") {
1637
1896
  // Stream the body in; `duplex: "half"` is required for a streamed request body.
1638
- init.body = body ?? (Readable.toWeb(req) as ReadableStream<Uint8Array>)
1897
+ init.body = body ?? claimableWebStream(req, "drain")
1639
1898
  init.duplex = "half"
1640
1899
  }
1641
1900
  return new Request(url, init)
@@ -1775,7 +2034,12 @@ function waitForDrain(nodeRes: ServerResponse): Promise<boolean> {
1775
2034
  })
1776
2035
  }
1777
2036
 
1778
- function writeNodeResponse(response: Response, nodeRes: ServerResponse): void | Promise<void> {
2037
+ function writeNodeResponse(
2038
+ response: Response,
2039
+ nodeRes: ServerResponse,
2040
+ method?: string,
2041
+ ): void | Promise<void> {
2042
+ const isHead = method?.toUpperCase() === "HEAD"
1779
2043
  // `ServerResponse.setHeaders` (Node 18.14+) takes the Headers object directly and iterates its
1780
2044
  // native Symbol.iterator, which - unlike `Headers.forEach`/`.get()` - never comma-joins repeated
1781
2045
  // `Set-Cookie` values (Node's own implementation carries the identical correctness note this
@@ -1783,26 +2047,170 @@ function writeNodeResponse(response: Response, nodeRes: ServerResponse): void |
1783
2047
  // forEach into a fresh plain object plus a second cookie-specific pass. Must run before
1784
2048
  // `writeHead`: headers are already flushed by the time `writeHead` returns.
1785
2049
  nodeRes.setHeaders(response.headers)
1786
- nodeRes.writeHead(response.status)
2050
+ if (isHead) {
2051
+ nodeRes.writeHead(response.status)
2052
+ nodeRes.end()
2053
+ return
2054
+ }
1787
2055
  const directBody = nodeResponseBody(response)
1788
2056
  if (directBody !== undefined) {
2057
+ // Node computes the length itself for `end(data)` while the header is still unflushed, so this
2058
+ // lane already declares one. The three below do not get that for free.
2059
+ nodeRes.writeHead(response.status)
1789
2060
  nodeRes.end(directBody)
1790
2061
  return
1791
2062
  }
2063
+ // `end()` with no data is framed as chunked instead, so a body-less `Response` - a hand-rolled
2064
+ // redirect, above all - went out with a chunk terminator and no length where every Web-native
2065
+ // runtime sends `content-length: 0`. Declared before `writeHead`, the last point a header can
2066
+ // still be added. Excluded: HEAD, whose length describes the GET's body that this lane does not
2067
+ // know; a status that cannot carry a body; and a length the caller set for itself.
2068
+ const canDeclareLength =
2069
+ !isBodylessStatus(response.status) && !response.headers.has("content-length")
1792
2070
  if (response.body === null) {
2071
+ if (canDeclareLength) nodeRes.setHeader("content-length", "0")
2072
+ nodeRes.writeHead(response.status)
1793
2073
  if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
1794
2074
  return
1795
2075
  }
1796
- return writeNodeResponseBody(response, nodeRes)
2076
+ // A body that is a Web view over a Node stream (an upstream response relayed by
2077
+ // `@nifrajs/proxy/undici`, say) goes to the socket as the Node stream it already is, skipping the
2078
+ // per-chunk trip through Web objects that the reader loop below pays for. Length is the upstream's
2079
+ // business: it either forwarded a `content-length` or is genuinely streaming.
2080
+ const nodeBody = response.bodyUsed ? null : claimNodeStream(response.body)
2081
+ if (nodeBody !== null) {
2082
+ nodeRes.writeHead(response.status)
2083
+ return pipeNodeResponseBody(nodeBody, nodeRes)
2084
+ }
2085
+ return writeNodeResponseBody(response, nodeRes, canDeclareLength)
1797
2086
  }
1798
2087
 
1799
- async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse): Promise<void> {
2088
+ /**
2089
+ * Send a claimed Node body straight to the socket.
2090
+ *
2091
+ * `pipe` rather than `pipeline`: the status line is already flushed by here, so the only teardown
2092
+ * still available is a destroy on both ends, and `pipeline`'s per-request `eos` machinery costs
2093
+ * more than it buys at this point - it measured as a net loss against the Web reader loop it
2094
+ * replaces, where plain `pipe` measured as a win on both GET and POST.
2095
+ *
2096
+ * The two destroys below are belt-and-braces, not the mechanism: for a body claimed from
2097
+ * `@nifrajs/proxy`, the request's abort signal already tears the upstream down when the client
2098
+ * hangs up, and an upstream that dies mid-body already reaches the client as a broken transfer
2099
+ * rather than a short `200`. They are kept because `pipe` itself guarantees neither, and the claim
2100
+ * seam is open to any transport - including one that wires no signal.
2101
+ */
2102
+ function pipeNodeResponseBody(body: Readable, nodeRes: ServerResponse): Promise<void> {
2103
+ return new Promise<void>((resolve) => {
2104
+ let settled = false
2105
+ const done = (): void => {
2106
+ if (settled) return
2107
+ settled = true
2108
+ if (!body.destroyed) body.destroy()
2109
+ resolve()
2110
+ }
2111
+ // Attached before anything here can destroy `body`, and with `on` rather than `once`, because
2112
+ // destroying an upstream body emits `error` (undici raises `RequestAbortedError` for a request
2113
+ // cut short) and an unhandled `error` on a Node stream terminates the process. Every path below
2114
+ // ends in a destroy, including the ones that run after this listener has already fired once.
2115
+ body.on("error", () => {
2116
+ // Only meaningful before the response completes; afterwards there is nothing left to abort.
2117
+ if (!settled && !nodeRes.destroyed && !nodeRes.writableEnded) nodeRes.destroy()
2118
+ done()
2119
+ })
2120
+ // The client is already gone: `pipe` would wait on `close`/`finish` that have both fired.
2121
+ if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
2122
+ done()
2123
+ return
2124
+ }
2125
+ nodeRes.once("close", done)
2126
+ nodeRes.once("finish", done)
2127
+ body.pipe(nodeRes)
2128
+ })
2129
+ }
2130
+
2131
+ /** The two Web-stream shapes the body lane below needs, named structurally: this package builds
2132
+ * without the DOM lib, so `ReadableStreamReadResult` is not a name it can refer to. */
2133
+ type BodyChunk =
2134
+ | { readonly done: false; readonly value: Uint8Array }
2135
+ | { readonly done: true; readonly value?: undefined }
2136
+ type BodyReader = { read(): Promise<BodyChunk>; cancel(): Promise<void> }
2137
+
2138
+ /**
2139
+ * Send a Web `ReadableStream` body to the socket, declaring a length when the body turns out to be
2140
+ * one already-complete chunk.
2141
+ *
2142
+ * `new Response("hi")` hands its bytes over as a stream, exactly as a live producer does, so the
2143
+ * framing decision looks identical from here and Node picks chunked for both. The two are told apart
2144
+ * by reading one chunk and then giving the stream a microtask to say it is done: a source-backed body
2145
+ * - a string, a `Uint8Array`, a `Blob` - has already enqueued everything and closes inside it, while
2146
+ * a producer still generating does not.
2147
+ *
2148
+ * What is held is one chunk, never the whole body, so a large or endless stream is unaffected in
2149
+ * memory. What is spent is a microtask, and it costs a streaming response no bytes on the wire: Node
2150
+ * does not flush the header until the first write either way, so nothing was going out during it.
2151
+ */
2152
+ async function writeNodeResponseBody(
2153
+ response: Response,
2154
+ nodeRes: ServerResponse,
2155
+ canDeclareLength: boolean,
2156
+ ): Promise<void> {
1800
2157
  const reader = response.body!.getReader()
2158
+ let first: BodyChunk
2159
+ try {
2160
+ first = await reader.read()
2161
+ } catch {
2162
+ await reader.cancel().catch(() => {})
2163
+ if (!nodeRes.headersSent) writeInternalError(nodeRes)
2164
+ else nodeRes.destroy()
2165
+ return
2166
+ }
2167
+ if (first.done) {
2168
+ if (canDeclareLength) nodeRes.setHeader("content-length", "0")
2169
+ nodeRes.writeHead(response.status)
2170
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end()
2171
+ return
2172
+ }
2173
+ // Attached with `then` rather than awaited: the point is to observe whether it has settled by the
2174
+ // end of this turn, not to wait for it. The rejection handler is only here so a body that fails
2175
+ // between the two reads is not an unhandled rejection - the drain below awaits the same promise
2176
+ // and is where that failure is actually handled.
2177
+ const pending = reader.read()
2178
+ let settled: BodyChunk | undefined
2179
+ pending.then(
2180
+ (value) => {
2181
+ settled = value
2182
+ },
2183
+ () => {},
2184
+ )
2185
+ // A microtask, not a turn of the event loop. It is enough - a source-backed body has already
2186
+ // enqueued everything, so its close is a microtask away on Node and two on Bun, both of which this
2187
+ // covers - and it is short enough to leave the socket's write order alone: yielding a whole turn
2188
+ // here wedged a pipelined keep-alive connection under Bun's `node:http` shim, where the second
2189
+ // response never reached the wire.
2190
+ await new Promise<void>((resolve) => queueMicrotask(resolve))
2191
+ if (settled?.done === true && canDeclareLength && ArrayBuffer.isView(first.value)) {
2192
+ nodeRes.setHeader("content-length", String(first.value.byteLength))
2193
+ nodeRes.writeHead(response.status)
2194
+ if (!nodeRes.destroyed && !nodeRes.writableEnded && nodeRes.writable) nodeRes.end(first.value)
2195
+ return
2196
+ }
2197
+ nodeRes.writeHead(response.status)
2198
+ return drainWebResponseBody(reader, nodeRes, first.value, pending)
2199
+ }
2200
+
2201
+ /** The streaming half of {@link writeNodeResponseBody}, resumed from the chunk and the outstanding
2202
+ * read the length probe already took off the stream. */
2203
+ async function drainWebResponseBody(
2204
+ reader: BodyReader,
2205
+ nodeRes: ServerResponse,
2206
+ firstChunk: Uint8Array,
2207
+ pending: Promise<BodyChunk>,
2208
+ ): Promise<void> {
2209
+ let chunk: Uint8Array | undefined = firstChunk
2210
+ let outstanding: Promise<BodyChunk> | undefined = pending
1801
2211
  try {
1802
2212
  for (;;) {
1803
- const { done, value } = await reader.read()
1804
- if (done) break
1805
- if (!nodeRes.write(value)) {
2213
+ if (!nodeRes.write(chunk)) {
1806
2214
  if (nodeRes.destroyed || nodeRes.writableEnded || !nodeRes.writable) {
1807
2215
  try {
1808
2216
  await reader.cancel()
@@ -1817,6 +2225,10 @@ async function writeNodeResponseBody(response: Response, nodeRes: ServerResponse
1817
2225
  return
1818
2226
  }
1819
2227
  }
2228
+ const result = await (outstanding ?? reader.read())
2229
+ outstanding = undefined
2230
+ if (result.done) break
2231
+ chunk = result.value
1820
2232
  }
1821
2233
  } catch {
1822
2234
  await reader.cancel().catch(() => {})