@nifrajs/node 3.5.0 → 4.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 +5 -0
- package/dist/generated/request-target.d.ts +17 -0
- package/dist/generated/request-target.d.ts.map +1 -0
- package/dist/generated/request-target.js +46 -0
- package/dist/generated/request-target.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +448 -109
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/generated/request-target.ts +49 -0
- package/src/index.ts +540 -101
package/dist/index.js
CHANGED
|
@@ -21,12 +21,14 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
21
21
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
22
|
import { constants as FS } from "node:fs";
|
|
23
23
|
import { open, realpath } from "node:fs/promises";
|
|
24
|
-
import { createServer, } from "node:http";
|
|
24
|
+
import { createServer, STATUS_CODES, } from "node:http";
|
|
25
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
25
26
|
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
26
27
|
import { fileURLToPath } from "node:url";
|
|
27
28
|
// srvx's lazy spec-shaped Response - see nodeOutcomeToResponse for why the bridge uses it.
|
|
28
29
|
import { FastResponse } from "srvx/node";
|
|
29
30
|
import { NODE_BRIDGE_MARKER_KEYS } from "./generated/bridge-markers.js";
|
|
31
|
+
import { hasDotSegment, resolveDotSegments } from "./generated/request-target.js";
|
|
30
32
|
import { claimableWebStream, claimNodeStream } from "./node-stream.js";
|
|
31
33
|
const RESOLVE_NODE_MOUNT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.resolveNodeMount);
|
|
32
34
|
/**
|
|
@@ -290,11 +292,21 @@ function installFastResponse() {
|
|
|
290
292
|
globalThis.Response = DeferringResponse;
|
|
291
293
|
}
|
|
292
294
|
function appendCookiesToResponse(response, cookies) {
|
|
293
|
-
if (cookies
|
|
295
|
+
if (cookies === undefined || cookies.length === 0)
|
|
296
|
+
return response;
|
|
297
|
+
try {
|
|
294
298
|
for (const cookie of cookies)
|
|
295
299
|
response.headers.append("set-cookie", cookie);
|
|
300
|
+
return response;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Guarded `Headers` (`Response.redirect()`, a raw `fetch()` result) reject every write, so the
|
|
304
|
+
// first append throws with nothing applied and the cookies land on a mutable copy instead.
|
|
305
|
+
const clone = new Response(response.body, response);
|
|
306
|
+
for (const cookie of cookies)
|
|
307
|
+
clone.headers.append("set-cookie", cookie);
|
|
308
|
+
return clone;
|
|
296
309
|
}
|
|
297
|
-
return response;
|
|
298
310
|
}
|
|
299
311
|
/** Early exits built outside the handler's finalizer - an `onRequest` hook's response, a mount's,
|
|
300
312
|
* every framework error render. A plain-data carrier takes the `kind: "json"` lane a handler's plain
|
|
@@ -370,7 +382,11 @@ function nodeOutcomeToResponse(outcome) {
|
|
|
370
382
|
pairs.splice(i, 1);
|
|
371
383
|
}
|
|
372
384
|
}
|
|
373
|
-
const body = isBodylessStatus(outcome.status)
|
|
385
|
+
const body = isBodylessStatus(outcome.status) || outcome.body === null
|
|
386
|
+
? null
|
|
387
|
+
: typeof outcome.body === "string"
|
|
388
|
+
? outcome.body
|
|
389
|
+
: outcome.body.slice();
|
|
374
390
|
const response = new FastResponse(body, {
|
|
375
391
|
status: outcome.status,
|
|
376
392
|
headers: pairs,
|
|
@@ -528,6 +544,14 @@ const STATIC_CONTENT_TYPES = {
|
|
|
528
544
|
".txt": "text/plain; charset=utf-8",
|
|
529
545
|
".webmanifest": "application/manifest+json",
|
|
530
546
|
};
|
|
547
|
+
function staticHeadersOf(input) {
|
|
548
|
+
if (input === undefined)
|
|
549
|
+
return undefined;
|
|
550
|
+
const headers = Object.create(null);
|
|
551
|
+
for (const [name, value] of Object.entries(input))
|
|
552
|
+
headers[name.toLowerCase()] = value;
|
|
553
|
+
return headers;
|
|
554
|
+
}
|
|
531
555
|
function staticStateOf(options) {
|
|
532
556
|
const root = resolve(typeof options.dir === "string" ? options.dir : fileURLToPath(options.dir));
|
|
533
557
|
const raw = options.prefix ?? "/assets";
|
|
@@ -543,7 +567,7 @@ function staticStateOf(options) {
|
|
|
543
567
|
root,
|
|
544
568
|
prefix,
|
|
545
569
|
immutable: options.immutable !== false,
|
|
546
|
-
headers: options.headers,
|
|
570
|
+
headers: staticHeadersOf(options.headers),
|
|
547
571
|
denyDotfiles: options.dotfiles !== "allow",
|
|
548
572
|
};
|
|
549
573
|
}
|
|
@@ -596,7 +620,80 @@ function staticMatch(state, rawUrl) {
|
|
|
596
620
|
}
|
|
597
621
|
return { file };
|
|
598
622
|
}
|
|
599
|
-
|
|
623
|
+
/** Strong validator from mtime + size (the nginx scheme): rewriting a file changes at least one. */
|
|
624
|
+
function staticEtag(size, mtimeMs) {
|
|
625
|
+
return `"${Math.floor(mtimeMs).toString(16)}-${size.toString(16)}"`;
|
|
626
|
+
}
|
|
627
|
+
function weakEtag(value) {
|
|
628
|
+
return value.startsWith("W/") ? value.slice(2) : value;
|
|
629
|
+
}
|
|
630
|
+
/** `If-None-Match` uses the weak comparison (RFC 9110 section 13.1.2): a `W/` prefix is ignored. */
|
|
631
|
+
function etagListMatches(header, etag) {
|
|
632
|
+
const comparable = weakEtag(etag);
|
|
633
|
+
for (const raw of header.split(",")) {
|
|
634
|
+
const candidate = raw.trim();
|
|
635
|
+
if (candidate === "*" || weakEtag(candidate) === comparable)
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
/** Whether the client's cached copy is current. `If-None-Match` wins over `If-Modified-Since`, whose
|
|
641
|
+
* HTTP-date only carries whole seconds. */
|
|
642
|
+
function staticNotModified(headers, etag, lastModified) {
|
|
643
|
+
const ifNoneMatch = headers["if-none-match"];
|
|
644
|
+
if (typeof ifNoneMatch === "string")
|
|
645
|
+
return etagListMatches(ifNoneMatch, etag);
|
|
646
|
+
if (lastModified === undefined)
|
|
647
|
+
return false;
|
|
648
|
+
const ifModifiedSince = headers["if-modified-since"];
|
|
649
|
+
if (typeof ifModifiedSince !== "string")
|
|
650
|
+
return false;
|
|
651
|
+
const since = Date.parse(ifModifiedSince);
|
|
652
|
+
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
|
|
653
|
+
}
|
|
654
|
+
function staticIfRangeMatches(value, etag, lastModified) {
|
|
655
|
+
const item = value.trim();
|
|
656
|
+
if (item.startsWith('"') || item.startsWith("W/")) {
|
|
657
|
+
return !item.startsWith("W/") && !etag.startsWith("W/") && item === etag;
|
|
658
|
+
}
|
|
659
|
+
// A date validator must match Last-Modified exactly (RFC 9110 section 13.1.5), not merely be later:
|
|
660
|
+
// a file swapped for one with an older mtime would otherwise splice foreign bytes into a resume.
|
|
661
|
+
if (lastModified === undefined)
|
|
662
|
+
return false;
|
|
663
|
+
const date = Date.parse(item);
|
|
664
|
+
return Number.isFinite(date) && Math.floor(lastModified / 1000) === Math.floor(date / 1000);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* One `bytes=` range as inclusive offsets, `"unsatisfiable"` (416), or `undefined` to send the whole
|
|
668
|
+
* file: no header, a malformed or multi-range one (RFC 9110 lets a server ignore Range), or an
|
|
669
|
+
* `If-Range` validator that no longer matches the file.
|
|
670
|
+
*/
|
|
671
|
+
function staticByteRange(headers, size, etag, lastModified) {
|
|
672
|
+
const range = headers.range;
|
|
673
|
+
if (typeof range !== "string")
|
|
674
|
+
return undefined;
|
|
675
|
+
const ifRange = headers["if-range"];
|
|
676
|
+
if (typeof ifRange === "string" && !staticIfRangeMatches(ifRange, etag, lastModified)) {
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
|
680
|
+
if (match === null)
|
|
681
|
+
return undefined;
|
|
682
|
+
const first = match[1];
|
|
683
|
+
const last = match[2];
|
|
684
|
+
if (first === "") {
|
|
685
|
+
if (last === "")
|
|
686
|
+
return undefined;
|
|
687
|
+
const suffix = Number(last);
|
|
688
|
+
return suffix === 0 || size === 0 ? "unsatisfiable" : [Math.max(0, size - suffix), size - 1];
|
|
689
|
+
}
|
|
690
|
+
const start = Number(first);
|
|
691
|
+
if (start >= size)
|
|
692
|
+
return "unsatisfiable";
|
|
693
|
+
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
|
|
694
|
+
return end < start ? undefined : [start, end];
|
|
695
|
+
}
|
|
696
|
+
async function readStatic(file, state, method, requestHeaders) {
|
|
600
697
|
let handle;
|
|
601
698
|
try {
|
|
602
699
|
// Defense-in-depth: the lexical `..` guard in staticMatch can't catch a symlink INSIDE root that
|
|
@@ -617,26 +714,59 @@ async function readStatic(file, state, method) {
|
|
|
617
714
|
return { kind: "response", response: new Response("Not Found", { status: 404 }) };
|
|
618
715
|
}
|
|
619
716
|
const headers = { ...state.headers };
|
|
717
|
+
if (state.immutable && headers["cache-control"] === undefined) {
|
|
718
|
+
headers["cache-control"] = "public, max-age=31536000, immutable";
|
|
719
|
+
}
|
|
720
|
+
headers.etag ??= staticEtag(stat.size, stat.mtimeMs);
|
|
721
|
+
headers["last-modified"] ??= stat.mtime.toUTCString();
|
|
722
|
+
// Custom validators are response headers, not decoration: compare the exact values the client saw.
|
|
723
|
+
const etag = headers.etag;
|
|
724
|
+
const parsedLastModified = Date.parse(headers["last-modified"]);
|
|
725
|
+
const lastModified = Number.isFinite(parsedLastModified) ? parsedLastModified : undefined;
|
|
726
|
+
// Revalidation: a current cached copy costs a header-only 304 instead of the whole file.
|
|
727
|
+
if (staticNotModified(requestHeaders, etag, lastModified)) {
|
|
728
|
+
await handle.close();
|
|
729
|
+
delete headers["content-length"];
|
|
730
|
+
delete headers["content-type"];
|
|
731
|
+
return { kind: "response", response: new Response(null, { status: 304, headers }) };
|
|
732
|
+
}
|
|
620
733
|
headers["content-type"] =
|
|
621
734
|
STATIC_CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
622
735
|
// Never let a client sniff a served file into a more dangerous type (e.g. an .svg as active content).
|
|
623
736
|
headers["x-content-type-options"] = "nosniff";
|
|
624
|
-
headers["
|
|
625
|
-
|
|
626
|
-
|
|
737
|
+
headers["accept-ranges"] = "bytes";
|
|
738
|
+
// Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
|
|
739
|
+
const range = method === "GET" ? staticByteRange(requestHeaders, stat.size, etag, lastModified) : undefined;
|
|
740
|
+
if (range === "unsatisfiable") {
|
|
741
|
+
await handle.close();
|
|
742
|
+
headers["content-range"] = `bytes */${stat.size}`;
|
|
743
|
+
delete headers["content-length"];
|
|
744
|
+
delete headers["content-type"];
|
|
745
|
+
return { kind: "response", response: new Response(null, { status: 416, headers }) };
|
|
746
|
+
}
|
|
747
|
+
let status = 200;
|
|
748
|
+
if (range !== undefined) {
|
|
749
|
+
status = 206;
|
|
750
|
+
headers["content-range"] = `bytes ${range[0]}-${range[1]}/${stat.size}`;
|
|
751
|
+
headers["content-length"] = String(range[1] - range[0] + 1);
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
headers["content-length"] = String(stat.size);
|
|
627
755
|
}
|
|
628
756
|
if (method === "HEAD") {
|
|
629
757
|
await handle.close();
|
|
630
758
|
return { kind: "response", response: new Response(null, { headers }) };
|
|
631
759
|
}
|
|
632
|
-
const stream =
|
|
760
|
+
const stream = range === undefined
|
|
761
|
+
? handle.createReadStream()
|
|
762
|
+
: handle.createReadStream({ start: range[0], end: range[1] });
|
|
633
763
|
return {
|
|
634
764
|
kind: "response",
|
|
635
765
|
// Claimable rather than `Readable.toWeb`: served straight from disk to the socket when this
|
|
636
766
|
// response reaches the writer untouched, and read as an ordinary Web stream by anything that
|
|
637
767
|
// gets to it first (a middleware that rewrites or compresses the body, say), which refuses
|
|
638
768
|
// the claim and takes the conversion instead.
|
|
639
|
-
response: new Response(claimableWebStream(stream), { headers }),
|
|
769
|
+
response: new Response(claimableWebStream(stream), { status, headers }),
|
|
640
770
|
};
|
|
641
771
|
}
|
|
642
772
|
catch {
|
|
@@ -684,6 +814,26 @@ function activateAsyncContextFrame() {
|
|
|
684
814
|
asyncContextFrameActivated = true;
|
|
685
815
|
new AsyncLocalStorage();
|
|
686
816
|
}
|
|
817
|
+
const ACTIVE_RESPONSE_STATE = Symbol("nifra.node.active-response-state");
|
|
818
|
+
const ACTIVE_RESPONSE_RELEASED = Symbol("nifra.node.active-response-released");
|
|
819
|
+
const ACTIVE_SOCKET_STATE = Symbol("nifra.node.active-socket-state");
|
|
820
|
+
/** Shared finish/close listener - response state carries the per-response release bit. */
|
|
821
|
+
function releaseActiveResponse() {
|
|
822
|
+
const response = this;
|
|
823
|
+
const state = response[ACTIVE_RESPONSE_STATE];
|
|
824
|
+
if (state === undefined || response[ACTIVE_RESPONSE_RELEASED])
|
|
825
|
+
return;
|
|
826
|
+
response[ACTIVE_RESPONSE_RELEASED] = true;
|
|
827
|
+
state.active -= 1;
|
|
828
|
+
if (state.parserError && state.active === 0 && !state.socket.destroyed) {
|
|
829
|
+
state.socket.destroy();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function clearActiveResponseState() {
|
|
833
|
+
const state = this[ACTIVE_SOCKET_STATE];
|
|
834
|
+
if (state !== undefined)
|
|
835
|
+
state.active = 0;
|
|
836
|
+
}
|
|
687
837
|
export function serve(app, options) {
|
|
688
838
|
activateAsyncContextFrame();
|
|
689
839
|
enableNodeDirect(app);
|
|
@@ -691,33 +841,29 @@ export function serve(app, options) {
|
|
|
691
841
|
installFastResponse();
|
|
692
842
|
let inFlight = 0;
|
|
693
843
|
let closed = false;
|
|
694
|
-
|
|
844
|
+
// Upgrade sockets an HTTP route answers instead: Node's server no longer tracks them, so stop()
|
|
845
|
+
// drains and closes them itself.
|
|
846
|
+
const answering = new Set();
|
|
847
|
+
const protocol = protocolResolver(options.protocol ?? (options.tls === undefined ? undefined : "https"));
|
|
695
848
|
const hostPolicy = hostPolicyOf(options);
|
|
696
849
|
const staticState = options.static !== undefined ? staticStateOf(options.static) : undefined;
|
|
697
850
|
// Node otherwise destroys a socket as soon as its HTTP parser emits `clientError`. That can
|
|
698
851
|
// discard the response for an already-dispatched request when an understated Content-Length
|
|
699
852
|
// leaves surplus bytes that look like a malformed pipelined request. Keep the parser error
|
|
700
853
|
// connection-scoped and close only after active responses finish, preserving response ordering.
|
|
701
|
-
const
|
|
702
|
-
const parserErrorSockets = new WeakSet();
|
|
703
|
-
const server = createServer((nodeReq, nodeRes) => {
|
|
854
|
+
const onRequest = (nodeReq, nodeRes) => {
|
|
704
855
|
const socket = nodeReq.socket;
|
|
705
|
-
let
|
|
706
|
-
if (
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (current.size === 0)
|
|
717
|
-
activeResponses.delete(socket);
|
|
718
|
-
};
|
|
719
|
-
nodeRes.once("finish", releaseResponse);
|
|
720
|
-
nodeRes.once("close", releaseResponse);
|
|
856
|
+
let responseState = socket[ACTIVE_SOCKET_STATE];
|
|
857
|
+
if (responseState === undefined) {
|
|
858
|
+
responseState = { socket, active: 0, parserError: false };
|
|
859
|
+
socket[ACTIVE_SOCKET_STATE] = responseState;
|
|
860
|
+
socket.once("close", clearActiveResponseState);
|
|
861
|
+
}
|
|
862
|
+
responseState.active += 1;
|
|
863
|
+
nodeRes[ACTIVE_RESPONSE_STATE] = responseState;
|
|
864
|
+
nodeRes[ACTIVE_RESPONSE_RELEASED] = false;
|
|
865
|
+
nodeRes.once("finish", releaseActiveResponse);
|
|
866
|
+
nodeRes.once("close", releaseActiveResponse);
|
|
721
867
|
inFlight += 1;
|
|
722
868
|
try {
|
|
723
869
|
const handled = handle(app, nodeReq, nodeRes, protocol, staticState, hostPolicy);
|
|
@@ -739,27 +885,22 @@ export function serve(app, options) {
|
|
|
739
885
|
failWrite(nodeRes);
|
|
740
886
|
inFlight -= 1;
|
|
741
887
|
}
|
|
742
|
-
}
|
|
888
|
+
};
|
|
889
|
+
const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
|
|
890
|
+
// Only a WebSocket handshake leaves the HTTP parser; any other `Upgrade` (curl's h2c offer) is an
|
|
891
|
+
// ordinary request. Node versions without the option send every upgrade to the `upgrade` event.
|
|
892
|
+
const upgradeOptions = resolveWs === undefined ? {} : { shouldUpgradeCallback: isWebSocketUpgrade };
|
|
893
|
+
const server = options.tls === undefined
|
|
894
|
+
? createServer(upgradeOptions, onRequest)
|
|
895
|
+
: createHttpsServer({ ...options.tls, ...upgradeOptions }, onRequest);
|
|
743
896
|
server.on("clientError", (_error, socket) => {
|
|
744
897
|
if (socket.destroyed)
|
|
745
898
|
return;
|
|
746
|
-
const
|
|
747
|
-
if (
|
|
748
|
-
if (
|
|
899
|
+
const responseState = socket[ACTIVE_SOCKET_STATE];
|
|
900
|
+
if (responseState !== undefined && responseState.active > 0) {
|
|
901
|
+
if (responseState.parserError)
|
|
749
902
|
return;
|
|
750
|
-
|
|
751
|
-
const closeWhenDrained = () => {
|
|
752
|
-
const current = activeResponses.get(socket);
|
|
753
|
-
if (current === undefined || current.size === 0)
|
|
754
|
-
socket.destroy();
|
|
755
|
-
};
|
|
756
|
-
for (const response of responses) {
|
|
757
|
-
if (response.writableEnded || response.destroyed)
|
|
758
|
-
closeWhenDrained();
|
|
759
|
-
else
|
|
760
|
-
response.once("finish", closeWhenDrained);
|
|
761
|
-
response.once("close", closeWhenDrained);
|
|
762
|
-
}
|
|
903
|
+
responseState.parserError = true;
|
|
763
904
|
return;
|
|
764
905
|
}
|
|
765
906
|
const body = "Bad Request";
|
|
@@ -768,11 +909,10 @@ export function serve(app, options) {
|
|
|
768
909
|
// WebSocket upgrades (a nifra app exposing the seam): handled on the http server's `upgrade` event via
|
|
769
910
|
// the optional `ws` package - lazy-imported (and the server lazily built) on the FIRST real WS
|
|
770
911
|
// upgrade, so a non-WS Node app never loads `ws`.
|
|
771
|
-
const resolveWs = app.resolveWebSocketUpgrade?.bind(app);
|
|
772
912
|
if (resolveWs !== undefined) {
|
|
773
913
|
let wssPromise;
|
|
774
914
|
server.on("upgrade", (nodeReq, socket, head) => {
|
|
775
|
-
void handleUpgrade(resolveWs, protocol, hostPolicy, nodeReq, socket, head, (maxPayloadBytes) => {
|
|
915
|
+
void handleUpgrade(app, answering, resolveWs, protocol, hostPolicy, nodeReq, socket, head, (maxPayloadBytes) => {
|
|
776
916
|
wssPromise ??= loadWsServer(maxPayloadBytes);
|
|
777
917
|
return wssPromise;
|
|
778
918
|
});
|
|
@@ -794,10 +934,12 @@ export function serve(app, options) {
|
|
|
794
934
|
}
|
|
795
935
|
server.close(); // stop accepting new connections; existing requests continue
|
|
796
936
|
const deadline = Date.now() + drainMs;
|
|
797
|
-
while (inFlight > 0 && Date.now() < deadline) {
|
|
937
|
+
while ((inFlight > 0 || answering.size > 0) && Date.now() < deadline) {
|
|
798
938
|
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
|
|
799
939
|
}
|
|
800
940
|
server.closeAllConnections(); // force-close stragglers + idle keep-alive sockets
|
|
941
|
+
for (const socket of answering)
|
|
942
|
+
socket.destroy();
|
|
801
943
|
}
|
|
802
944
|
return new Promise((resolve) => {
|
|
803
945
|
// Do not pass an explicit `undefined` hostname. Node accepts it, but Bun's Node-compatible
|
|
@@ -835,7 +977,7 @@ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform)
|
|
|
835
977
|
return;
|
|
836
978
|
}
|
|
837
979
|
}
|
|
838
|
-
const request = toWebRequest(nodeReq, protocol, host);
|
|
980
|
+
const request = toWebRequest(nodeReq, protocol, host, nodeRes);
|
|
839
981
|
const resolveNode = app.resolveNode;
|
|
840
982
|
if (typeof resolveNode === "function") {
|
|
841
983
|
try {
|
|
@@ -861,7 +1003,33 @@ function runNodeSource(app, source, nodeReq, nodeRes, protocol, host, platform)
|
|
|
861
1003
|
return;
|
|
862
1004
|
}
|
|
863
1005
|
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Resolve the target's `.` / `..` segments and backslashes in place, before anything reads it. Node
|
|
1008
|
+
* keeps the target as the client sent it, where Bun and workerd deliver it parsed; resolving it here
|
|
1009
|
+
* routes `/users/../admin` to `/admin` on every runtime, and every later reader - static files,
|
|
1010
|
+
* mounts, the app, `c.req.url` - sees the one path.
|
|
1011
|
+
*/
|
|
1012
|
+
const ABSOLUTE_FORM = /^[a-z][a-z\d+.-]*:\/\//i;
|
|
1013
|
+
function resolveTarget(nodeReq) {
|
|
1014
|
+
let target = nodeReq.url;
|
|
1015
|
+
if (target === undefined)
|
|
1016
|
+
return;
|
|
1017
|
+
// Absolute-form (`GET http://host/path`, RFC 9112 section 3.2.2) is routed as its origin-form path
|
|
1018
|
+
// with the Host header, as Bun does. Kept whole, hooks saw `http://hosthttp://...` while the
|
|
1019
|
+
// router matched the full URL against a `/*` route.
|
|
1020
|
+
if (target.charCodeAt(0) !== 47 && ABSOLUTE_FORM.test(target)) {
|
|
1021
|
+
let end = target.indexOf("//") + 2;
|
|
1022
|
+
while (end < target.length && target[end] !== "/" && target[end] !== "?")
|
|
1023
|
+
end++;
|
|
1024
|
+
const rest = target.slice(end);
|
|
1025
|
+
target = rest.charCodeAt(0) === 47 ? rest : `/${rest}`;
|
|
1026
|
+
nodeReq.url = target;
|
|
1027
|
+
}
|
|
1028
|
+
if (hasDotSegment(target))
|
|
1029
|
+
nodeReq.url = resolveDotSegments(target);
|
|
1030
|
+
}
|
|
864
1031
|
function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
1032
|
+
resolveTarget(nodeReq);
|
|
865
1033
|
const host = requestHost(nodeReq, hostPolicy);
|
|
866
1034
|
if (host === undefined) {
|
|
867
1035
|
writeBadRequest(nodeRes);
|
|
@@ -895,7 +1063,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
895
1063
|
if (matched !== "pass") {
|
|
896
1064
|
if ("reject" in matched)
|
|
897
1065
|
return writeResponseSafely(matched.reject, nodeRes, nodeReq.method);
|
|
898
|
-
return readStatic(matched.file, staticState, nodeReq.method ?? "GET").then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
|
|
1066
|
+
return readStatic(matched.file, staticState, nodeReq.method ?? "GET", nodeReq.headers).then((outcome) => writeOutcomeSafely(outcome, nodeRes, nodeReq.method), () => failWrite(nodeRes));
|
|
899
1067
|
}
|
|
900
1068
|
}
|
|
901
1069
|
// A mounted handler may advertise a native Node lane. Core has already checked route precedence
|
|
@@ -905,7 +1073,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
905
1073
|
const resolveNodeSource = app.resolveNodeSource;
|
|
906
1074
|
const resolveNodeMount = app[RESOLVE_NODE_MOUNT];
|
|
907
1075
|
if (typeof resolveNodeSource === "function" || typeof resolveNodeMount === "function") {
|
|
908
|
-
const nodeSource = toNodeRequestSource(nodeReq, protocol, host);
|
|
1076
|
+
const nodeSource = toNodeRequestSource(nodeReq, protocol, host, nodeRes);
|
|
909
1077
|
if (typeof resolveNodeMount === "function") {
|
|
910
1078
|
let selection;
|
|
911
1079
|
try {
|
|
@@ -942,7 +1110,7 @@ function handle(app, nodeReq, nodeRes, getProtocol, staticState, hostPolicy) {
|
|
|
942
1110
|
}
|
|
943
1111
|
return runNodeSource(app, nodeSource, nodeReq, nodeRes, protocol, host, platform);
|
|
944
1112
|
}
|
|
945
|
-
const request = toWebRequest(nodeReq, protocol, host);
|
|
1113
|
+
const request = toWebRequest(nodeReq, protocol, host, nodeRes);
|
|
946
1114
|
const resolveNode = app.resolveNode;
|
|
947
1115
|
if (typeof resolveNode === "function") {
|
|
948
1116
|
try {
|
|
@@ -1053,6 +1221,60 @@ function allHeaderKeysLowercase(record) {
|
|
|
1053
1221
|
}
|
|
1054
1222
|
return true;
|
|
1055
1223
|
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Store one wire header on a literal record. A literal `__proto__` name must not go through plain
|
|
1226
|
+
* assignment - the inherited setter would silently swallow it - so it is defined as an own data
|
|
1227
|
+
* property instead; every other name takes the fast plain store. Same guard core's portable header
|
|
1228
|
+
* view makes over the same record, so a hook-set `__proto__` header round-trips to the wire
|
|
1229
|
+
* exactly as it did through the old normalization copy.
|
|
1230
|
+
*/
|
|
1231
|
+
function storeWireHeader(headers, name, value) {
|
|
1232
|
+
if (name === "__proto__") {
|
|
1233
|
+
Object.defineProperty(headers, name, {
|
|
1234
|
+
value,
|
|
1235
|
+
writable: true,
|
|
1236
|
+
enumerable: true,
|
|
1237
|
+
configurable: true,
|
|
1238
|
+
});
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
headers[name] = value;
|
|
1242
|
+
}
|
|
1243
|
+
/** True when an existing header slot can be replaced without throwing or invoking an accessor. */
|
|
1244
|
+
function canReplaceHeaderSlot(record, name) {
|
|
1245
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, name);
|
|
1246
|
+
return descriptor === undefined || ("writable" in descriptor && descriptor.writable === true);
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* A proven lowercase record can stay on the Node hot path when the writer only has to append its
|
|
1250
|
+
* own framing headers. Non-default framing names and explicit lengths take the copy path: the old
|
|
1251
|
+
* writer moved those names to the end of the record, and retaining that order keeps raw HTTP/1.1
|
|
1252
|
+
* snapshots stable. Core's default JSON type is the one exception - it appends that value before
|
|
1253
|
+
* this writer runs, so recognizing it lets the common hooked route retain the record and append
|
|
1254
|
+
* length in place. An application that explicitly supplies the same default value is indistinguishable
|
|
1255
|
+
* here and follows that same semantics-preserving fast path.
|
|
1256
|
+
*/
|
|
1257
|
+
function canWriteJsonHeadersInPlace(source, cookies) {
|
|
1258
|
+
try {
|
|
1259
|
+
if (!Object.isExtensible(source))
|
|
1260
|
+
return false;
|
|
1261
|
+
if (Object.hasOwn(source, "content-length"))
|
|
1262
|
+
return false;
|
|
1263
|
+
if (Object.hasOwn(source, "content-type") && source["content-type"] !== JSON_CONTENT_TYPE) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
if (cookies !== undefined &&
|
|
1267
|
+
cookies.length > 0 &&
|
|
1268
|
+
!canReplaceHeaderSlot(source, "set-cookie")) {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
// A proxy or a hostile descriptor should use the same isolated fallback as mixed-case input.
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1056
1278
|
/**
|
|
1057
1279
|
* Serialize a node-direct JSON outcome straight to the socket - no undici `Response`, no stream drain.
|
|
1058
1280
|
* Mirrors `Response.json(data, { status, headers })` byte-for-byte: user headers are lowercased to
|
|
@@ -1062,31 +1284,34 @@ function allHeaderKeysLowercase(record) {
|
|
|
1062
1284
|
function writeJsonOutcome(outcome, nodeRes, isHead = false) {
|
|
1063
1285
|
// The outcome's record is the request's own (`c.set.headers`, already mutated by any native
|
|
1064
1286
|
// response hooks), and its writers - middleware twins and the framework's own additions - emit
|
|
1065
|
-
// lowercase names.
|
|
1066
|
-
// these keys, so re-walking them here is the same pass twice)
|
|
1067
|
-
// through that walk, by the scan below.
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1071
|
-
|
|
1287
|
+
// lowercase names. Whether a name needs folding is answered either by core's mark (the native
|
|
1288
|
+
// response walk already looked at these keys, so re-walking them here is the same pass twice)
|
|
1289
|
+
// or, when the record never went through that walk, by the scan below.
|
|
1290
|
+
//
|
|
1291
|
+
// A proven lowercase, extensible record is owned by this request. Keep it in place and append
|
|
1292
|
+
// lowercase framing names directly; the old uppercase rename paid two deletes and demoted the
|
|
1293
|
+
// record to dictionary mode. Mixed-case, frozen, and explicitly framed records take an isolated
|
|
1294
|
+
// copy below, preserving the old source order and protecting caller-owned objects.
|
|
1072
1295
|
const source = outcome.headers;
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1296
|
+
const lowercase = source === undefined ||
|
|
1297
|
+
source[LOWERCASE_HEADER_KEYS] === true ||
|
|
1298
|
+
allHeaderKeysLowercase(source);
|
|
1299
|
+
const inPlace = source === undefined || (lowercase && canWriteJsonHeadersInPlace(source, outcome.cookies));
|
|
1300
|
+
const headers = inPlace && source !== undefined ? source : {};
|
|
1301
|
+
let contentType;
|
|
1302
|
+
let hasLength = false;
|
|
1303
|
+
if (!inPlace && source !== undefined) {
|
|
1304
|
+
for (const key of Object.keys(source)) {
|
|
1305
|
+
const lower = lowercase ? key : key.toLowerCase();
|
|
1306
|
+
if (lower === "content-type") {
|
|
1307
|
+
contentType = source[key];
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (lower === "content-length") {
|
|
1311
|
+
hasLength = true;
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
storeWireHeader(headers, lower, source[key]);
|
|
1090
1315
|
}
|
|
1091
1316
|
}
|
|
1092
1317
|
// A `null` body is a 204/no-content render - `new Response(null)` carries no Content-Type, so we add
|
|
@@ -1095,28 +1320,27 @@ function writeJsonOutcome(outcome, nodeRes, isHead = false) {
|
|
|
1095
1320
|
// without it Node falls back to chunked framing, which costs extra wire bytes and client parsing
|
|
1096
1321
|
// on every response (and no other runtime chunks a buffered JSON body).
|
|
1097
1322
|
if (outcome.body !== null) {
|
|
1098
|
-
if (
|
|
1099
|
-
headers
|
|
1323
|
+
if (inPlace) {
|
|
1324
|
+
if (!Object.hasOwn(headers, "content-type"))
|
|
1325
|
+
headers["content-type"] = JSON_CONTENT_TYPE;
|
|
1326
|
+
headers["content-length"] = String(Buffer.byteLength(outcome.body));
|
|
1327
|
+
}
|
|
1100
1328
|
else {
|
|
1101
|
-
headers
|
|
1102
|
-
|
|
1329
|
+
storeWireHeader(headers, "content-type", contentType ?? JSON_CONTENT_TYPE);
|
|
1330
|
+
storeWireHeader(headers, "content-length", String(Buffer.byteLength(outcome.body)));
|
|
1103
1331
|
}
|
|
1104
|
-
headers["Content-Length"] = String(Buffer.byteLength(outcome.body));
|
|
1105
|
-
delete headers["content-length"];
|
|
1106
1332
|
}
|
|
1107
1333
|
else if (isBodylessStatus(outcome.status)) {
|
|
1108
|
-
// 204/205/304 never carry a payload;
|
|
1109
|
-
//
|
|
1110
|
-
delete headers["content-length"];
|
|
1111
|
-
delete headers["Content-Length"];
|
|
1334
|
+
// 204/205/304 never carry a payload; a user/native-hook length is dropped rather than copied,
|
|
1335
|
+
// so the direct writer cannot advertise bytes that will not ship.
|
|
1112
1336
|
}
|
|
1113
|
-
else if (!isHead &&
|
|
1337
|
+
else if (!isHead && !hasLength) {
|
|
1114
1338
|
// A body-less render at a status that MAY carry a body - a `redirect()`, above all. Node frames
|
|
1115
1339
|
// a `writeHead` + bare `end()` as chunked, so the shortest response the framework emits went out
|
|
1116
1340
|
// with a chunk terminator and no length, where every Web-native runtime sends `content-length: 0`.
|
|
1117
1341
|
// Declared here so the wire matches them. HEAD is excluded: its length describes the GET's body,
|
|
1118
1342
|
// which this lane does not know.
|
|
1119
|
-
headers["
|
|
1343
|
+
headers["content-length"] = "0";
|
|
1120
1344
|
}
|
|
1121
1345
|
if (outcome.cookies !== undefined && outcome.cookies.length > 0) {
|
|
1122
1346
|
headers["set-cookie"] = [...outcome.cookies];
|
|
@@ -1184,16 +1408,16 @@ function normalizeProtocol(value) {
|
|
|
1184
1408
|
return value;
|
|
1185
1409
|
throw new Error(`@nifrajs/node: protocol must be "http" or "https" (got ${JSON.stringify(value)})`);
|
|
1186
1410
|
}
|
|
1187
|
-
function toWebRequest(req, protocol, host) {
|
|
1411
|
+
function toWebRequest(req, protocol, host, res) {
|
|
1188
1412
|
const url = `${protocol}://${host}${req.url ?? "/"}`;
|
|
1189
1413
|
const method = req.method ?? "GET";
|
|
1190
|
-
return makeWebRequest(req, method, url, headerRecordFromNode(req.headers));
|
|
1414
|
+
return makeWebRequest(req, method, url, headerRecordFromNode(req.headers), undefined, res);
|
|
1191
1415
|
}
|
|
1192
|
-
function toNodeRequestSource(req, protocol, host) {
|
|
1416
|
+
function toNodeRequestSource(req, protocol, host, res) {
|
|
1193
1417
|
const method = req.method ?? "GET";
|
|
1194
1418
|
return method === "GET" || method === "HEAD"
|
|
1195
|
-
? new LeanNodeGetSource(req, method, protocol, host)
|
|
1196
|
-
: new LazyNodeRequestSource(req, method, protocol, host);
|
|
1419
|
+
? new LeanNodeGetSource(req, method, protocol, host, res)
|
|
1420
|
+
: new LazyNodeRequestSource(req, method, protocol, host, res);
|
|
1197
1421
|
}
|
|
1198
1422
|
function stripNodeMountPrefix(url, prefix) {
|
|
1199
1423
|
if (prefix === "/")
|
|
@@ -1269,11 +1493,13 @@ class LazyNodeRequestSource {
|
|
|
1269
1493
|
nodeReq;
|
|
1270
1494
|
protocol;
|
|
1271
1495
|
host;
|
|
1272
|
-
|
|
1496
|
+
nodeRes;
|
|
1497
|
+
constructor(nodeReq, method, protocol, host, nodeRes) {
|
|
1273
1498
|
this.nodeReq = nodeReq;
|
|
1274
1499
|
this.method = method;
|
|
1275
1500
|
this.protocol = protocol;
|
|
1276
1501
|
this.host = host;
|
|
1502
|
+
this.nodeRes = nodeRes;
|
|
1277
1503
|
}
|
|
1278
1504
|
/** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
|
|
1279
1505
|
get url() {
|
|
@@ -1353,12 +1579,24 @@ class LazyNodeRequestSource {
|
|
|
1353
1579
|
rawBodyBytes() {
|
|
1354
1580
|
if (this.consumedBody !== undefined)
|
|
1355
1581
|
return Promise.resolve(this.consumedBody);
|
|
1356
|
-
const handed = this.
|
|
1582
|
+
const handed = this.handedBody();
|
|
1357
1583
|
if (handed != null) {
|
|
1358
1584
|
return new Response(handed).arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
1359
1585
|
}
|
|
1360
1586
|
return this.readNodeBody();
|
|
1361
1587
|
}
|
|
1588
|
+
/**
|
|
1589
|
+
* The stream the Web `Request` was built on, or - once a hook's `req.clone()` teed it (which locks
|
|
1590
|
+
* the original) - the branch that request kept. Reading the locked original threw, so a middleware
|
|
1591
|
+
* that peeked at the body through a clone turned every downstream body read into a 500.
|
|
1592
|
+
*/
|
|
1593
|
+
handedBody() {
|
|
1594
|
+
const handed = this.bodyValue;
|
|
1595
|
+
if (handed == null || !handed.locked)
|
|
1596
|
+
return handed ?? undefined;
|
|
1597
|
+
const owner = this.requestValue;
|
|
1598
|
+
return owner?._real?.body ?? handed;
|
|
1599
|
+
}
|
|
1362
1600
|
/**
|
|
1363
1601
|
* The raw body stream, never routed back through `this.request` - post-cap that getter is the
|
|
1364
1602
|
* shadowed one, which reads back through these very readers. An already-consumed body replays
|
|
@@ -1371,18 +1609,18 @@ class LazyNodeRequestSource {
|
|
|
1371
1609
|
if (consumed !== undefined)
|
|
1372
1610
|
return streamOfBytes(consumed);
|
|
1373
1611
|
this.bodyValue ??= claimableWebStream(this.nodeReq, "drain");
|
|
1374
|
-
return this.bodyValue;
|
|
1612
|
+
return this.handedBody() ?? this.bodyValue;
|
|
1375
1613
|
}
|
|
1376
1614
|
get request() {
|
|
1377
1615
|
this.requestValue ??= new LazyWebRequest(this.method, this.url, () => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers), (headers) => {
|
|
1378
1616
|
if (this.consumedBody !== undefined) {
|
|
1379
|
-
const real = makeWebRequest(this.nodeReq, this.method, this.url, headers, this.consumedBody);
|
|
1617
|
+
const real = makeWebRequest(this.nodeReq, this.method, this.url, headers, this.consumedBody, this.nodeRes);
|
|
1380
1618
|
// Preserve one-shot body semantics if user code asks for `c.req` after nifra already
|
|
1381
1619
|
// consumed it.
|
|
1382
1620
|
void real.arrayBuffer().catch(() => { });
|
|
1383
1621
|
return real;
|
|
1384
1622
|
}
|
|
1385
|
-
return makeWebRequest(this.nodeReq, this.method, this.url, headers, this.body);
|
|
1623
|
+
return makeWebRequest(this.nodeReq, this.method, this.url, headers, this.body, this.nodeRes);
|
|
1386
1624
|
});
|
|
1387
1625
|
return this.requestValue;
|
|
1388
1626
|
}
|
|
@@ -1452,11 +1690,13 @@ class LeanNodeGetSource {
|
|
|
1452
1690
|
nodeReq;
|
|
1453
1691
|
protocol;
|
|
1454
1692
|
host;
|
|
1455
|
-
|
|
1693
|
+
nodeRes;
|
|
1694
|
+
constructor(nodeReq, method, protocol, host, nodeRes) {
|
|
1456
1695
|
this.nodeReq = nodeReq;
|
|
1457
1696
|
this.method = method;
|
|
1458
1697
|
this.protocol = protocol;
|
|
1459
1698
|
this.host = host;
|
|
1699
|
+
this.nodeRes = nodeRes;
|
|
1460
1700
|
}
|
|
1461
1701
|
/** The absolute URL, built only when something reads it - routing uses `urlParts` instead. */
|
|
1462
1702
|
get url() {
|
|
@@ -1486,7 +1726,7 @@ class LeanNodeGetSource {
|
|
|
1486
1726
|
return Promise.reject(new SyntaxError("Unexpected end of JSON input"));
|
|
1487
1727
|
}
|
|
1488
1728
|
get request() {
|
|
1489
|
-
this.requestValue ??= new LazyWebRequest(this.method, this.url, () => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers), (headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null));
|
|
1729
|
+
this.requestValue ??= new LazyWebRequest(this.method, this.url, () => this.headersValue ?? headerRecordFromNode(this.nodeReq.headers), (headers) => makeWebRequest(this.nodeReq, this.method, this.url, headers, null, this.nodeRes));
|
|
1490
1730
|
return this.requestValue;
|
|
1491
1731
|
}
|
|
1492
1732
|
}
|
|
@@ -1509,11 +1749,25 @@ function headerRecordFromNode(input) {
|
|
|
1509
1749
|
}
|
|
1510
1750
|
return record;
|
|
1511
1751
|
}
|
|
1512
|
-
function makeWebRequest(req, method, url, headers, body) {
|
|
1752
|
+
function makeWebRequest(req, method, url, headers, body, res) {
|
|
1513
1753
|
const init = { method, headers };
|
|
1754
|
+
if (res !== undefined) {
|
|
1755
|
+
// `signal` aborts when the client goes away before the response finished, as on Bun and Deno.
|
|
1756
|
+
const disconnect = new AbortController();
|
|
1757
|
+
init.signal = disconnect.signal;
|
|
1758
|
+
const onClose = () => {
|
|
1759
|
+
if (!res.writableFinished)
|
|
1760
|
+
disconnect.abort();
|
|
1761
|
+
};
|
|
1762
|
+
if (res.closed)
|
|
1763
|
+
onClose();
|
|
1764
|
+
else
|
|
1765
|
+
res.once("close", onClose);
|
|
1766
|
+
}
|
|
1514
1767
|
if (method !== "GET" && method !== "HEAD") {
|
|
1515
1768
|
// Stream the body in; `duplex: "half"` is required for a streamed request body.
|
|
1516
|
-
init.body =
|
|
1769
|
+
init.body =
|
|
1770
|
+
body instanceof Uint8Array ? body.slice() : (body ?? claimableWebStream(req, "drain"));
|
|
1517
1771
|
init.duplex = "half";
|
|
1518
1772
|
}
|
|
1519
1773
|
return new Request(url, init);
|
|
@@ -1653,6 +1907,9 @@ function writeNodeResponse(response, nodeRes, method) {
|
|
|
1653
1907
|
if (isHead) {
|
|
1654
1908
|
nodeRes.writeHead(response.status);
|
|
1655
1909
|
nodeRes.end();
|
|
1910
|
+
// Nothing reads a HEAD answer's body. Cancelled, its source learns the exchange is over, as on
|
|
1911
|
+
// Bun and Deno; left alone, a streaming route's producer runs on with no reader.
|
|
1912
|
+
response.body?.cancel().catch(() => { });
|
|
1656
1913
|
return;
|
|
1657
1914
|
}
|
|
1658
1915
|
const directBody = nodeResponseBody(response);
|
|
@@ -1749,6 +2006,22 @@ function pipeNodeResponseBody(body, nodeRes) {
|
|
|
1749
2006
|
*/
|
|
1750
2007
|
async function writeNodeResponseBody(response, nodeRes, canDeclareLength) {
|
|
1751
2008
|
const reader = response.body.getReader();
|
|
2009
|
+
// A client that leaves while the stream is idle never fails a write. Its departure cancels the
|
|
2010
|
+
// stream, as on Bun and Deno, so a producer waiting to send learns the exchange is over.
|
|
2011
|
+
const onClose = () => {
|
|
2012
|
+
reader.cancel().catch(() => { });
|
|
2013
|
+
};
|
|
2014
|
+
nodeRes.once("close", onClose);
|
|
2015
|
+
if (nodeRes.destroyed)
|
|
2016
|
+
onClose();
|
|
2017
|
+
try {
|
|
2018
|
+
await writeNodeResponseChunks(reader, response, nodeRes, canDeclareLength);
|
|
2019
|
+
}
|
|
2020
|
+
finally {
|
|
2021
|
+
nodeRes.removeListener("close", onClose);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function writeNodeResponseChunks(reader, response, nodeRes, canDeclareLength) {
|
|
1752
2025
|
let first;
|
|
1753
2026
|
try {
|
|
1754
2027
|
first = await reader.read();
|
|
@@ -1871,8 +2144,13 @@ const WS_STATUS_TEXT = {
|
|
|
1871
2144
|
};
|
|
1872
2145
|
/** Resolve a Node `upgrade` event: run the nifra upgrade guard, then either reject (write an HTTP error
|
|
1873
2146
|
* to the raw socket) or perform the `ws` upgrade and wire the socket to the handler. */
|
|
1874
|
-
async function handleUpgrade(resolveWs, getProtocol, hostPolicy, nodeReq, socket, head, getWss) {
|
|
2147
|
+
async function handleUpgrade(app, answering, resolveWs, getProtocol, hostPolicy, nodeReq, socket, head, getWss) {
|
|
2148
|
+
// A peer can reset the socket while the guard is awaited; with no listener that error ends the process.
|
|
2149
|
+
socket.on("error", ignoreSocketError);
|
|
2150
|
+
resolveTarget(nodeReq);
|
|
1875
2151
|
let outcome;
|
|
2152
|
+
let request;
|
|
2153
|
+
let platform;
|
|
1876
2154
|
try {
|
|
1877
2155
|
const host = requestHost(nodeReq, hostPolicy);
|
|
1878
2156
|
if (host === undefined) {
|
|
@@ -1880,15 +2158,38 @@ async function handleUpgrade(resolveWs, getProtocol, hostPolicy, nodeReq, socket
|
|
|
1880
2158
|
return;
|
|
1881
2159
|
}
|
|
1882
2160
|
const peerAddress = nodeReq.socket.remoteAddress;
|
|
1883
|
-
|
|
1884
|
-
|
|
2161
|
+
platform = peerAddress === undefined ? undefined : { clientIp: peerAddress };
|
|
2162
|
+
// Its signal follows the socket, so a route answered below stops when the client leaves.
|
|
2163
|
+
request = toWebRequest(nodeReq, getProtocol(nodeReq), host, socket);
|
|
2164
|
+
outcome = await resolveWs(request, platform);
|
|
1885
2165
|
}
|
|
1886
2166
|
catch {
|
|
1887
2167
|
writeUpgradeRejection(socket, 500, "internal_error");
|
|
1888
2168
|
return;
|
|
1889
2169
|
}
|
|
1890
2170
|
if (outcome.kind === "pass") {
|
|
1891
|
-
|
|
2171
|
+
// No WS route here: the HTTP route answers, as on Bun. Only a bodiless request can be served from
|
|
2172
|
+
// an upgrade socket - Node parses no body for one, so a POST here (Node without
|
|
2173
|
+
// `shouldUpgradeCallback`) keeps the 404.
|
|
2174
|
+
if (nodeReq.method !== "GET" && nodeReq.method !== "HEAD") {
|
|
2175
|
+
writeUpgradeRejection(socket, 404, "not_found");
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
let response;
|
|
2179
|
+
try {
|
|
2180
|
+
response = await app.fetch(request, platform);
|
|
2181
|
+
}
|
|
2182
|
+
catch {
|
|
2183
|
+
writeUpgradeRejection(socket, 500, "internal_error");
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
answering.add(socket);
|
|
2187
|
+
socket.once("close", () => answering.delete(socket));
|
|
2188
|
+
// Detached from Node's HTTP handling, the socket comes paused and nothing ends it on the client's
|
|
2189
|
+
// FIN. Reading it and ending on `end`, as Node's server does, lets a departure reach the signal.
|
|
2190
|
+
socket.once("end", () => socket.end());
|
|
2191
|
+
socket.resume();
|
|
2192
|
+
await writeSocketResponse(socket, response, nodeReq.method);
|
|
1892
2193
|
return;
|
|
1893
2194
|
}
|
|
1894
2195
|
if (outcome.kind === "reject") {
|
|
@@ -1953,6 +2254,11 @@ function attachNodeWebSocket(ws, handler, data, pubsub) {
|
|
|
1953
2254
|
ws.on("error", (error) => reportError(error));
|
|
1954
2255
|
safe(() => handler.open?.(nifra)); // open: the socket is already established here
|
|
1955
2256
|
}
|
|
2257
|
+
function ignoreSocketError() { }
|
|
2258
|
+
function isWebSocketUpgrade(nodeReq) {
|
|
2259
|
+
// A handshake is a GET (RFC 6455 section 4.1); any other method is an ordinary request with a body.
|
|
2260
|
+
return nodeReq.method === "GET" && /\bwebsocket\b/i.test(nodeReq.headers.upgrade ?? "");
|
|
2261
|
+
}
|
|
1956
2262
|
/** Write a minimal JSON error response to a raw upgrade socket, then close it. */
|
|
1957
2263
|
function writeUpgradeRejection(socket, status, error) {
|
|
1958
2264
|
const body = JSON.stringify({ ok: false, error });
|
|
@@ -1974,4 +2280,37 @@ async function writeRejectionResponse(socket, response) {
|
|
|
1974
2280
|
socket.write(head + body);
|
|
1975
2281
|
socket.destroy();
|
|
1976
2282
|
}
|
|
2283
|
+
/** Answer an upgrade socket with an ordinary response: the body streams as produced, paced by the
|
|
2284
|
+
* socket's backpressure and delimited by the close (RFC 9112 section 6.3), never buffered whole. */
|
|
2285
|
+
async function writeSocketResponse(socket, response, method) {
|
|
2286
|
+
let head = `HTTP/1.1 ${response.status} ${response.statusText || STATUS_CODES[response.status] || "Unknown"}\r\n`;
|
|
2287
|
+
response.headers.forEach((value, key) => {
|
|
2288
|
+
if (key !== "connection" && key !== "keep-alive" && key !== "transfer-encoding") {
|
|
2289
|
+
head += `${key}: ${value}\r\n`;
|
|
2290
|
+
}
|
|
2291
|
+
});
|
|
2292
|
+
socket.write(`${head}Connection: close\r\n\r\n`);
|
|
2293
|
+
if (response.body === null || method === "HEAD") {
|
|
2294
|
+
await response.body?.cancel().catch(() => { });
|
|
2295
|
+
socket.end();
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
const reader = response.body.getReader();
|
|
2299
|
+
try {
|
|
2300
|
+
for (;;) {
|
|
2301
|
+
const chunk = await reader.read();
|
|
2302
|
+
if (chunk.done)
|
|
2303
|
+
break;
|
|
2304
|
+
if (!socket.write(chunk.value) && !(await waitForDrain(socket))) {
|
|
2305
|
+
await reader.cancel();
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
socket.end();
|
|
2310
|
+
}
|
|
2311
|
+
catch {
|
|
2312
|
+
socket.destroy();
|
|
2313
|
+
await reader.cancel().catch(() => { });
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
1977
2316
|
//# sourceMappingURL=index.js.map
|