@nifrajs/node 4.0.0 → 5.0.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,8 +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"
15
- import { open, realpath } from "node:fs/promises"
14
+ import { realpath } from "node:fs/promises"
16
15
  import {
17
16
  createServer,
18
17
  type IncomingHttpHeaders,
@@ -31,6 +30,7 @@ import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.ts"
31
30
  import type { NodeServeOutcome } from "./generated/node-outcome.ts"
32
31
  import { hasDotSegment, resolveDotSegments } from "./generated/request-target.ts"
33
32
  import { claimableWebStream, claimNodeStream } from "./node-stream.ts"
33
+ import { openResolved } from "./open-resolved.ts"
34
34
 
35
35
  /** The runtime platform a nifra app accepts as `fetch`'s 2nd arg - here, the observed socket peer. */
36
36
  interface NodePlatform {
@@ -97,6 +97,7 @@ type WsUpgradeOutcome =
97
97
  interface WsSocket {
98
98
  send(data: string | ArrayBufferView | ArrayBuffer): void
99
99
  close(code?: number, reason?: string): void
100
+ terminate(): void
100
101
  readonly readyState: number
101
102
  on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): void
102
103
  on(event: "close", listener: (code: number, reason: Buffer) => void): void
@@ -210,6 +211,15 @@ const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount)
210
211
  const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json"
211
212
 
212
213
  const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}'
214
+ /** A handler's Response whose body was already sent: refused, since its headers were written for the
215
+ * request it already answered. */
216
+ const SENT_BODY_OUTCOME: NodeServeOutcome = Object.freeze({
217
+ kind: "json",
218
+ status: 500,
219
+ headers: undefined,
220
+ cookies: undefined,
221
+ body: INTERNAL_ERROR_BODY,
222
+ })
213
223
  const EMPTY_BUFFER = Buffer.alloc(0)
214
224
  const NODE_RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
215
225
  const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
@@ -256,14 +266,15 @@ const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
256
266
  return {
257
267
  kind: "body",
258
268
  status: body.status,
259
- headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
269
+ headers: applySetToNodeHeaders(body.headers, body.status, set),
260
270
  body: isBodylessStatus(body.status) ? new Uint8Array(0) : body.body,
261
271
  }
262
272
  }
263
- return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set._cookies))
273
+ return nodeOutcomeFromResponse(applySetToResponse(result.toResponse(), set))
264
274
  }
265
275
  if (result instanceof Response) {
266
- return nodeOutcomeFromResponse(appendCookiesToResponse(result, set._cookies))
276
+ if (result.bodyUsed) return SENT_BODY_OUTCOME
277
+ return nodeOutcomeFromResponse(applySetToResponse(result, set))
267
278
  }
268
279
  const status = set.status ?? (result === undefined ? 204 : 200)
269
280
  const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
@@ -509,23 +520,67 @@ function installFastResponse(): void {
509
520
  globalThis.Response = DeferringResponse as unknown as typeof Response
510
521
  }
511
522
 
512
- function appendCookiesToResponse(
513
- response: Response,
514
- cookies: readonly string[] | undefined,
515
- ): Response {
516
- if (cookies === undefined || cookies.length === 0) return response
523
+ /** Headers that describe a Response's body or its framing - core's list, mirrored here because the
524
+ * adapter does not import core: a `c.set` value for one describes a body the handler did not return. */
525
+ const RESPONSE_OWNED_HEADERS: ReadonlySet<string> = new Set([
526
+ "content-type",
527
+ "content-length",
528
+ "content-encoding",
529
+ "content-range",
530
+ "transfer-encoding",
531
+ ])
532
+
533
+ /**
534
+ * `response` with this request's `c.set` headers it lacks and its queued cookies - core's
535
+ * `applySetToResponse` and `writableResponse` rules, mirrored. A Response without a body can be sent
536
+ * any number of times, so the object a handler returns may answer other requests too and is always
537
+ * copied; one with a body can be sent once, so it is this request's and takes the writes in place
538
+ * unless its `Headers` are guarded (`Response.redirect()`, a raw `fetch()` result).
539
+ */
540
+ function applySetToResponse(response: Response, set: NodeContextSet): Response {
541
+ const ambient = response.status >= 200 ? set._headers : undefined
542
+ const cookies = set._cookies ?? []
543
+ const added: Array<readonly [string, string]> = []
544
+ if (ambient !== undefined) {
545
+ for (const name in ambient) {
546
+ if (RESPONSE_OWNED_HEADERS.has(name.toLowerCase()) || response.headers.has(name)) continue
547
+ added.push([name, ambient[name]!])
548
+ }
549
+ }
550
+ if (added.length === 0 && cookies.length === 0) return response
551
+ const write = (target: Response): Response => {
552
+ for (const [name, value] of added) target.headers.set(name, value)
553
+ for (const cookie of cookies) target.headers.append("set-cookie", cookie)
554
+ return target
555
+ }
556
+ if (response.body === null && response.status >= 200) {
557
+ return write(
558
+ copiedResponse(
559
+ response,
560
+ new Response(null, {
561
+ status: response.status,
562
+ statusText: response.statusText,
563
+ headers: response.headers,
564
+ }),
565
+ ),
566
+ )
567
+ }
517
568
  try {
518
- for (const cookie of cookies) response.headers.append("set-cookie", cookie)
519
- return response
569
+ return write(response)
520
570
  } catch {
521
- // Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
522
- // first append throws with nothing applied and the cookies land on a mutable copy instead.
523
- const clone = new Response(response.body, response)
524
- for (const cookie of cookies) clone.headers.append("set-cookie", cookie)
525
- return clone
571
+ // Guarded `Headers` reject every write, so the first one throws with nothing applied and the
572
+ // writes land on a mutable copy instead.
573
+ return write(copiedResponse(response, new Response(response.body, response)))
526
574
  }
527
575
  }
528
576
 
577
+ /** `copy` carrying `original`'s buffered-body marker, so the direct writer still sends those bytes. */
578
+ function copiedResponse(original: Response, copy: Response): Response {
579
+ const body: unknown = Reflect.get(original, NODE_RESPONSE_BODY)
580
+ if (body !== undefined) Object.defineProperty(copy, NODE_RESPONSE_BODY, { value: body })
581
+ return copy
582
+ }
583
+
529
584
  /** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
530
585
  * every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
531
586
  * return takes, with no `Response` built and none drained; there is no `c.set` at these sites, which
@@ -627,13 +682,33 @@ function responseHeaders(
627
682
  return headers
628
683
  }
629
684
 
630
- function appendCookiesToNodeHeaders(
685
+ /** {@link applySetToResponse} for a body the direct writer sends from a header record. */
686
+ function applySetToNodeHeaders(
631
687
  headers: Readonly<Record<string, string | readonly string[]>> | undefined,
632
- cookies: readonly string[] | undefined,
688
+ status: number,
689
+ set: NodeContextSet,
633
690
  ): Readonly<Record<string, string | readonly string[]>> | undefined {
634
- if (cookies === undefined || cookies.length === 0) return headers
635
- const out = Object.create(null) as Record<string, string | readonly string[]>
636
- if (headers !== undefined) Object.assign(out, headers)
691
+ const ambient = status >= 200 ? set._headers : undefined
692
+ const cookies = set._cookies
693
+ const copy = (): Record<string, string | readonly string[]> =>
694
+ Object.assign(Object.create(null) as Record<string, string | readonly string[]>, headers)
695
+ let out: Record<string, string | readonly string[]> | undefined
696
+ if (ambient !== undefined) {
697
+ let own: Set<string> | undefined
698
+ for (const name in ambient) {
699
+ const lower = name.toLowerCase()
700
+ if (RESPONSE_OWNED_HEADERS.has(lower)) continue
701
+ own ??= new Set(
702
+ headers === undefined ? [] : Object.keys(headers).map((key) => key.toLowerCase()),
703
+ )
704
+ if (own.has(lower)) continue
705
+ own.add(lower)
706
+ out ??= copy()
707
+ out[name] = ambient[name]!
708
+ }
709
+ }
710
+ if (cookies === undefined || cookies.length === 0) return out ?? headers
711
+ out ??= copy()
637
712
  const existing = out["set-cookie"]
638
713
  const setCookies =
639
714
  existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
@@ -653,6 +728,12 @@ export interface ServeStaticOptions {
653
728
  readonly prefix?: string
654
729
  /** Emit `cache-control: public, max-age=31536000, immutable` - correct for content-hashed files. Default `true`. */
655
730
  readonly immutable?: boolean
731
+ /**
732
+ * URL paths of the content-hashed files, such as a client build manifest's `assets`. When set,
733
+ * `immutable` applies only to these; any other file is sent with its validators and no
734
+ * `cache-control`, so a file that keeps its name across deploys still revalidates.
735
+ */
736
+ readonly hashedFiles?: Iterable<string>
656
737
  /** Extra headers merged onto every served file. */
657
738
  readonly headers?: Readonly<Record<string, string>>
658
739
  /**
@@ -691,13 +772,15 @@ export interface ServeOptions {
691
772
  * Install SIGTERM/SIGINT handlers that call `stop()` for a graceful drain on
692
773
  * `docker stop` / Ctrl-C. Off by default - taking over process signals is opt-in,
693
774
  * mirroring nifra's Bun `listen({ gracefulSignals })`.
775
+ * `{ drainMs }` sets how long in-flight requests get before the stop forces them closed
776
+ * (default 10_000).
694
777
  *
695
778
  * The app-level request timeout (`server({ requestTimeoutMs })` → 503) and body cap
696
779
  * are *not* set here - they live inside `app.fetch`, so they already apply through
697
780
  * this adapter. Slow-client protection is Node's built-in `requestTimeout` (300s) /
698
781
  * `headersTimeout` (60s) defaults.
699
782
  */
700
- readonly signals?: boolean
783
+ readonly signals?: boolean | { readonly drainMs?: number }
701
784
  /**
702
785
  * Serve static files from disk for matching GET/HEAD requests *before* the app runs - non-matching
703
786
  * requests fall through to `app.fetch` with the node-direct fast path intact (no perf regression on
@@ -723,7 +806,8 @@ export interface NodeServer {
723
806
  readonly port: number
724
807
  /**
725
808
  * Stop accepting connections, let in-flight requests drain (up to `drainMs`), then
726
- * force-close stragglers + idle keep-alives. Mirrors nifra's Bun `stop()`.
809
+ * force-close stragglers + idle keep-alives. Open WebSockets get a 1001 close at once and are
810
+ * terminated if still open at the deadline. Mirrors nifra's Bun `stop()`.
727
811
  */
728
812
  stop(options?: { drainMs?: number }): Promise<void>
729
813
  }
@@ -760,7 +844,8 @@ function normalizeHostAuthority(value: string): string | undefined {
760
844
  hostname = value.slice(0, colon)
761
845
  port = value.slice(colon + 1)
762
846
  }
763
- if (hostname.length === 0 || !/^[a-z0-9.-]+$/i.test(hostname)) return undefined
847
+ // `_` is a legal reg-name character (RFC 3986) and common in container service names.
848
+ if (hostname.length === 0 || !/^[\w.-]+$/.test(hostname)) return undefined
764
849
  hostname = hostname.toLowerCase()
765
850
  }
766
851
  if (port !== undefined) {
@@ -855,6 +940,7 @@ interface StaticState {
855
940
  readonly root: string
856
941
  readonly prefix: string // normalized: "/assets" (no trailing slash) or "/"
857
942
  readonly immutable: boolean
943
+ readonly hashedFiles: ReadonlySet<string> | undefined
858
944
  readonly headers: Readonly<Record<string, string>> | undefined
859
945
  readonly denyDotfiles: boolean
860
946
  }
@@ -881,6 +967,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
881
967
  root,
882
968
  prefix,
883
969
  immutable: options.immutable !== false,
970
+ hashedFiles: options.hashedFiles === undefined ? undefined : new Set(options.hashedFiles),
884
971
  headers: staticHeadersOf(options.headers),
885
972
  denyDotfiles: options.dotfiles !== "allow",
886
973
  }
@@ -894,7 +981,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
894
981
  function staticMatch(
895
982
  state: StaticState,
896
983
  rawUrl: string,
897
- ): "pass" | { readonly reject: Response } | { readonly file: string } {
984
+ ): "pass" | { readonly reject: Response } | { readonly file: string; readonly immutable: boolean } {
898
985
  const query = rawUrl.indexOf("?")
899
986
  const path = query === -1 ? rawUrl : rawUrl.slice(0, query)
900
987
  const underPrefix =
@@ -933,7 +1020,9 @@ function staticMatch(
933
1020
  if (contained.startsWith("..") || isAbsolute(contained)) {
934
1021
  return { reject: new Response("Forbidden", { status: 403 }) }
935
1022
  }
936
- return { file }
1023
+ const immutable =
1024
+ state.immutable && (state.hashedFiles === undefined || state.hashedFiles.has(path))
1025
+ return { file, immutable }
937
1026
  }
938
1027
 
939
1028
  /** Strong validator from mtime + size (the nginx scheme): rewriting a file changes at least one. */
@@ -1021,31 +1110,32 @@ function staticByteRange(
1021
1110
 
1022
1111
  async function readStatic(
1023
1112
  file: string,
1113
+ immutable: boolean,
1024
1114
  state: StaticState,
1025
1115
  method: string,
1026
1116
  requestHeaders: IncomingHttpHeaders,
1027
1117
  ): Promise<NodeServeOutcome> {
1028
- let handle: Awaited<ReturnType<typeof open>> | undefined
1118
+ let handle: Awaited<ReturnType<typeof openResolved>> | undefined
1029
1119
  try {
1030
1120
  // Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
1031
1121
  // points outside it. Resolve first, check containment on the RESOLVED name, and open that -
1032
1122
  // never open the requested path and re-resolve it afterwards. A local attacker who can write
1033
1123
  // inside the served tree wins that ordering: swap a link between the open and the lookup and the
1034
1124
  // descriptor already streaming refers to an external file while the lookup answers with a
1035
- // contained path. `O_NOFOLLOW` closes the remainder on the final component - `realpath` returned
1036
- // a name with no links left in it, so one appearing before the open is an attack, not a layout.
1125
+ // contained path. openResolved closes the remainder - `realpath` returned a name with no links
1126
+ // left in it, so one appearing in any component before the open is an attack, not a layout.
1037
1127
  const [realFile, realRoot] = await Promise.all([realpath(file), realpath(state.root)])
1038
1128
  if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
1039
1129
  return { kind: "response", response: new Response("Forbidden", { status: 403 }) }
1040
1130
  }
1041
- handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW)
1131
+ handle = await openResolved(realFile)
1042
1132
  const stat = await handle.stat()
1043
1133
  if (!stat.isFile()) {
1044
1134
  await handle.close()
1045
1135
  return { kind: "response", response: new Response("Not Found", { status: 404 }) }
1046
1136
  }
1047
1137
  const headers: Record<string, string> = { ...state.headers }
1048
- if (state.immutable && headers["cache-control"] === undefined) {
1138
+ if (immutable && headers["cache-control"] === undefined) {
1049
1139
  headers["cache-control"] = "public, max-age=31536000, immutable"
1050
1140
  }
1051
1141
  headers.etag ??= staticEtag(stat.size, stat.mtimeMs)
@@ -1117,6 +1207,14 @@ async function readStatic(
1117
1207
  // @nifrajs/core, and no-op on a handler that does not expose the seam (a plain `{ fetch }`) or one that
1118
1208
  // is already frozen (the per-request `resolveNodeSource` path supplies the runtime regardless).
1119
1209
  const INSTALL_NODE_DIRECT = Symbol.for("@nifrajs/core/install-node-direct")
1210
+ // A nifra server logs what an adapter reports through this seam, as Bun's `listen()` does; a plain
1211
+ // `{ fetch }` handler has no logger, so its failures stay a bare 500.
1212
+ const REPORT_ERROR = Symbol.for("@nifrajs/core/report-error")
1213
+ function errorReporterOf(app: FetchHandler): ((error: unknown) => void) | undefined {
1214
+ const report: unknown = Reflect.get(app, REPORT_ERROR)
1215
+ return typeof report === "function" ? (error) => report.call(app, error) : undefined
1216
+ }
1217
+
1120
1218
  function enableNodeDirect(app: FetchHandler): void {
1121
1219
  const install = (app as unknown as Record<symbol, unknown>)[INSTALL_NODE_DIRECT]
1122
1220
  if (typeof install !== "function") return
@@ -1148,6 +1246,8 @@ interface ActiveResponseState {
1148
1246
  readonly socket: { readonly destroyed: boolean; destroy(): void }
1149
1247
  active: number
1150
1248
  parserError: boolean
1249
+ /** The served app's own error log, when it has one. */
1250
+ readonly report: ((error: unknown) => void) | undefined
1151
1251
  }
1152
1252
 
1153
1253
  const ACTIVE_RESPONSE_STATE = Symbol("nifra.node.active-response-state")
@@ -1184,12 +1284,21 @@ function clearActiveResponseState(this: TrackedSocket): void {
1184
1284
  export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeServer> {
1185
1285
  activateAsyncContextFrame()
1186
1286
  enableNodeDirect(app)
1287
+ const report = errorReporterOf(app)
1187
1288
  if (options.fastResponse === true) installFastResponse()
1188
1289
  let inFlight = 0
1189
1290
  let closed = false
1190
1291
  // Upgrade sockets an HTTP route answers instead: Node's server no longer tracks them, so stop()
1191
1292
  // drains and closes them itself.
1192
1293
  const answering = new Set<Duplex>()
1294
+ // Upgraded WebSockets: Node's server stops tracking a socket once `ws` takes it over.
1295
+ const upgraded = new Set<WsSocket>()
1296
+ const adopt = (ws: WsSocket): boolean => {
1297
+ if (closed) return false
1298
+ upgraded.add(ws)
1299
+ ws.on("close", () => upgraded.delete(ws))
1300
+ return true
1301
+ }
1193
1302
  const protocol = protocolResolver(
1194
1303
  options.protocol ?? (options.tls === undefined ? undefined : "https"),
1195
1304
  )
@@ -1203,7 +1312,7 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1203
1312
  const socket = nodeReq.socket as TrackedSocket
1204
1313
  let responseState = socket[ACTIVE_SOCKET_STATE]
1205
1314
  if (responseState === undefined) {
1206
- responseState = { socket, active: 0, parserError: false }
1315
+ responseState = { socket, active: 0, parserError: false, report }
1207
1316
  socket[ACTIVE_SOCKET_STATE] = responseState
1208
1317
  socket.once("close", clearActiveResponseState)
1209
1318
  }
@@ -1218,18 +1327,19 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1218
1327
  if (handled instanceof Promise) {
1219
1328
  // `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
1220
1329
  // observes, and Node terminates the process on an unhandled rejection by default. Every
1221
- // writer already ends its own failures (`failWrite`), so anything arriving here is a fault
1222
- // no response can still be built from - the connection is the only thing left to end.
1330
+ // writer already ends and reports its own failures (`failAndReport`), so anything arriving
1331
+ // here is a fault no response can still be built from - the connection is the only thing
1332
+ // left to end.
1223
1333
  void handled
1224
- .catch(() => failWrite(nodeRes))
1334
+ .catch((error: unknown) => failAndReport(nodeRes, error))
1225
1335
  .finally(() => {
1226
1336
  inFlight -= 1
1227
1337
  })
1228
1338
  return
1229
1339
  }
1230
1340
  inFlight -= 1
1231
- } catch {
1232
- failWrite(nodeRes)
1341
+ } catch (error) {
1342
+ failAndReport(nodeRes, error)
1233
1343
  inFlight -= 1
1234
1344
  }
1235
1345
  }
@@ -1261,11 +1371,14 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1261
1371
  // the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
1262
1372
  // upgrade, so a non-WS Node app never loads `ws`.
1263
1373
  if (resolveWs !== undefined) {
1264
- let wssPromise: Promise<WsServer | undefined> | undefined
1374
+ // One `ws` server per payload cap: its `maxPayload` bounds buffering before a frame is delivered,
1375
+ // and mounted children can declare different caps.
1376
+ const servers = new Map<number | undefined, Promise<WsServer | undefined>>()
1265
1377
  server.on("upgrade", (nodeReq, socket, head) => {
1266
1378
  void handleUpgrade(
1267
1379
  app,
1268
1380
  answering,
1381
+ adopt,
1269
1382
  resolveWs,
1270
1383
  protocol,
1271
1384
  hostPolicy,
@@ -1273,8 +1386,12 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1273
1386
  socket,
1274
1387
  head,
1275
1388
  (maxPayloadBytes) => {
1276
- wssPromise ??= loadWsServer(maxPayloadBytes)
1277
- return wssPromise
1389
+ let wss = servers.get(maxPayloadBytes)
1390
+ if (wss === undefined) {
1391
+ wss = loadWsServer(maxPayloadBytes)
1392
+ servers.set(maxPayloadBytes, wss)
1393
+ }
1394
+ return wss
1278
1395
  },
1279
1396
  )
1280
1397
  })
@@ -1282,25 +1399,29 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1282
1399
 
1283
1400
  // Opt-in: own SIGTERM/SIGINT so `docker stop` / Ctrl-C drains in-flight requests
1284
1401
  // before exit. `stop` is hoisted, so `onSignal` can reference it.
1402
+ const signals = options.signals === true ? {} : options.signals || undefined
1285
1403
  const onSignal = (): void => {
1286
- void stop()
1404
+ void stop(signals)
1287
1405
  }
1288
1406
 
1289
1407
  async function stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
1290
1408
  if (closed) return // idempotent
1291
1409
  closed = true
1292
- if (options.signals === true) {
1410
+ if (signals !== undefined) {
1293
1411
  // Remove our own handlers so repeated serve()/stop() cycles don't leak listeners.
1294
1412
  process.removeListener("SIGTERM", onSignal)
1295
1413
  process.removeListener("SIGINT", onSignal)
1296
1414
  }
1297
1415
  server.close() // stop accepting new connections; existing requests continue
1416
+ // A WebSocket gets a close handshake now and the drain window to finish it.
1417
+ for (const ws of upgraded) ws.close(1001, "server shutting down")
1298
1418
  const deadline = Date.now() + drainMs
1299
- while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
1419
+ while ((inFlight > 0 || answering.size > 0 || upgraded.size > 0) && Date.now() < deadline) {
1300
1420
  await new Promise((r) => setTimeout(r, DRAIN_POLL_MS))
1301
1421
  }
1302
1422
  server.closeAllConnections() // force-close stragglers + idle keep-alive sockets
1303
1423
  for (const socket of answering) socket.destroy()
1424
+ for (const ws of upgraded) ws.terminate()
1304
1425
  }
1305
1426
 
1306
1427
  return new Promise((resolve) => {
@@ -1310,7 +1431,7 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1310
1431
  const onListening = (): void => {
1311
1432
  const address = server.address()
1312
1433
  const port = address !== null && typeof address === "object" ? address.port : options.port
1313
- if (options.signals === true) {
1434
+ if (signals !== undefined) {
1314
1435
  process.once("SIGTERM", onSignal)
1315
1436
  process.once("SIGINT", onSignal)
1316
1437
  }
@@ -1340,11 +1461,11 @@ function runNodeSource(
1340
1461
  return outcome instanceof Promise
1341
1462
  ? outcome.then(
1342
1463
  (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1343
- () => failWrite(nodeRes),
1464
+ (error: unknown) => failAndReport(nodeRes, error),
1344
1465
  )
1345
1466
  : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1346
- } catch {
1347
- failWrite(nodeRes)
1467
+ } catch (error) {
1468
+ failAndReport(nodeRes, error)
1348
1469
  return
1349
1470
  }
1350
1471
  }
@@ -1357,11 +1478,11 @@ function runNodeSource(
1357
1478
  return outcome instanceof Promise
1358
1479
  ? outcome.then(
1359
1480
  (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1360
- () => failWrite(nodeRes),
1481
+ (error: unknown) => failAndReport(nodeRes, error),
1361
1482
  )
1362
1483
  : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1363
- } catch {
1364
- failWrite(nodeRes)
1484
+ } catch (error) {
1485
+ failAndReport(nodeRes, error)
1365
1486
  return
1366
1487
  }
1367
1488
  }
@@ -1371,12 +1492,12 @@ function runNodeSource(
1371
1492
  return response instanceof Promise
1372
1493
  ? response.then(
1373
1494
  (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1374
- () => failWrite(nodeRes),
1495
+ (error: unknown) => failAndReport(nodeRes, error),
1375
1496
  )
1376
1497
  : writeResponseSafely(response, nodeRes, nodeReq.method)
1377
- } catch {
1378
- // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
1379
- failWrite(nodeRes)
1498
+ } catch (error) {
1499
+ // A request hook that throws escapes `fetch`; never leak a stack to the wire.
1500
+ failAndReport(nodeRes, error)
1380
1501
  return
1381
1502
  }
1382
1503
  }
@@ -1447,7 +1568,13 @@ function handle(
1447
1568
  const matched = staticMatch(staticState, nodeReq.url ?? "/")
1448
1569
  if (matched !== "pass") {
1449
1570
  if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
1450
- return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then(
1571
+ return readStatic(
1572
+ matched.file,
1573
+ matched.immutable,
1574
+ staticState,
1575
+ nodeReq.method ?? "GET",
1576
+ nodeReq.headers,
1577
+ ).then(
1451
1578
  (outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
1452
1579
  () => failWrite(nodeRes),
1453
1580
  )
@@ -1493,12 +1620,12 @@ function handle(
1493
1620
  handled === false
1494
1621
  ? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
1495
1622
  : undefined,
1496
- () => failWrite(nodeRes),
1623
+ (error: unknown) => failAndReport(nodeRes, error),
1497
1624
  )
1498
1625
  }
1499
1626
  if (outcome !== false) return
1500
- } catch {
1501
- failWrite(nodeRes)
1627
+ } catch (error) {
1628
+ failAndReport(nodeRes, error)
1502
1629
  return
1503
1630
  }
1504
1631
  }
@@ -1515,11 +1642,11 @@ function handle(
1515
1642
  return outcome instanceof Promise
1516
1643
  ? outcome.then(
1517
1644
  (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1518
- () => failWrite(nodeRes),
1645
+ (error: unknown) => failAndReport(nodeRes, error),
1519
1646
  )
1520
1647
  : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1521
- } catch {
1522
- failWrite(nodeRes)
1648
+ } catch (error) {
1649
+ failAndReport(nodeRes, error)
1523
1650
  return
1524
1651
  }
1525
1652
  }
@@ -1529,12 +1656,12 @@ function handle(
1529
1656
  return response instanceof Promise
1530
1657
  ? response.then(
1531
1658
  (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1532
- () => failWrite(nodeRes),
1659
+ (error: unknown) => failAndReport(nodeRes, error),
1533
1660
  )
1534
1661
  : writeResponseSafely(response, nodeRes, nodeReq.method)
1535
- } catch {
1536
- // The app should never throw (nifra returns a 500), but never leak a stack to the wire.
1537
- failWrite(nodeRes)
1662
+ } catch (error) {
1663
+ // A request hook that throws escapes `fetch`; never leak a stack to the wire.
1664
+ failAndReport(nodeRes, error)
1538
1665
  return
1539
1666
  }
1540
1667
  }
@@ -1578,6 +1705,9 @@ function writeInternalError(nodeRes: ServerResponse): void {
1578
1705
  function failWrite(nodeRes: ServerResponse): void {
1579
1706
  try {
1580
1707
  if (!nodeRes.headersSent) {
1708
+ // The failed write may have staged the response's own headers (cookies among them) with
1709
+ // `setHeaders`; the 500 must not carry them.
1710
+ for (const name of nodeRes.getHeaderNames()) nodeRes.removeHeader(name)
1581
1711
  writeInternalError(nodeRes)
1582
1712
  return
1583
1713
  }
@@ -1587,7 +1717,20 @@ function failWrite(nodeRes: ServerResponse): void {
1587
1717
  nodeRes.destroy()
1588
1718
  }
1589
1719
 
1590
- /** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
1720
+ /** {@link failWrite} for a failure the app never answered, logged through the app's own logger. */
1721
+ function failAndReport(nodeRes: ServerResponse, error: unknown): void {
1722
+ const state: ActiveResponseState | undefined = Reflect.get(nodeRes, ACTIVE_RESPONSE_STATE)
1723
+ const report = state?.report
1724
+ try {
1725
+ report?.(error)
1726
+ } catch {
1727
+ // A logger that throws must not keep the 500 from going out.
1728
+ }
1729
+ failWrite(nodeRes)
1730
+ }
1731
+
1732
+ /** {@link writeNodeOutcome} with {@link failAndReport} behind it, sync throw and async rejection
1733
+ * alike. */
1591
1734
  function writeOutcomeSafely(
1592
1735
  outcome: NodeServeOutcome,
1593
1736
  nodeRes: ServerResponse,
@@ -1595,14 +1738,16 @@ function writeOutcomeSafely(
1595
1738
  ): void | Promise<void> {
1596
1739
  try {
1597
1740
  const written = writeNodeOutcome(outcome, nodeRes, method)
1598
- return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1599
- } catch {
1600
- failWrite(nodeRes)
1741
+ return written instanceof Promise
1742
+ ? written.catch((error: unknown) => failAndReport(nodeRes, error))
1743
+ : written
1744
+ } catch (error) {
1745
+ failAndReport(nodeRes, error)
1601
1746
  return
1602
1747
  }
1603
1748
  }
1604
1749
 
1605
- /** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
1750
+ /** {@link writeNodeResponse} with {@link failAndReport} behind it - same contract as
1606
1751
  * {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
1607
1752
  function writeResponseSafely(
1608
1753
  response: Response,
@@ -1611,9 +1756,11 @@ function writeResponseSafely(
1611
1756
  ): void | Promise<void> {
1612
1757
  try {
1613
1758
  const written = writeNodeResponse(response, nodeRes, method)
1614
- return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1615
- } catch {
1616
- failWrite(nodeRes)
1759
+ return written instanceof Promise
1760
+ ? written.catch((error: unknown) => failAndReport(nodeRes, error))
1761
+ : written
1762
+ } catch (error) {
1763
+ failAndReport(nodeRes, error)
1617
1764
  return
1618
1765
  }
1619
1766
  }
@@ -2436,6 +2583,8 @@ function writeNodeResponse(
2436
2583
  nodeRes: ServerResponse,
2437
2584
  method?: string,
2438
2585
  ): void | Promise<void> {
2586
+ // A body already sent once answered another request, with headers written for that one.
2587
+ if (response.bodyUsed) throw new TypeError("[nifra] a Response body can be sent only once")
2439
2588
  const isHead = method?.toUpperCase() === "HEAD"
2440
2589
  // `ServerResponse.setHeaders` (Node 18.14+) takes the Headers object directly and iterates its
2441
2590
  // native Symbol.iterator, which - unlike `Headers.forEach`/`.get()` - never comma-joins repeated
@@ -2682,12 +2831,14 @@ async function loadWsServer(maxPayloadBytes?: number): Promise<WsServer | undefi
2682
2831
  default?: { WebSocketServer?: WsServerCtor }
2683
2832
  }
2684
2833
  const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer
2834
+ // `ws` reads `maxPayload: 0` as no limit, so a cap of 0 buffers at most a byte and the message
2835
+ // check in `attachNodeWebSocket` refuses anything but an empty frame.
2685
2836
  return Ctor === undefined
2686
2837
  ? undefined
2687
2838
  : new Ctor(
2688
2839
  maxPayloadBytes === undefined
2689
2840
  ? { noServer: true }
2690
- : { noServer: true, maxPayload: maxPayloadBytes },
2841
+ : { noServer: true, maxPayload: Math.max(maxPayloadBytes, 1) },
2691
2842
  )
2692
2843
  } catch {
2693
2844
  return undefined // `ws` not installed - caller responds 501
@@ -2709,6 +2860,7 @@ const WS_STATUS_TEXT: Readonly<Record<number, string>> = {
2709
2860
  async function handleUpgrade(
2710
2861
  app: FetchHandler,
2711
2862
  answering: Set<Duplex>,
2863
+ adopt: (ws: WsSocket) => boolean,
2712
2864
  resolveWs: (
2713
2865
  request: Request,
2714
2866
  platform?: NodePlatform,
@@ -2774,8 +2926,15 @@ async function handleUpgrade(
2774
2926
  writeUpgradeRejection(socket, 501, "websocket_unavailable") // `ws` not installed
2775
2927
  return
2776
2928
  }
2777
- const { handler, data, pubsub } = outcome
2778
- wss.handleUpgrade(nodeReq, socket, head, (ws) => attachNodeWebSocket(ws, handler, data, pubsub))
2929
+ const { handler, data, pubsub, maxPayloadBytes } = outcome
2930
+ wss.handleUpgrade(nodeReq, socket, head, (ws) => {
2931
+ // An upgrade still resolving when stop() ran must not outlive it.
2932
+ if (!adopt(ws)) {
2933
+ ws.terminate()
2934
+ return
2935
+ }
2936
+ attachNodeWebSocket(ws, handler, data, pubsub, maxPayloadBytes)
2937
+ })
2779
2938
  }
2780
2939
 
2781
2940
  /** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
@@ -2786,6 +2945,7 @@ function attachNodeWebSocket(
2786
2945
  handler: NifraWsHandler,
2787
2946
  data: unknown,
2788
2947
  pubsub: WsPubSub,
2948
+ maxPayloadBytes: number | undefined,
2789
2949
  ): void {
2790
2950
  const nifra: NifraWs = {
2791
2951
  send: (payload) => ws.send(payload),
@@ -2816,6 +2976,10 @@ function attachNodeWebSocket(
2816
2976
  }
2817
2977
  }
2818
2978
  ws.on("message", (raw, isBinary) => {
2979
+ if (maxPayloadBytes !== undefined && raw.byteLength > maxPayloadBytes) {
2980
+ ws.close(1009, "message too large")
2981
+ return
2982
+ }
2819
2983
  const payload: NifraWsData = isBinary
2820
2984
  ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
2821
2985
  : raw.toString()