@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/README.md +17 -4
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +216 -76
- package/dist/index.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +64 -0
- package/dist/open-resolved.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +241 -77
- package/src/open-resolved.ts +66 -0
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 {
|
|
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:
|
|
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(
|
|
273
|
+
return nodeOutcomeFromResponse(applySetToResponse(result.toResponse(), set))
|
|
264
274
|
}
|
|
265
275
|
if (result instanceof Response) {
|
|
266
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
519
|
-
return response
|
|
569
|
+
return write(response)
|
|
520
570
|
} catch {
|
|
521
|
-
// Guarded `Headers`
|
|
522
|
-
//
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
688
|
+
status: number,
|
|
689
|
+
set: NodeContextSet,
|
|
633
690
|
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1036
|
-
//
|
|
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
|
|
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 (
|
|
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 (`
|
|
1222
|
-
// no response can still be built from - the connection is the only thing
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
() =>
|
|
1464
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1344
1465
|
)
|
|
1345
1466
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1346
|
-
} catch {
|
|
1347
|
-
|
|
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
|
-
() =>
|
|
1481
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1361
1482
|
)
|
|
1362
1483
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1363
|
-
} catch {
|
|
1364
|
-
|
|
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
|
-
() =>
|
|
1495
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1375
1496
|
)
|
|
1376
1497
|
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1377
|
-
} catch {
|
|
1378
|
-
//
|
|
1379
|
-
|
|
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(
|
|
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
|
-
() =>
|
|
1623
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1497
1624
|
)
|
|
1498
1625
|
}
|
|
1499
1626
|
if (outcome !== false) return
|
|
1500
|
-
} catch {
|
|
1501
|
-
|
|
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
|
-
() =>
|
|
1645
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1519
1646
|
)
|
|
1520
1647
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1521
|
-
} catch {
|
|
1522
|
-
|
|
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
|
-
() =>
|
|
1659
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1533
1660
|
)
|
|
1534
1661
|
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1535
|
-
} catch {
|
|
1536
|
-
//
|
|
1537
|
-
|
|
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
|
|
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
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
|
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
|
|
1615
|
-
|
|
1616
|
-
|
|
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) =>
|
|
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()
|