@jsenv/server 17.6.1 → 17.6.2
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 +47 -48
- package/dist/jsenv_server.js +1332 -1269
- package/package.json +2 -2
- package/src/content_negotiation/pick_content_encoding.js +11 -1
- package/src/content_negotiation/pick_content_language.js +11 -1
- package/src/content_negotiation/pick_content_type.js +11 -1
- package/src/content_negotiation/pick_content_version.js +1 -1
- package/src/interfacing_with_node/from_node_request.js +154 -178
- package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
- package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
- package/src/interfacing_with_node/write_node_response.js +59 -103
- package/src/internal/allowed_hosts.js +85 -0
- package/src/internal/escape_html.js +11 -0
- package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
- package/src/internal/listen.js +32 -5
- package/src/internal/{listenRequest.js → listen_request.js} +1 -1
- package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
- package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
- package/src/internal/reason_phrase.js +17 -0
- package/src/internal/response_composition.js +11 -2
- package/src/internal/secure_server.js +22 -0
- package/src/internal/server_origins.js +78 -0
- package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
- package/src/internal/track_server_pending_connections.js +50 -0
- package/src/internal/track_server_pending_requests.js +47 -0
- package/src/plugins/cors/server_plugin_cors.js +39 -6
- package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
- package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
- package/src/plugins/filesystem/fetch_directory.js +16 -3
- package/src/plugins/filesystem/fetch_file.js +80 -21
- package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
- package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
- package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
- package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
- package/src/plugins/server_plugins_controller.js +27 -13
- package/src/progressive_response.js +24 -0
- package/src/router/permissions.js +52 -0
- package/src/router/router.js +14 -79
- package/src/server_events.js +3 -1
- package/src/server_timing/timing_header.js +1 -1
- package/src/start_server.js +266 -416
- package/src/internal/headersFromString.js +0 -21
- package/src/internal/headersToObject.js +0 -7
- package/src/internal/headersToString.js +0 -16
- package/src/internal/listenClientError.js +0 -27
- package/src/internal/trackServerPendingConnections.js +0 -145
- package/src/internal/trackServerPendingRequests.js +0 -68
- package/src/request_body_handling.js +0 -22
- /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
- /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
- /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
- /package/src/internal/{normalizeHeaderValue.js → normalize_header_value.js} +0 -0
|
@@ -1,70 +1,34 @@
|
|
|
1
1
|
// https://github.com/jamestalmage/stream-to-observable/blob/master/index.js
|
|
2
|
-
import { Readable } from "node:stream";
|
|
3
2
|
import { createObservable } from "./observable.js";
|
|
4
3
|
|
|
5
|
-
export const observableFromNodeStream = (
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
nodeStream.
|
|
25
|
-
nodeStream.
|
|
26
|
-
nodeStream.
|
|
27
|
-
|
|
28
|
-
nodeStream.removeListener("error", errorEventCallback);
|
|
29
|
-
nodeStream.removeListener("data", dataEventCallback);
|
|
30
|
-
nodeStream.removeListener("end", endEventCallback);
|
|
31
|
-
nodeStream.removeListener("close", closeEventCallback); // not sure it's required
|
|
32
|
-
});
|
|
33
|
-
if (nodeStream.isPaused()) {
|
|
34
|
-
nodeStream.resume();
|
|
35
|
-
} else if (nodeStream.complete) {
|
|
36
|
-
complete();
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
if (readableLifetime && nodeStream instanceof Readable) {
|
|
42
|
-
const timeout = setTimeout(() => {
|
|
43
|
-
// disabled for now
|
|
44
|
-
// process.emitWarning(
|
|
45
|
-
// `Readable stream not used after ${readableLifetime / 1000} seconds.`,
|
|
46
|
-
// {
|
|
47
|
-
// CODE: "READABLE_STREAM_TIMEOUT",
|
|
48
|
-
// // url is for http client request
|
|
49
|
-
// detail: `path: ${nodeStream.path}, fd: ${nodeStream.fd}, url: ${nodeStream.url}`,
|
|
50
|
-
// },
|
|
51
|
-
// );
|
|
52
|
-
}, readableLifetime).unref();
|
|
53
|
-
onceReadableStreamUsedOrClosed(nodeStream, () => {
|
|
54
|
-
clearTimeout(timeout);
|
|
4
|
+
export const observableFromNodeStream = (nodeStream) => {
|
|
5
|
+
return createObservable(({ next, error, complete, addTeardown }) => {
|
|
6
|
+
const errorEventCallback = (e) => {
|
|
7
|
+
error(e);
|
|
8
|
+
};
|
|
9
|
+
const dataEventCallback = (data) => {
|
|
10
|
+
next(data);
|
|
11
|
+
};
|
|
12
|
+
const closeEventCallback = () => {
|
|
13
|
+
complete();
|
|
14
|
+
};
|
|
15
|
+
const endEventCallback = () => {
|
|
16
|
+
complete();
|
|
17
|
+
};
|
|
18
|
+
nodeStream.once("error", errorEventCallback);
|
|
19
|
+
nodeStream.on("data", dataEventCallback);
|
|
20
|
+
nodeStream.once("end", endEventCallback);
|
|
21
|
+
nodeStream.once("close", closeEventCallback); // not sure it's required
|
|
22
|
+
addTeardown(() => {
|
|
23
|
+
nodeStream.removeListener("error", errorEventCallback);
|
|
24
|
+
nodeStream.removeListener("data", dataEventCallback);
|
|
25
|
+
nodeStream.removeListener("end", endEventCallback);
|
|
26
|
+
nodeStream.removeListener("close", closeEventCallback); // not sure it's required
|
|
55
27
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const onceReadableStreamUsedOrClosed = (readableStream, callback) => {
|
|
63
|
-
const dataOrCloseCallback = () => {
|
|
64
|
-
readableStream.removeListener("data", dataOrCloseCallback);
|
|
65
|
-
readableStream.removeListener("close", dataOrCloseCallback);
|
|
66
|
-
callback();
|
|
67
|
-
};
|
|
68
|
-
readableStream.on("data", dataOrCloseCallback);
|
|
69
|
-
readableStream.once("close", dataOrCloseCallback);
|
|
28
|
+
if (nodeStream.isPaused()) {
|
|
29
|
+
nodeStream.resume();
|
|
30
|
+
} else if (nodeStream.complete) {
|
|
31
|
+
complete();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
70
34
|
};
|
|
@@ -2,27 +2,15 @@ import { raceCallbacks } from "@jsenv/abort";
|
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import { Http2ServerResponse } from "node:http2";
|
|
4
4
|
import { Socket } from "node:net";
|
|
5
|
+
import { asReasonPhrase } from "../internal/reason_phrase.js";
|
|
5
6
|
import { getObservableValueType } from "./get_observable_value_type.js";
|
|
6
7
|
import { observableFromValue } from "./observable_from.js";
|
|
7
8
|
|
|
8
9
|
export const writeNodeResponse = async (
|
|
9
10
|
responseStream,
|
|
10
|
-
{ status, statusText, headers, body
|
|
11
|
+
{ status, statusText, headers, body },
|
|
11
12
|
{ signal, ignoreBody, onAbort, onError, onHeadersSent, onEnd } = {},
|
|
12
13
|
) => {
|
|
13
|
-
const isNetSocket = responseStream instanceof Socket;
|
|
14
|
-
if (
|
|
15
|
-
body &&
|
|
16
|
-
body.isObservableBody &&
|
|
17
|
-
headers["connection"] === undefined &&
|
|
18
|
-
headers["content-length"] === undefined
|
|
19
|
-
) {
|
|
20
|
-
headers["transfer-encoding"] = "chunked";
|
|
21
|
-
}
|
|
22
|
-
// if (body && headers["content-length"] === undefined) {
|
|
23
|
-
// headers["transfer-encoding"] = "chunked";
|
|
24
|
-
// }
|
|
25
|
-
|
|
26
14
|
const bodyObservableType = getObservableValueType(body);
|
|
27
15
|
const destroyBody = () => {
|
|
28
16
|
if (bodyObservableType === "file_handle") {
|
|
@@ -66,46 +54,8 @@ export const writeNodeResponse = async (
|
|
|
66
54
|
return;
|
|
67
55
|
}
|
|
68
56
|
|
|
69
|
-
if (bodyEncoding && !isNetSocket) {
|
|
70
|
-
responseStream.setEncoding(bodyEncoding);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
57
|
await new Promise((resolve) => {
|
|
74
|
-
const observable = observableFromValue(body);
|
|
75
58
|
const abortController = new AbortController();
|
|
76
|
-
signal.addEventListener("abort", () => {
|
|
77
|
-
abortController.abort();
|
|
78
|
-
});
|
|
79
|
-
observable.subscribe(
|
|
80
|
-
{
|
|
81
|
-
next: (data) => {
|
|
82
|
-
try {
|
|
83
|
-
responseStream.write(data);
|
|
84
|
-
} catch (e) {
|
|
85
|
-
// Something inside Node.js sometimes puts stream
|
|
86
|
-
// in a state where .write() throw despites nodeResponse.destroyed
|
|
87
|
-
// being undefined and "close" event not being emitted.
|
|
88
|
-
// I have tested if we are the one calling destroy
|
|
89
|
-
// (I have commented every .destroy() call)
|
|
90
|
-
// but issue still occurs
|
|
91
|
-
// For the record it's "hard" to reproduce but can be by running
|
|
92
|
-
// a lot of tests against a browser in the context of @jsenv/core testing
|
|
93
|
-
if (e.code === "ERR_HTTP2_INVALID_STREAM") {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
responseStream.emit("error", e);
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
error: (value) => {
|
|
100
|
-
responseStream.emit("error", value);
|
|
101
|
-
},
|
|
102
|
-
complete: () => {
|
|
103
|
-
responseStream.end();
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
{ signal: abortController.signal },
|
|
107
|
-
);
|
|
108
|
-
|
|
109
59
|
raceCallbacks(
|
|
110
60
|
{
|
|
111
61
|
abort: (cb) => {
|
|
@@ -166,6 +116,46 @@ export const writeNodeResponse = async (
|
|
|
166
116
|
raceEffects[winner.name](winner.data);
|
|
167
117
|
},
|
|
168
118
|
);
|
|
119
|
+
|
|
120
|
+
// a string or a buffer is written and ended in one go
|
|
121
|
+
if (typeof body === "string" || body instanceof Uint8Array) {
|
|
122
|
+
responseStream.end(body);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const observable = observableFromValue(body);
|
|
127
|
+
signal.addEventListener("abort", () => {
|
|
128
|
+
abortController.abort();
|
|
129
|
+
});
|
|
130
|
+
observable.subscribe(
|
|
131
|
+
{
|
|
132
|
+
next: (data) => {
|
|
133
|
+
try {
|
|
134
|
+
responseStream.write(data);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// Something inside Node.js sometimes puts stream
|
|
137
|
+
// in a state where .write() throw despites nodeResponse.destroyed
|
|
138
|
+
// being undefined and "close" event not being emitted.
|
|
139
|
+
// I have tested if we are the one calling destroy
|
|
140
|
+
// (I have commented every .destroy() call)
|
|
141
|
+
// but issue still occurs
|
|
142
|
+
// For the record it's "hard" to reproduce but can be by running
|
|
143
|
+
// a lot of tests against a browser in the context of @jsenv/core testing
|
|
144
|
+
if (e.code === "ERR_HTTP2_INVALID_STREAM") {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
responseStream.emit("error", e);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
error: (value) => {
|
|
151
|
+
responseStream.emit("error", value);
|
|
152
|
+
},
|
|
153
|
+
complete: () => {
|
|
154
|
+
responseStream.end();
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{ signal: abortController.signal },
|
|
158
|
+
);
|
|
169
159
|
});
|
|
170
160
|
};
|
|
171
161
|
|
|
@@ -176,38 +166,23 @@ const writeHead = (
|
|
|
176
166
|
const responseIsNetSocket = responseStream instanceof Socket;
|
|
177
167
|
const responseIsHttp2ServerResponse =
|
|
178
168
|
responseStream instanceof Http2ServerResponse;
|
|
179
|
-
const
|
|
180
|
-
responseStream.constructor.name === "ServerHttp2Stream";
|
|
181
|
-
let nodeHeaders = headersToNodeHeaders(headers, {
|
|
169
|
+
const nodeHeaders = headersToNodeHeaders(headers, {
|
|
182
170
|
// https://github.com/nodejs/node/blob/79296dc2d02c0b9872bbfcbb89148ea036a546d0/lib/internal/http2/compat.js#L112
|
|
183
|
-
ignoreConnectionHeader:
|
|
184
|
-
responseIsHttp2ServerResponse || responseIsServerHttp2Stream,
|
|
171
|
+
ignoreConnectionHeader: responseIsHttp2ServerResponse,
|
|
185
172
|
});
|
|
186
|
-
|
|
187
|
-
statusText
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
nodeHeaders = {
|
|
193
|
-
...nodeHeaders,
|
|
194
|
-
":status": status,
|
|
195
|
-
};
|
|
196
|
-
responseStream.respond(nodeHeaders);
|
|
197
|
-
onHeadersSent({ nodeHeaders, status, statusText });
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
// nodejs strange signature for writeHead force this
|
|
201
|
-
// https://nodejs.org/api/http.html#http_response_writehead_statuscode_statusmessage_headers
|
|
202
|
-
if (
|
|
203
|
-
// https://github.com/nodejs/node/blob/79296dc2d02c0b9872bbfcbb89148ea036a546d0/lib/internal/http2/compat.js#L97
|
|
204
|
-
responseIsHttp2ServerResponse
|
|
205
|
-
) {
|
|
173
|
+
statusText =
|
|
174
|
+
statusText === undefined
|
|
175
|
+
? statusTextFromStatus(status)
|
|
176
|
+
: asReasonPhrase(statusText);
|
|
177
|
+
if (responseIsHttp2ServerResponse) {
|
|
178
|
+
// http2 has no reason phrase: statusText only reaches the logs
|
|
206
179
|
responseStream.writeHead(status, nodeHeaders);
|
|
207
180
|
onHeadersSent({ nodeHeaders, status, statusText });
|
|
208
181
|
return;
|
|
209
182
|
}
|
|
210
183
|
if (responseIsNetSocket) {
|
|
184
|
+
// a websocket upgrade request answered with something else than 101:
|
|
185
|
+
// the response is written by hand on the socket
|
|
211
186
|
const headersString = Object.keys(nodeHeaders)
|
|
212
187
|
.map((h) => `${h}: ${nodeHeaders[h]}`)
|
|
213
188
|
.join("\r\n");
|
|
@@ -217,20 +192,7 @@ const writeHead = (
|
|
|
217
192
|
onHeadersSent({ nodeHeaders, status, statusText });
|
|
218
193
|
return;
|
|
219
194
|
}
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
responseStream.writeHead(status, statusText, nodeHeaders);
|
|
223
|
-
} catch (e) {
|
|
224
|
-
if (
|
|
225
|
-
e.code === "ERR_INVALID_CHAR" &&
|
|
226
|
-
e.message.includes("Invalid character in statusMessage")
|
|
227
|
-
) {
|
|
228
|
-
throw new Error(`Invalid character in statusMessage
|
|
229
|
-
--- status message ---
|
|
230
|
-
${statusText}`);
|
|
231
|
-
}
|
|
232
|
-
throw e;
|
|
233
|
-
}
|
|
195
|
+
responseStream.writeHead(status, statusText, nodeHeaders);
|
|
234
196
|
onHeadersSent({ nodeHeaders, status, statusText });
|
|
235
197
|
};
|
|
236
198
|
|
|
@@ -239,17 +201,11 @@ const statusTextFromStatus = (status) =>
|
|
|
239
201
|
|
|
240
202
|
const headersToNodeHeaders = (headers, { ignoreConnectionHeader }) => {
|
|
241
203
|
const nodeHeaders = {};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
nodeHeaders[
|
|
247
|
-
}
|
|
248
|
-
|
|
204
|
+
for (const name of Object.keys(headers)) {
|
|
205
|
+
if (name === "connection" && ignoreConnectionHeader) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
nodeHeaders[name] = headers[name];
|
|
209
|
+
}
|
|
249
210
|
return nodeHeaders;
|
|
250
211
|
};
|
|
251
|
-
|
|
252
|
-
const mapping = {
|
|
253
|
-
// "content-type": "Content-Type",
|
|
254
|
-
// "last-modified": "Last-Modified",
|
|
255
|
-
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { hostname as osHostname, networkInterfaces } from "node:os";
|
|
2
|
+
|
|
3
|
+
// The Host header of a request is compared to the names the server can
|
|
4
|
+
// legitimately be reached at. Without this a page served by another site
|
|
5
|
+
// reads the responses once its DNS name is rebound to this machine (DNS
|
|
6
|
+
// rebinding): the request then comes from the developer's own browser, so
|
|
7
|
+
// listening on localhost does not help, only the Host name tells.
|
|
8
|
+
export const createHostChecker = ({
|
|
9
|
+
allowedHosts,
|
|
10
|
+
hostname,
|
|
11
|
+
serverOrigins,
|
|
12
|
+
acceptAnyIp,
|
|
13
|
+
}) => {
|
|
14
|
+
if (allowedHosts === true) {
|
|
15
|
+
return () => true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const allowedHostSet = new Set();
|
|
19
|
+
const allowedHostSuffixes = [];
|
|
20
|
+
const allow = (host) => {
|
|
21
|
+
if (host.startsWith(".")) {
|
|
22
|
+
// ".example.com" allows example.com and every subdomain
|
|
23
|
+
allowedHostSuffixes.push(host.toLowerCase());
|
|
24
|
+
allowedHostSet.add(host.slice(1).toLowerCase());
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const hostNormalized = normalizeHost(host);
|
|
28
|
+
if (hostNormalized) {
|
|
29
|
+
allowedHostSet.add(hostNormalized);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
allow("localhost");
|
|
34
|
+
allow("127.0.0.1");
|
|
35
|
+
allow("[::1]");
|
|
36
|
+
allow(hostname);
|
|
37
|
+
for (const origin of Object.values(serverOrigins)) {
|
|
38
|
+
allow(new URL(origin).hostname);
|
|
39
|
+
}
|
|
40
|
+
// the name other machines use for this one (mDNS gives "name.local")
|
|
41
|
+
const machineName = osHostname();
|
|
42
|
+
allow(machineName);
|
|
43
|
+
if (!machineName.endsWith(".local")) {
|
|
44
|
+
allow(`${machineName}.local`);
|
|
45
|
+
}
|
|
46
|
+
if (acceptAnyIp) {
|
|
47
|
+
for (const addresses of Object.values(networkInterfaces())) {
|
|
48
|
+
for (const { address, family } of addresses) {
|
|
49
|
+
allow(family === "IPv6" || family === 6 ? `[${address}]` : address);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const allowedHost of allowedHosts) {
|
|
54
|
+
allow(allowedHost);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (host) => {
|
|
58
|
+
if (host === undefined) {
|
|
59
|
+
// an http/1.0 client; a browser always sends the header
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const hostNormalized = normalizeHost(host);
|
|
63
|
+
if (hostNormalized === null) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (allowedHostSet.has(hostNormalized)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
for (const suffix of allowedHostSuffixes) {
|
|
70
|
+
if (hostNormalized.endsWith(suffix)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// "example.com:3456" or "[::1]:3456": the port is not part of the name
|
|
79
|
+
const normalizeHost = (host) => {
|
|
80
|
+
const url = `http://${host}`;
|
|
81
|
+
if (!URL.canParse(url)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return new URL(url).hostname.toLowerCase();
|
|
85
|
+
};
|
|
@@ -3,8 +3,8 @@ https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
|
|
3
3
|
https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { normalizeHeaderName } from "./
|
|
7
|
-
import { normalizeHeaderValue } from "./
|
|
6
|
+
import { normalizeHeaderName } from "./normalize_header_name.js";
|
|
7
|
+
import { normalizeHeaderValue } from "./normalize_header_value.js";
|
|
8
8
|
|
|
9
9
|
export const headersFromObject = (headersObject) => {
|
|
10
10
|
const headers = {};
|
package/src/internal/listen.js
CHANGED
|
@@ -31,6 +31,19 @@ const listen = async ({
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Find a port nobody listens to on the given hostname, trying `initialPort`
|
|
36
|
+
* first then the following ones.
|
|
37
|
+
*
|
|
38
|
+
* @param {number} [initialPort=1] - First port to try.
|
|
39
|
+
* @param {Object} [options]
|
|
40
|
+
* @param {AbortSignal} [options.signal]
|
|
41
|
+
* @param {string} [options.hostname="127.0.0.1"] - Interface the port must be free on.
|
|
42
|
+
* @param {number} [options.min=1]
|
|
43
|
+
* @param {number} [options.max=65534] - Give up (throw) past this port.
|
|
44
|
+
* @param {(port: number) => number} [options.next] - How to pick the next port to try.
|
|
45
|
+
* @returns {Promise<number>}
|
|
46
|
+
*/
|
|
34
47
|
export const findFreePort = async (
|
|
35
48
|
initialPort = 1,
|
|
36
49
|
{
|
|
@@ -93,20 +106,34 @@ const portIsFree = async (port, hostname) => {
|
|
|
93
106
|
|
|
94
107
|
const startListening = ({ server, port, hostname }) => {
|
|
95
108
|
return new Promise((resolve, reject) => {
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
const onError = (error) => {
|
|
110
|
+
server.removeListener("listening", onListening);
|
|
111
|
+
reject(error);
|
|
112
|
+
};
|
|
113
|
+
const onListening = () => {
|
|
114
|
+
server.removeListener("error", onError);
|
|
98
115
|
// in case port is 0 (randomly assign an available port)
|
|
99
116
|
// https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback
|
|
100
117
|
resolve(server.address().port);
|
|
101
|
-
}
|
|
118
|
+
};
|
|
119
|
+
server.once("error", onError);
|
|
120
|
+
server.once("listening", onListening);
|
|
102
121
|
server.listen(port, hostname);
|
|
103
122
|
});
|
|
104
123
|
};
|
|
105
124
|
|
|
106
125
|
export const stopListening = (server) => {
|
|
107
126
|
return new Promise((resolve, reject) => {
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
const onError = (error) => {
|
|
128
|
+
server.removeListener("close", onClose);
|
|
129
|
+
reject(error);
|
|
130
|
+
};
|
|
131
|
+
const onClose = () => {
|
|
132
|
+
server.removeListener("error", onError);
|
|
133
|
+
resolve();
|
|
134
|
+
};
|
|
135
|
+
server.once("error", onError);
|
|
136
|
+
server.once("close", onClose);
|
|
110
137
|
server.close();
|
|
111
138
|
});
|
|
112
139
|
};
|
|
@@ -32,17 +32,31 @@ export const parseMultipleHeader = (
|
|
|
32
32
|
return multipleHeader;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// "for=192.0.2.60;proto=http;by=203.0.113.43", the shape of a Forwarded
|
|
36
|
+
// header element (RFC 7239). Several elements, one per proxy, can be joined
|
|
37
|
+
// with a comma: only the first one, the closest to the client, is read.
|
|
38
|
+
// Attributes without "=" are ignored, values may be quoted.
|
|
35
39
|
export const parseSingleHeaderWithAttributes = (
|
|
36
40
|
string,
|
|
37
41
|
{ validateAttribute = () => true } = {},
|
|
38
42
|
) => {
|
|
39
43
|
const props = {};
|
|
40
|
-
const
|
|
41
|
-
for (const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const [firstElement] = string.split(",");
|
|
45
|
+
for (const attribute of firstElement.split(";")) {
|
|
46
|
+
const equalIndex = attribute.indexOf("=");
|
|
47
|
+
if (equalIndex === -1) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const name = attribute.slice(0, equalIndex).trim();
|
|
51
|
+
let value = attribute.slice(equalIndex + 1).trim();
|
|
52
|
+
if (
|
|
53
|
+
value.length > 1 &&
|
|
54
|
+
value[0] === '"' &&
|
|
55
|
+
value[value.length - 1] === '"'
|
|
56
|
+
) {
|
|
57
|
+
value = value.slice(1, -1);
|
|
58
|
+
}
|
|
59
|
+
if (name && validateAttribute({ name, value })) {
|
|
46
60
|
props[name] = value;
|
|
47
61
|
}
|
|
48
62
|
}
|
|
@@ -52,10 +66,15 @@ export const parseSingleHeaderWithAttributes = (
|
|
|
52
66
|
const parseHeaderProperties = (headerProperties, { validateProperty }) => {
|
|
53
67
|
const properties = {};
|
|
54
68
|
for (const propertySource of headerProperties) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
const equalIndex = propertySource.indexOf("=");
|
|
70
|
+
// "text/html;foo": a property without value is kept as a flag
|
|
71
|
+
const propertyName = (
|
|
72
|
+
equalIndex === -1 ? propertySource : propertySource.slice(0, equalIndex)
|
|
73
|
+
).trim();
|
|
74
|
+
const propertyValue =
|
|
75
|
+
equalIndex === -1
|
|
76
|
+
? true
|
|
77
|
+
: parseHeaderPropertyValue(propertySource.slice(equalIndex + 1).trim());
|
|
59
78
|
const property = { name: propertyName, value: propertyValue };
|
|
60
79
|
const propertyValidation = validateProperty(property);
|
|
61
80
|
if (!propertyValidation) {
|
|
@@ -67,6 +86,9 @@ const parseHeaderProperties = (headerProperties, { validateProperty }) => {
|
|
|
67
86
|
};
|
|
68
87
|
|
|
69
88
|
const parseHeaderPropertyValue = (headerPropertyValueString) => {
|
|
89
|
+
if (headerPropertyValueString === "") {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
70
92
|
const firstChar = headerPropertyValueString[0];
|
|
71
93
|
const lastChar =
|
|
72
94
|
headerPropertyValueString[headerPropertyValueString.length - 1];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// An HTTP/1.1 reason phrase allows HTAB, SP, VCHAR and obs-text (0x80-0xFF),
|
|
2
|
+
// see RFC 9112 section 4. Node's writeHead and the Response constructor both
|
|
3
|
+
// throw on anything else. A status text often echoes a request (a file path
|
|
4
|
+
// decoded from the url for instance), so the characters outside that set are
|
|
5
|
+
// percent-encoded rather than dropped: the text stays readable and reversible.
|
|
6
|
+
export const asReasonPhrase = (value) => {
|
|
7
|
+
return String(value).replace(/[^\t\x20-\x7e\x80-\xff]/g, encodeChar);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const encodeChar = (char) => {
|
|
11
|
+
try {
|
|
12
|
+
return encodeURIComponent(char);
|
|
13
|
+
} catch {
|
|
14
|
+
// lone surrogate
|
|
15
|
+
return "%3F";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -8,12 +8,22 @@ const asResponseProperties = (value) => {
|
|
|
8
8
|
statusText: value.statusText,
|
|
9
9
|
headers: Object.fromEntries(value.headers),
|
|
10
10
|
body: value.body,
|
|
11
|
-
bodyEncoding: value.bodyEncoding,
|
|
12
11
|
};
|
|
13
12
|
}
|
|
14
13
|
return value;
|
|
15
14
|
};
|
|
16
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Merge two responses into one. Each can be a `Response` instance or a plain
|
|
18
|
+
* `{ status, statusText, statusMessage, headers, body, timing }` object.
|
|
19
|
+
* The second response wins for status, statusText, statusMessage and body;
|
|
20
|
+
* headers are composed (list headers such as `vary` or `allow` accumulate
|
|
21
|
+
* their values, `set-cookie` becomes an array) and `timing` objects are merged.
|
|
22
|
+
*
|
|
23
|
+
* @param {Response|Object} firstResponse
|
|
24
|
+
* @param {Response|Object} secondResponse
|
|
25
|
+
* @returns {Object} Plain response properties.
|
|
26
|
+
*/
|
|
17
27
|
export const composeTwoResponses = (firstResponse, secondResponse) => {
|
|
18
28
|
firstResponse = asResponseProperties(firstResponse);
|
|
19
29
|
secondResponse = asResponseProperties(secondResponse);
|
|
@@ -30,7 +40,6 @@ const RESPONSE_KEYS_COMPOSITION = {
|
|
|
30
40
|
statusMessage: (prevStatusMessage, statusMessage) => statusMessage,
|
|
31
41
|
headers: composeTwoHeaders,
|
|
32
42
|
body: (prevBody, body) => body,
|
|
33
|
-
bodyEncoding: (prevEncoding, encoding) => encoding,
|
|
34
43
|
// measures a response hands back, merged into the server-timing header when
|
|
35
44
|
// the server has serverTiming enabled (see finalizeResponseProperties)
|
|
36
45
|
timing: (prevTiming, timing) => ({ ...prevTiming, ...timing }),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The imports are dynamic so that "node:http2" / "node:https" are only parsed
|
|
2
|
+
// when a secure server is actually created.
|
|
3
|
+
export const createSecureServer = async ({
|
|
4
|
+
certificate,
|
|
5
|
+
privateKey,
|
|
6
|
+
http2,
|
|
7
|
+
http1Allowed,
|
|
8
|
+
}) => {
|
|
9
|
+
if (http2) {
|
|
10
|
+
const { createSecureServer } = await import("node:http2");
|
|
11
|
+
return createSecureServer({
|
|
12
|
+
cert: certificate,
|
|
13
|
+
key: privateKey,
|
|
14
|
+
allowHTTP1: http1Allowed,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const { createServer } = await import("node:https");
|
|
18
|
+
return createServer({
|
|
19
|
+
cert: certificate,
|
|
20
|
+
key: privateKey,
|
|
21
|
+
});
|
|
22
|
+
};
|