@nifrajs/node 4.0.2 → 5.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,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 {
@@ -91,12 +91,19 @@ type WsUpgradeOutcome =
91
91
  readonly data: unknown
92
92
  readonly pubsub: WsPubSub
93
93
  readonly maxPayloadBytes?: number
94
+ /** Builds `ws.send` with the route's outbound contract (`validateSend`). */
95
+ sender?(
96
+ handler: NifraWsHandler,
97
+ send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
98
+ onError: (error: unknown) => void,
99
+ ): (payload: string | ArrayBufferView | ArrayBuffer) => void
94
100
  }
95
101
 
96
102
  /** Structural view of the `ws` package's `WebSocket` (no `@types/ws` dependency - see `loadWsServer`). */
97
103
  interface WsSocket {
98
104
  send(data: string | ArrayBufferView | ArrayBuffer): void
99
105
  close(code?: number, reason?: string): void
106
+ terminate(): void
100
107
  readonly readyState: number
101
108
  on(event: "message", listener: (data: Buffer, isBinary: boolean) => void): void
102
109
  on(event: "close", listener: (code: number, reason: Buffer) => void): void
@@ -210,6 +217,15 @@ const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount)
210
217
  const JSON_CONTENT_TYPE = Response.json(0).headers.get("content-type") ?? "application/json"
211
218
 
212
219
  const INTERNAL_ERROR_BODY = '{"ok":false,"error":"internal_error"}'
220
+ /** A handler's Response whose body was already sent: refused, since its headers were written for the
221
+ * request it already answered. */
222
+ const SENT_BODY_OUTCOME: NodeServeOutcome = Object.freeze({
223
+ kind: "json",
224
+ status: 500,
225
+ headers: undefined,
226
+ cookies: undefined,
227
+ body: INTERNAL_ERROR_BODY,
228
+ })
213
229
  const EMPTY_BUFFER = Buffer.alloc(0)
214
230
  const NODE_RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
215
231
  const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
@@ -256,14 +272,15 @@ const NODE_OUTCOME_RUNTIME: NodeOutcomeRuntime = {
256
272
  return {
257
273
  kind: "body",
258
274
  status: body.status,
259
- headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
275
+ headers: applySetToNodeHeaders(body.headers, body.status, set),
260
276
  body: isBodylessStatus(body.status) ? new Uint8Array(0) : body.body,
261
277
  }
262
278
  }
263
- return nodeOutcomeFromResponse(appendCookiesToResponse(result.toResponse(), set._cookies))
279
+ return nodeOutcomeFromResponse(applySetToResponse(result.toResponse(), set))
264
280
  }
265
281
  if (result instanceof Response) {
266
- return nodeOutcomeFromResponse(appendCookiesToResponse(result, set._cookies))
282
+ if (result.bodyUsed) return SENT_BODY_OUTCOME
283
+ return nodeOutcomeFromResponse(applySetToResponse(result, set))
267
284
  }
268
285
  const status = set.status ?? (result === undefined ? 204 : 200)
269
286
  const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
@@ -509,23 +526,67 @@ function installFastResponse(): void {
509
526
  globalThis.Response = DeferringResponse as unknown as typeof Response
510
527
  }
511
528
 
512
- function appendCookiesToResponse(
513
- response: Response,
514
- cookies: readonly string[] | undefined,
515
- ): Response {
516
- if (cookies === undefined || cookies.length === 0) return response
529
+ /** Headers that describe a Response's body or its framing - core's list, mirrored here because the
530
+ * adapter does not import core: a `c.set` value for one describes a body the handler did not return. */
531
+ const RESPONSE_OWNED_HEADERS: ReadonlySet<string> = new Set([
532
+ "content-type",
533
+ "content-length",
534
+ "content-encoding",
535
+ "content-range",
536
+ "transfer-encoding",
537
+ ])
538
+
539
+ /**
540
+ * `response` with this request's `c.set` headers it lacks and its queued cookies - core's
541
+ * `applySetToResponse` and `writableResponse` rules, mirrored. A Response without a body can be sent
542
+ * any number of times, so the object a handler returns may answer other requests too and is always
543
+ * copied; one with a body can be sent once, so it is this request's and takes the writes in place
544
+ * unless its `Headers` are guarded (`Response.redirect()`, a raw `fetch()` result).
545
+ */
546
+ function applySetToResponse(response: Response, set: NodeContextSet): Response {
547
+ const ambient = response.status >= 200 ? set._headers : undefined
548
+ const cookies = set._cookies ?? []
549
+ const added: Array<readonly [string, string]> = []
550
+ if (ambient !== undefined) {
551
+ for (const name in ambient) {
552
+ if (RESPONSE_OWNED_HEADERS.has(name.toLowerCase()) || response.headers.has(name)) continue
553
+ added.push([name, ambient[name]!])
554
+ }
555
+ }
556
+ if (added.length === 0 && cookies.length === 0) return response
557
+ const write = (target: Response): Response => {
558
+ for (const [name, value] of added) target.headers.set(name, value)
559
+ for (const cookie of cookies) target.headers.append("set-cookie", cookie)
560
+ return target
561
+ }
562
+ if (response.body === null && response.status >= 200) {
563
+ return write(
564
+ copiedResponse(
565
+ response,
566
+ new Response(null, {
567
+ status: response.status,
568
+ statusText: response.statusText,
569
+ headers: response.headers,
570
+ }),
571
+ ),
572
+ )
573
+ }
517
574
  try {
518
- for (const cookie of cookies) response.headers.append("set-cookie", cookie)
519
- return response
575
+ return write(response)
520
576
  } 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
577
+ // Guarded `Headers` reject every write, so the first one throws with nothing applied and the
578
+ // writes land on a mutable copy instead.
579
+ return write(copiedResponse(response, new Response(response.body, response)))
526
580
  }
527
581
  }
528
582
 
583
+ /** `copy` carrying `original`'s buffered-body marker, so the direct writer still sends those bytes. */
584
+ function copiedResponse(original: Response, copy: Response): Response {
585
+ const body: unknown = Reflect.get(original, NODE_RESPONSE_BODY)
586
+ if (body !== undefined) Object.defineProperty(copy, NODE_RESPONSE_BODY, { value: body })
587
+ return copy
588
+ }
589
+
529
590
  /** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
530
591
  * every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
531
592
  * return takes, with no `Response` built and none drained; there is no `c.set` at these sites, which
@@ -627,13 +688,33 @@ function responseHeaders(
627
688
  return headers
628
689
  }
629
690
 
630
- function appendCookiesToNodeHeaders(
691
+ /** {@link applySetToResponse} for a body the direct writer sends from a header record. */
692
+ function applySetToNodeHeaders(
631
693
  headers: Readonly<Record<string, string | readonly string[]>> | undefined,
632
- cookies: readonly string[] | undefined,
694
+ status: number,
695
+ set: NodeContextSet,
633
696
  ): 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)
697
+ const ambient = status >= 200 ? set._headers : undefined
698
+ const cookies = set._cookies
699
+ const copy = (): Record<string, string | readonly string[]> =>
700
+ Object.assign(Object.create(null) as Record<string, string | readonly string[]>, headers)
701
+ let out: Record<string, string | readonly string[]> | undefined
702
+ if (ambient !== undefined) {
703
+ let own: Set<string> | undefined
704
+ for (const name in ambient) {
705
+ const lower = name.toLowerCase()
706
+ if (RESPONSE_OWNED_HEADERS.has(lower)) continue
707
+ own ??= new Set(
708
+ headers === undefined ? [] : Object.keys(headers).map((key) => key.toLowerCase()),
709
+ )
710
+ if (own.has(lower)) continue
711
+ own.add(lower)
712
+ out ??= copy()
713
+ out[name] = ambient[name]!
714
+ }
715
+ }
716
+ if (cookies === undefined || cookies.length === 0) return out ?? headers
717
+ out ??= copy()
637
718
  const existing = out["set-cookie"]
638
719
  const setCookies =
639
720
  existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
@@ -653,6 +734,12 @@ export interface ServeStaticOptions {
653
734
  readonly prefix?: string
654
735
  /** Emit `cache-control: public, max-age=31536000, immutable` - correct for content-hashed files. Default `true`. */
655
736
  readonly immutable?: boolean
737
+ /**
738
+ * URL paths of the content-hashed files, such as a client build manifest's `assets`. When set,
739
+ * `immutable` applies only to these; any other file is sent with its validators and no
740
+ * `cache-control`, so a file that keeps its name across deploys still revalidates.
741
+ */
742
+ readonly hashedFiles?: Iterable<string>
656
743
  /** Extra headers merged onto every served file. */
657
744
  readonly headers?: Readonly<Record<string, string>>
658
745
  /**
@@ -691,13 +778,15 @@ export interface ServeOptions {
691
778
  * Install SIGTERM/SIGINT handlers that call `stop()` for a graceful drain on
692
779
  * `docker stop` / Ctrl-C. Off by default - taking over process signals is opt-in,
693
780
  * mirroring nifra's Bun `listen({ gracefulSignals })`.
781
+ * `{ drainMs }` sets how long in-flight requests get before the stop forces them closed
782
+ * (default 10_000).
694
783
  *
695
784
  * The app-level request timeout (`server({ requestTimeoutMs })` → 503) and body cap
696
785
  * are *not* set here - they live inside `app.fetch`, so they already apply through
697
786
  * this adapter. Slow-client protection is Node's built-in `requestTimeout` (300s) /
698
787
  * `headersTimeout` (60s) defaults.
699
788
  */
700
- readonly signals?: boolean
789
+ readonly signals?: boolean | { readonly drainMs?: number }
701
790
  /**
702
791
  * Serve static files from disk for matching GET/HEAD requests *before* the app runs - non-matching
703
792
  * requests fall through to `app.fetch` with the node-direct fast path intact (no perf regression on
@@ -723,7 +812,8 @@ export interface NodeServer {
723
812
  readonly port: number
724
813
  /**
725
814
  * Stop accepting connections, let in-flight requests drain (up to `drainMs`), then
726
- * force-close stragglers + idle keep-alives. Mirrors nifra's Bun `stop()`.
815
+ * force-close stragglers + idle keep-alives. Open WebSockets get a 1001 close at once and are
816
+ * terminated if still open at the deadline. Mirrors nifra's Bun `stop()`.
727
817
  */
728
818
  stop(options?: { drainMs?: number }): Promise<void>
729
819
  }
@@ -760,7 +850,8 @@ function normalizeHostAuthority(value: string): string | undefined {
760
850
  hostname = value.slice(0, colon)
761
851
  port = value.slice(colon + 1)
762
852
  }
763
- if (hostname.length === 0 || !/^[a-z0-9.-]+$/i.test(hostname)) return undefined
853
+ // `_` is a legal reg-name character (RFC 3986) and common in container service names.
854
+ if (hostname.length === 0 || !/^[\w.-]+$/.test(hostname)) return undefined
764
855
  hostname = hostname.toLowerCase()
765
856
  }
766
857
  if (port !== undefined) {
@@ -855,6 +946,7 @@ interface StaticState {
855
946
  readonly root: string
856
947
  readonly prefix: string // normalized: "/assets" (no trailing slash) or "/"
857
948
  readonly immutable: boolean
949
+ readonly hashedFiles: ReadonlySet<string> | undefined
858
950
  readonly headers: Readonly<Record<string, string>> | undefined
859
951
  readonly denyDotfiles: boolean
860
952
  }
@@ -881,6 +973,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
881
973
  root,
882
974
  prefix,
883
975
  immutable: options.immutable !== false,
976
+ hashedFiles: options.hashedFiles === undefined ? undefined : new Set(options.hashedFiles),
884
977
  headers: staticHeadersOf(options.headers),
885
978
  denyDotfiles: options.dotfiles !== "allow",
886
979
  }
@@ -894,7 +987,7 @@ function staticStateOf(options: ServeStaticOptions): StaticState {
894
987
  function staticMatch(
895
988
  state: StaticState,
896
989
  rawUrl: string,
897
- ): "pass" | { readonly reject: Response } | { readonly file: string } {
990
+ ): "pass" | { readonly reject: Response } | { readonly file: string; readonly immutable: boolean } {
898
991
  const query = rawUrl.indexOf("?")
899
992
  const path = query === -1 ? rawUrl : rawUrl.slice(0, query)
900
993
  const underPrefix =
@@ -933,7 +1026,9 @@ function staticMatch(
933
1026
  if (contained.startsWith("..") || isAbsolute(contained)) {
934
1027
  return { reject: new Response("Forbidden", { status: 403 }) }
935
1028
  }
936
- return { file }
1029
+ const immutable =
1030
+ state.immutable && (state.hashedFiles === undefined || state.hashedFiles.has(path))
1031
+ return { file, immutable }
937
1032
  }
938
1033
 
939
1034
  /** Strong validator from mtime + size (the nginx scheme): rewriting a file changes at least one. */
@@ -1021,31 +1116,32 @@ function staticByteRange(
1021
1116
 
1022
1117
  async function readStatic(
1023
1118
  file: string,
1119
+ immutable: boolean,
1024
1120
  state: StaticState,
1025
1121
  method: string,
1026
1122
  requestHeaders: IncomingHttpHeaders,
1027
1123
  ): Promise<NodeServeOutcome> {
1028
- let handle: Awaited<ReturnType<typeof open>> | undefined
1124
+ let handle: Awaited<ReturnType<typeof openResolved>> | undefined
1029
1125
  try {
1030
1126
  // Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
1031
1127
  // points outside it. Resolve first, check containment on the RESOLVED name, and open that -
1032
1128
  // never open the requested path and re-resolve it afterwards. A local attacker who can write
1033
1129
  // inside the served tree wins that ordering: swap a link between the open and the lookup and the
1034
1130
  // 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.
1131
+ // contained path. openResolved closes the remainder - `realpath` returned a name with no links
1132
+ // left in it, so one appearing in any component before the open is an attack, not a layout.
1037
1133
  const [realFile, realRoot] = await Promise.all([realpath(file), realpath(state.root)])
1038
1134
  if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
1039
1135
  return { kind: "response", response: new Response("Forbidden", { status: 403 }) }
1040
1136
  }
1041
- handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW)
1137
+ handle = await openResolved(realFile)
1042
1138
  const stat = await handle.stat()
1043
1139
  if (!stat.isFile()) {
1044
1140
  await handle.close()
1045
1141
  return { kind: "response", response: new Response("Not Found", { status: 404 }) }
1046
1142
  }
1047
1143
  const headers: Record<string, string> = { ...state.headers }
1048
- if (state.immutable && headers["cache-control"] === undefined) {
1144
+ if (immutable && headers["cache-control"] === undefined) {
1049
1145
  headers["cache-control"] = "public, max-age=31536000, immutable"
1050
1146
  }
1051
1147
  headers.etag ??= staticEtag(stat.size, stat.mtimeMs)
@@ -1117,6 +1213,14 @@ async function readStatic(
1117
1213
  // @nifrajs/core, and no-op on a handler that does not expose the seam (a plain `{ fetch }`) or one that
1118
1214
  // is already frozen (the per-request `resolveNodeSource` path supplies the runtime regardless).
1119
1215
  const INSTALL_NODE_DIRECT = Symbol.for("@nifrajs/core/install-node-direct")
1216
+ // A nifra server logs what an adapter reports through this seam, as Bun's `listen()` does; a plain
1217
+ // `{ fetch }` handler has no logger, so its failures stay a bare 500.
1218
+ const REPORT_ERROR = Symbol.for("@nifrajs/core/report-error")
1219
+ function errorReporterOf(app: FetchHandler): ((error: unknown) => void) | undefined {
1220
+ const report: unknown = Reflect.get(app, REPORT_ERROR)
1221
+ return typeof report === "function" ? (error) => report.call(app, error) : undefined
1222
+ }
1223
+
1120
1224
  function enableNodeDirect(app: FetchHandler): void {
1121
1225
  const install = (app as unknown as Record<symbol, unknown>)[INSTALL_NODE_DIRECT]
1122
1226
  if (typeof install !== "function") return
@@ -1148,6 +1252,8 @@ interface ActiveResponseState {
1148
1252
  readonly socket: { readonly destroyed: boolean; destroy(): void }
1149
1253
  active: number
1150
1254
  parserError: boolean
1255
+ /** The served app's own error log, when it has one. */
1256
+ readonly report: ((error: unknown) => void) | undefined
1151
1257
  }
1152
1258
 
1153
1259
  const ACTIVE_RESPONSE_STATE = Symbol("nifra.node.active-response-state")
@@ -1184,12 +1290,21 @@ function clearActiveResponseState(this: TrackedSocket): void {
1184
1290
  export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeServer> {
1185
1291
  activateAsyncContextFrame()
1186
1292
  enableNodeDirect(app)
1293
+ const report = errorReporterOf(app)
1187
1294
  if (options.fastResponse === true) installFastResponse()
1188
1295
  let inFlight = 0
1189
1296
  let closed = false
1190
1297
  // Upgrade sockets an HTTP route answers instead: Node's server no longer tracks them, so stop()
1191
1298
  // drains and closes them itself.
1192
1299
  const answering = new Set<Duplex>()
1300
+ // Upgraded WebSockets: Node's server stops tracking a socket once `ws` takes it over.
1301
+ const upgraded = new Set<WsSocket>()
1302
+ const adopt = (ws: WsSocket): boolean => {
1303
+ if (closed) return false
1304
+ upgraded.add(ws)
1305
+ ws.on("close", () => upgraded.delete(ws))
1306
+ return true
1307
+ }
1193
1308
  const protocol = protocolResolver(
1194
1309
  options.protocol ?? (options.tls === undefined ? undefined : "https"),
1195
1310
  )
@@ -1203,7 +1318,7 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1203
1318
  const socket = nodeReq.socket as TrackedSocket
1204
1319
  let responseState = socket[ACTIVE_SOCKET_STATE]
1205
1320
  if (responseState === undefined) {
1206
- responseState = { socket, active: 0, parserError: false }
1321
+ responseState = { socket, active: 0, parserError: false, report }
1207
1322
  socket[ACTIVE_SOCKET_STATE] = responseState
1208
1323
  socket.once("close", clearActiveResponseState)
1209
1324
  }
@@ -1218,18 +1333,19 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1218
1333
  if (handled instanceof Promise) {
1219
1334
  // `catch` before `finally`: a `finally` alone forwards the rejection to a promise nothing
1220
1335
  // 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.
1336
+ // writer already ends and reports its own failures (`failAndReport`), so anything arriving
1337
+ // here is a fault no response can still be built from - the connection is the only thing
1338
+ // left to end.
1223
1339
  void handled
1224
- .catch(() => failWrite(nodeRes))
1340
+ .catch((error: unknown) => failAndReport(nodeRes, error))
1225
1341
  .finally(() => {
1226
1342
  inFlight -= 1
1227
1343
  })
1228
1344
  return
1229
1345
  }
1230
1346
  inFlight -= 1
1231
- } catch {
1232
- failWrite(nodeRes)
1347
+ } catch (error) {
1348
+ failAndReport(nodeRes, error)
1233
1349
  inFlight -= 1
1234
1350
  }
1235
1351
  }
@@ -1261,11 +1377,14 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1261
1377
  // the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
1262
1378
  // upgrade, so a non-WS Node app never loads `ws`.
1263
1379
  if (resolveWs !== undefined) {
1264
- let wssPromise: Promise<WsServer | undefined> | undefined
1380
+ // One `ws` server per payload cap: its `maxPayload` bounds buffering before a frame is delivered,
1381
+ // and mounted children can declare different caps.
1382
+ const servers = new Map<number | undefined, Promise<WsServer | undefined>>()
1265
1383
  server.on("upgrade", (nodeReq, socket, head) => {
1266
1384
  void handleUpgrade(
1267
1385
  app,
1268
1386
  answering,
1387
+ adopt,
1269
1388
  resolveWs,
1270
1389
  protocol,
1271
1390
  hostPolicy,
@@ -1273,8 +1392,12 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1273
1392
  socket,
1274
1393
  head,
1275
1394
  (maxPayloadBytes) => {
1276
- wssPromise ??= loadWsServer(maxPayloadBytes)
1277
- return wssPromise
1395
+ let wss = servers.get(maxPayloadBytes)
1396
+ if (wss === undefined) {
1397
+ wss = loadWsServer(maxPayloadBytes)
1398
+ servers.set(maxPayloadBytes, wss)
1399
+ }
1400
+ return wss
1278
1401
  },
1279
1402
  )
1280
1403
  })
@@ -1282,25 +1405,29 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1282
1405
 
1283
1406
  // Opt-in: own SIGTERM/SIGINT so `docker stop` / Ctrl-C drains in-flight requests
1284
1407
  // before exit. `stop` is hoisted, so `onSignal` can reference it.
1408
+ const signals = options.signals === true ? {} : options.signals || undefined
1285
1409
  const onSignal = (): void => {
1286
- void stop()
1410
+ void stop(signals)
1287
1411
  }
1288
1412
 
1289
1413
  async function stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
1290
1414
  if (closed) return // idempotent
1291
1415
  closed = true
1292
- if (options.signals === true) {
1416
+ if (signals !== undefined) {
1293
1417
  // Remove our own handlers so repeated serve()/stop() cycles don't leak listeners.
1294
1418
  process.removeListener("SIGTERM", onSignal)
1295
1419
  process.removeListener("SIGINT", onSignal)
1296
1420
  }
1297
1421
  server.close() // stop accepting new connections; existing requests continue
1422
+ // A WebSocket gets a close handshake now and the drain window to finish it.
1423
+ for (const ws of upgraded) ws.close(1001, "server shutting down")
1298
1424
  const deadline = Date.now() + drainMs
1299
- while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
1425
+ while ((inFlight > 0 || answering.size > 0 || upgraded.size > 0) && Date.now() < deadline) {
1300
1426
  await new Promise((r) => setTimeout(r, DRAIN_POLL_MS))
1301
1427
  }
1302
1428
  server.closeAllConnections() // force-close stragglers + idle keep-alive sockets
1303
1429
  for (const socket of answering) socket.destroy()
1430
+ for (const ws of upgraded) ws.terminate()
1304
1431
  }
1305
1432
 
1306
1433
  return new Promise((resolve) => {
@@ -1310,7 +1437,7 @@ export function serve(app: FetchHandler, options: ServeOptions): Promise<NodeSer
1310
1437
  const onListening = (): void => {
1311
1438
  const address = server.address()
1312
1439
  const port = address !== null && typeof address === "object" ? address.port : options.port
1313
- if (options.signals === true) {
1440
+ if (signals !== undefined) {
1314
1441
  process.once("SIGTERM", onSignal)
1315
1442
  process.once("SIGINT", onSignal)
1316
1443
  }
@@ -1340,11 +1467,11 @@ function runNodeSource(
1340
1467
  return outcome instanceof Promise
1341
1468
  ? outcome.then(
1342
1469
  (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1343
- () => failWrite(nodeRes),
1470
+ (error: unknown) => failAndReport(nodeRes, error),
1344
1471
  )
1345
1472
  : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1346
- } catch {
1347
- failWrite(nodeRes)
1473
+ } catch (error) {
1474
+ failAndReport(nodeRes, error)
1348
1475
  return
1349
1476
  }
1350
1477
  }
@@ -1357,11 +1484,11 @@ function runNodeSource(
1357
1484
  return outcome instanceof Promise
1358
1485
  ? outcome.then(
1359
1486
  (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1360
- () => failWrite(nodeRes),
1487
+ (error: unknown) => failAndReport(nodeRes, error),
1361
1488
  )
1362
1489
  : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1363
- } catch {
1364
- failWrite(nodeRes)
1490
+ } catch (error) {
1491
+ failAndReport(nodeRes, error)
1365
1492
  return
1366
1493
  }
1367
1494
  }
@@ -1371,12 +1498,12 @@ function runNodeSource(
1371
1498
  return response instanceof Promise
1372
1499
  ? response.then(
1373
1500
  (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1374
- () => failWrite(nodeRes),
1501
+ (error: unknown) => failAndReport(nodeRes, error),
1375
1502
  )
1376
1503
  : 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)
1504
+ } catch (error) {
1505
+ // A request hook that throws escapes `fetch`; never leak a stack to the wire.
1506
+ failAndReport(nodeRes, error)
1380
1507
  return
1381
1508
  }
1382
1509
  }
@@ -1447,7 +1574,13 @@ function handle(
1447
1574
  const matched = staticMatch(staticState, nodeReq.url ?? "/")
1448
1575
  if (matched !== "pass") {
1449
1576
  if ("reject" in matched) return writeResponseSafely(matched.reject, nodeRes, nodeReq.method)
1450
- return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then(
1577
+ return readStatic(
1578
+ matched.file,
1579
+ matched.immutable,
1580
+ staticState,
1581
+ nodeReq.method ?? "GET",
1582
+ nodeReq.headers,
1583
+ ).then(
1451
1584
  (outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method),
1452
1585
  () => failWrite(nodeRes),
1453
1586
  )
@@ -1493,12 +1626,12 @@ function handle(
1493
1626
  handled === false
1494
1627
  ? runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform)
1495
1628
  : undefined,
1496
- () => failWrite(nodeRes),
1629
+ (error: unknown) => failAndReport(nodeRes, error),
1497
1630
  )
1498
1631
  }
1499
1632
  if (outcome !== false) return
1500
- } catch {
1501
- failWrite(nodeRes)
1633
+ } catch (error) {
1634
+ failAndReport(nodeRes, error)
1502
1635
  return
1503
1636
  }
1504
1637
  }
@@ -1515,11 +1648,11 @@ function handle(
1515
1648
  return outcome instanceof Promise
1516
1649
  ? outcome.then(
1517
1650
  (settled) => writeOutcomeSafely(settled, nodeRes, nodeReq.method),
1518
- () => failWrite(nodeRes),
1651
+ (error: unknown) => failAndReport(nodeRes, error),
1519
1652
  )
1520
1653
  : writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
1521
- } catch {
1522
- failWrite(nodeRes)
1654
+ } catch (error) {
1655
+ failAndReport(nodeRes, error)
1523
1656
  return
1524
1657
  }
1525
1658
  }
@@ -1529,12 +1662,12 @@ function handle(
1529
1662
  return response instanceof Promise
1530
1663
  ? response.then(
1531
1664
  (settled) => writeResponseSafely(settled, nodeRes, nodeReq.method),
1532
- () => failWrite(nodeRes),
1665
+ (error: unknown) => failAndReport(nodeRes, error),
1533
1666
  )
1534
1667
  : 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)
1668
+ } catch (error) {
1669
+ // A request hook that throws escapes `fetch`; never leak a stack to the wire.
1670
+ failAndReport(nodeRes, error)
1538
1671
  return
1539
1672
  }
1540
1673
  }
@@ -1578,6 +1711,9 @@ function writeInternalError(nodeRes: ServerResponse): void {
1578
1711
  function failWrite(nodeRes: ServerResponse): void {
1579
1712
  try {
1580
1713
  if (!nodeRes.headersSent) {
1714
+ // The failed write may have staged the response's own headers (cookies among them) with
1715
+ // `setHeaders`; the 500 must not carry them.
1716
+ for (const name of nodeRes.getHeaderNames()) nodeRes.removeHeader(name)
1581
1717
  writeInternalError(nodeRes)
1582
1718
  return
1583
1719
  }
@@ -1587,7 +1723,20 @@ function failWrite(nodeRes: ServerResponse): void {
1587
1723
  nodeRes.destroy()
1588
1724
  }
1589
1725
 
1590
- /** {@link writeNodeOutcome} with {@link failWrite} behind it, sync throw and async rejection alike. */
1726
+ /** {@link failWrite} for a failure the app never answered, logged through the app's own logger. */
1727
+ function failAndReport(nodeRes: ServerResponse, error: unknown): void {
1728
+ const state: ActiveResponseState | undefined = Reflect.get(nodeRes, ACTIVE_RESPONSE_STATE)
1729
+ const report = state?.report
1730
+ try {
1731
+ report?.(error)
1732
+ } catch {
1733
+ // A logger that throws must not keep the 500 from going out.
1734
+ }
1735
+ failWrite(nodeRes)
1736
+ }
1737
+
1738
+ /** {@link writeNodeOutcome} with {@link failAndReport} behind it, sync throw and async rejection
1739
+ * alike. */
1591
1740
  function writeOutcomeSafely(
1592
1741
  outcome: NodeServeOutcome,
1593
1742
  nodeRes: ServerResponse,
@@ -1595,14 +1744,16 @@ function writeOutcomeSafely(
1595
1744
  ): void | Promise<void> {
1596
1745
  try {
1597
1746
  const written = writeNodeOutcome(outcome, nodeRes, method)
1598
- return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1599
- } catch {
1600
- failWrite(nodeRes)
1747
+ return written instanceof Promise
1748
+ ? written.catch((error: unknown) => failAndReport(nodeRes, error))
1749
+ : written
1750
+ } catch (error) {
1751
+ failAndReport(nodeRes, error)
1601
1752
  return
1602
1753
  }
1603
1754
  }
1604
1755
 
1605
- /** {@link writeNodeResponse} with {@link failWrite} behind it - same contract as
1756
+ /** {@link writeNodeResponse} with {@link failAndReport} behind it - same contract as
1606
1757
  * {@link writeOutcomeSafely}, for the lanes that carry a `Response`. */
1607
1758
  function writeResponseSafely(
1608
1759
  response: Response,
@@ -1611,9 +1762,11 @@ function writeResponseSafely(
1611
1762
  ): void | Promise<void> {
1612
1763
  try {
1613
1764
  const written = writeNodeResponse(response, nodeRes, method)
1614
- return written instanceof Promise ? written.catch(() => failWrite(nodeRes)) : written
1615
- } catch {
1616
- failWrite(nodeRes)
1765
+ return written instanceof Promise
1766
+ ? written.catch((error: unknown) => failAndReport(nodeRes, error))
1767
+ : written
1768
+ } catch (error) {
1769
+ failAndReport(nodeRes, error)
1617
1770
  return
1618
1771
  }
1619
1772
  }
@@ -2436,6 +2589,8 @@ function writeNodeResponse(
2436
2589
  nodeRes: ServerResponse,
2437
2590
  method?: string,
2438
2591
  ): void | Promise<void> {
2592
+ // A body already sent once answered another request, with headers written for that one.
2593
+ if (response.bodyUsed) throw new TypeError("[nifra] a Response body can be sent only once")
2439
2594
  const isHead = method?.toUpperCase() === "HEAD"
2440
2595
  // `ServerResponse.setHeaders` (Node 18.14+) takes the Headers object directly and iterates its
2441
2596
  // native Symbol.iterator, which - unlike `Headers.forEach`/`.get()` - never comma-joins repeated
@@ -2682,12 +2837,14 @@ async function loadWsServer(maxPayloadBytes?: number): Promise<WsServer | undefi
2682
2837
  default?: { WebSocketServer?: WsServerCtor }
2683
2838
  }
2684
2839
  const Ctor = mod.WebSocketServer ?? mod.default?.WebSocketServer
2840
+ // `ws` reads `maxPayload: 0` as no limit, so a cap of 0 buffers at most a byte and the message
2841
+ // check in `attachNodeWebSocket` refuses anything but an empty frame.
2685
2842
  return Ctor === undefined
2686
2843
  ? undefined
2687
2844
  : new Ctor(
2688
2845
  maxPayloadBytes === undefined
2689
2846
  ? { noServer: true }
2690
- : { noServer: true, maxPayload: maxPayloadBytes },
2847
+ : { noServer: true, maxPayload: Math.max(maxPayloadBytes, 1) },
2691
2848
  )
2692
2849
  } catch {
2693
2850
  return undefined // `ws` not installed - caller responds 501
@@ -2709,6 +2866,7 @@ const WS_STATUS_TEXT: Readonly<Record<number, string>> = {
2709
2866
  async function handleUpgrade(
2710
2867
  app: FetchHandler,
2711
2868
  answering: Set<Duplex>,
2869
+ adopt: (ws: WsSocket) => boolean,
2712
2870
  resolveWs: (
2713
2871
  request: Request,
2714
2872
  platform?: NodePlatform,
@@ -2774,8 +2932,15 @@ async function handleUpgrade(
2774
2932
  writeUpgradeRejection(socket, 501, "websocket_unavailable") // `ws` not installed
2775
2933
  return
2776
2934
  }
2777
- const { handler, data, pubsub } = outcome
2778
- wss.handleUpgrade(nodeReq, socket, head, (ws) => attachNodeWebSocket(ws, handler, data, pubsub))
2935
+ const upgrade = outcome
2936
+ wss.handleUpgrade(nodeReq, socket, head, (ws) => {
2937
+ // An upgrade still resolving when stop() ran must not outlive it.
2938
+ if (!adopt(ws)) {
2939
+ ws.terminate()
2940
+ return
2941
+ }
2942
+ attachNodeWebSocket(ws, upgrade)
2943
+ })
2779
2944
  }
2780
2945
 
2781
2946
  /** Wire a `ws` socket (already open in `handleUpgrade`'s callback) to a nifra WS handler. The Node copy
@@ -2783,12 +2948,12 @@ async function handleUpgrade(
2783
2948
  * routes to `error()` and never crashes the connection. */
2784
2949
  function attachNodeWebSocket(
2785
2950
  ws: WsSocket,
2786
- handler: NifraWsHandler,
2787
- data: unknown,
2788
- pubsub: WsPubSub,
2951
+ outcome: Extract<WsUpgradeOutcome, { readonly kind: "upgrade" }>,
2789
2952
  ): void {
2953
+ const { handler, data, pubsub, maxPayloadBytes } = outcome
2954
+ const rawSend = (payload: string | ArrayBufferView | ArrayBuffer): void => ws.send(payload)
2790
2955
  const nifra: NifraWs = {
2791
- send: (payload) => ws.send(payload),
2956
+ send: outcome.sender === undefined ? rawSend : outcome.sender(handler, rawSend, reportError),
2792
2957
  close: (code, reason) => ws.close(code, reason),
2793
2958
  get readyState() {
2794
2959
  return ws.readyState
@@ -2798,7 +2963,7 @@ function attachNodeWebSocket(
2798
2963
  data,
2799
2964
  raw: ws,
2800
2965
  }
2801
- const reportError = (error: unknown): void => {
2966
+ function reportError(error: unknown): void {
2802
2967
  if (handler.error === undefined) return
2803
2968
  try {
2804
2969
  const r = handler.error(nifra, error)
@@ -2816,6 +2981,10 @@ function attachNodeWebSocket(
2816
2981
  }
2817
2982
  }
2818
2983
  ws.on("message", (raw, isBinary) => {
2984
+ if (maxPayloadBytes !== undefined && raw.byteLength > maxPayloadBytes) {
2985
+ ws.close(1009, "message too large")
2986
+ return
2987
+ }
2819
2988
  const payload: NifraWsData = isBinary
2820
2989
  ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength)
2821
2990
  : raw.toString()