@jsenv/server 17.6.1 → 17.6.3

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.
Files changed (52) hide show
  1. package/README.md +47 -48
  2. package/dist/jsenv_server.js +1336 -1270
  3. package/package.json +2 -2
  4. package/src/content_negotiation/pick_content_encoding.js +11 -1
  5. package/src/content_negotiation/pick_content_language.js +11 -1
  6. package/src/content_negotiation/pick_content_type.js +11 -1
  7. package/src/content_negotiation/pick_content_version.js +1 -1
  8. package/src/interfacing_with_node/from_node_request.js +154 -178
  9. package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
  10. package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
  11. package/src/interfacing_with_node/write_node_response.js +59 -103
  12. package/src/internal/allowed_hosts.js +85 -0
  13. package/src/internal/escape_html.js +11 -0
  14. package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
  15. package/src/internal/listen.js +32 -5
  16. package/src/internal/{listenRequest.js → listen_request.js} +1 -1
  17. package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
  18. package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
  19. package/src/internal/reason_phrase.js +17 -0
  20. package/src/internal/response_composition.js +11 -2
  21. package/src/internal/secure_server.js +22 -0
  22. package/src/internal/server_origins.js +78 -0
  23. package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
  24. package/src/internal/track_server_pending_connections.js +50 -0
  25. package/src/internal/track_server_pending_requests.js +47 -0
  26. package/src/plugins/cors/server_plugin_cors.js +39 -6
  27. package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
  28. package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
  29. package/src/plugins/filesystem/fetch_directory.js +16 -3
  30. package/src/plugins/filesystem/fetch_file.js +80 -21
  31. package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
  32. package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
  33. package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
  34. package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
  35. package/src/plugins/server_plugins_controller.js +27 -13
  36. package/src/progressive_response.js +24 -0
  37. package/src/router/permissions.js +52 -0
  38. package/src/router/router.js +18 -80
  39. package/src/server_events.js +3 -1
  40. package/src/server_timing/timing_header.js +1 -1
  41. package/src/start_server.js +266 -416
  42. package/src/internal/headersFromString.js +0 -21
  43. package/src/internal/headersToObject.js +0 -7
  44. package/src/internal/headersToString.js +0 -16
  45. package/src/internal/listenClientError.js +0 -27
  46. package/src/internal/trackServerPendingConnections.js +0 -145
  47. package/src/internal/trackServerPendingRequests.js +0 -68
  48. package/src/request_body_handling.js +0 -22
  49. /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
  50. /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
  51. /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
  52. /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
- nodeStream,
7
- { readableLifetime } = {},
8
- ) => {
9
- const observable = createObservable(
10
- ({ next, error, complete, addTeardown }) => {
11
- const errorEventCallback = (e) => {
12
- error(e);
13
- };
14
- const dataEventCallback = (data) => {
15
- next(data);
16
- };
17
- const closeEventCallback = () => {
18
- complete();
19
- };
20
- const endEventCallback = () => {
21
- complete();
22
- };
23
- nodeStream.once("error", errorEventCallback);
24
- nodeStream.on("data", dataEventCallback);
25
- nodeStream.once("end", endEventCallback);
26
- nodeStream.once("close", closeEventCallback); // not sure it's required
27
- addTeardown(() => {
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
- observable.timeout = timeout;
57
- }
58
-
59
- return observable;
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, bodyEncoding },
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 responseIsServerHttp2Stream =
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
- if (statusText === undefined) {
187
- statusText = statusTextFromStatus(status);
188
- } else {
189
- statusText = statusText.replace(/\n/g, "").replaceAll("✅", "");
190
- }
191
- if (responseIsServerHttp2Stream) {
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
- Object.keys(headers).forEach((name) => {
244
- if (name === "connection" && ignoreConnectionHeader) return;
245
- const nodeHeaderName = name in mapping ? mapping[name] : name;
246
- nodeHeaders[nodeHeaderName] = headers[name];
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
+ };
@@ -0,0 +1,11 @@
1
+ const HTML_ESCAPES = {
2
+ "&": "&",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;",
7
+ };
8
+
9
+ export const escapeHtml = (value) => {
10
+ return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
11
+ };
@@ -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 "./normalizeHeaderName.js";
7
- import { normalizeHeaderValue } from "./normalizeHeaderValue.js";
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 = {};
@@ -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
- server.on("error", reject);
97
- server.on("listening", () => {
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
- server.on("error", reject);
109
- server.on("close", resolve);
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
  };
@@ -1,4 +1,4 @@
1
- import { listenEvent } from "./listenEvent.js";
1
+ import { listenEvent } from "./listen_event.js";
2
2
 
3
3
  export const listenRequest = (nodeServer, requestCallback) => {
4
4
  if (nodeServer._httpServer) {
@@ -1,4 +1,4 @@
1
- import { listenEvent } from "./listenEvent.js";
1
+ import { listenEvent } from "./listen_event.js";
2
2
 
3
3
  export const listenServerConnectionError = (
4
4
  nodeServer,
@@ -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 attributes = string.split(";");
41
- for (const attr of attributes) {
42
- let [name, value] = attr.split("=");
43
- name = name.trim();
44
- value = value.trim();
45
- if (validateAttribute({ name, value })) {
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 [propertyName, propertyValueString] = propertySource
56
- .trim()
57
- .split("=");
58
- const propertyValue = parseHeaderPropertyValue(propertyValueString);
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
+ };