@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/README.md +17 -4
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +221 -79
- 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 +251 -82
- 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 {
|
|
@@ -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:
|
|
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(
|
|
279
|
+
return nodeOutcomeFromResponse(applySetToResponse(result.toResponse(), set))
|
|
264
280
|
}
|
|
265
281
|
if (result instanceof Response) {
|
|
266
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
519
|
-
return response
|
|
575
|
+
return write(response)
|
|
520
576
|
} catch {
|
|
521
|
-
// Guarded `Headers`
|
|
522
|
-
//
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
+
status: number,
|
|
695
|
+
set: NodeContextSet,
|
|
633
696
|
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1036
|
-
//
|
|
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
|
|
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 (
|
|
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 (`
|
|
1222
|
-
// no response can still be built from - the connection is the only thing
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
() =>
|
|
1470
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1344
1471
|
)
|
|
1345
1472
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1346
|
-
} catch {
|
|
1347
|
-
|
|
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
|
-
() =>
|
|
1487
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1361
1488
|
)
|
|
1362
1489
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1363
|
-
} catch {
|
|
1364
|
-
|
|
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
|
-
() =>
|
|
1501
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1375
1502
|
)
|
|
1376
1503
|
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1377
|
-
} catch {
|
|
1378
|
-
//
|
|
1379
|
-
|
|
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(
|
|
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
|
-
() =>
|
|
1629
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1497
1630
|
)
|
|
1498
1631
|
}
|
|
1499
1632
|
if (outcome !== false) return
|
|
1500
|
-
} catch {
|
|
1501
|
-
|
|
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
|
-
() =>
|
|
1651
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1519
1652
|
)
|
|
1520
1653
|
: writeOutcomeSafely(outcome, nodeRes, nodeReq.method)
|
|
1521
|
-
} catch {
|
|
1522
|
-
|
|
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
|
-
() =>
|
|
1665
|
+
(error: unknown) => failAndReport(nodeRes, error),
|
|
1533
1666
|
)
|
|
1534
1667
|
: writeResponseSafely(response, nodeRes, nodeReq.method)
|
|
1535
|
-
} catch {
|
|
1536
|
-
//
|
|
1537
|
-
|
|
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
|
|
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
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
|
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
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
|
2778
|
-
wss.handleUpgrade(nodeReq, socket, head, (ws) =>
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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()
|