@jsenv/server 17.6.0 → 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/client/default_body_4xx_5xx/4xx.html +1 -1
- package/dist/jsenv_server.js +1332 -1269
- package/dist/ws/ws.js +3 -1
- package/package.json +5 -5
- 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
package/dist/jsenv_server.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import cluster from "node:cluster";
|
|
2
|
-
import net, { Socket, createServer, isIP } from "node:net";
|
|
3
2
|
import { extname } from "node:path";
|
|
4
3
|
import { parse } from "node:querystring";
|
|
5
|
-
import
|
|
6
|
-
import http from "node:http";
|
|
4
|
+
import http, { STATUS_CODES } from "node:http";
|
|
7
5
|
import { Http2ServerResponse } from "node:http2";
|
|
8
|
-
import
|
|
6
|
+
import net, { Socket, createServer, isIP } from "node:net";
|
|
7
|
+
import { Stream, Writable, Readable } from "node:stream";
|
|
8
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
9
|
+
import { lookup } from "node:dns";
|
|
10
|
+
import { readFileSync, existsSync, readdirSync, lstatSync, createReadStream } from "node:fs";
|
|
9
11
|
import { createRequire } from "node:module";
|
|
10
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
13
|
import { performance as performance$1 } from "node:perf_hooks";
|
|
12
|
-
import {
|
|
13
|
-
import { networkInterfaces } from "node:os";
|
|
14
|
+
import { stat, readFile } from "node:fs/promises";
|
|
14
15
|
import { createHash } from "node:crypto";
|
|
15
16
|
|
|
16
17
|
const createCallbackListNotifiedOnce = () => {
|
|
@@ -559,14 +560,14 @@ const errorToHTML = (error) => {
|
|
|
559
560
|
|
|
560
561
|
if (errorIsAPrimitive) {
|
|
561
562
|
if (typeof error === "string") {
|
|
562
|
-
return `<pre>${escapeHtml(error)}</pre>`;
|
|
563
|
+
return `<pre>${escapeHtml$1(error)}</pre>`;
|
|
563
564
|
}
|
|
564
565
|
return `<pre>${JSON.stringify(error, null, " ")}</pre>`;
|
|
565
566
|
}
|
|
566
|
-
return `<pre>${escapeHtml(error.stack)}</pre>`;
|
|
567
|
+
return `<pre>${escapeHtml$1(error.stack)}</pre>`;
|
|
567
568
|
};
|
|
568
569
|
|
|
569
|
-
const escapeHtml = (string) => {
|
|
570
|
+
const escapeHtml$1 = (string) => {
|
|
570
571
|
return string
|
|
571
572
|
.replace(/&/g, "&")
|
|
572
573
|
.replace(/</g, "<")
|
|
@@ -1017,17 +1018,31 @@ const parseMultipleHeader = (
|
|
|
1017
1018
|
return multipleHeader;
|
|
1018
1019
|
};
|
|
1019
1020
|
|
|
1021
|
+
// "for=192.0.2.60;proto=http;by=203.0.113.43", the shape of a Forwarded
|
|
1022
|
+
// header element (RFC 7239). Several elements, one per proxy, can be joined
|
|
1023
|
+
// with a comma: only the first one, the closest to the client, is read.
|
|
1024
|
+
// Attributes without "=" are ignored, values may be quoted.
|
|
1020
1025
|
const parseSingleHeaderWithAttributes = (
|
|
1021
1026
|
string,
|
|
1022
1027
|
{ validateAttribute = () => true } = {},
|
|
1023
1028
|
) => {
|
|
1024
1029
|
const props = {};
|
|
1025
|
-
const
|
|
1026
|
-
for (const
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1030
|
+
const [firstElement] = string.split(",");
|
|
1031
|
+
for (const attribute of firstElement.split(";")) {
|
|
1032
|
+
const equalIndex = attribute.indexOf("=");
|
|
1033
|
+
if (equalIndex === -1) {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const name = attribute.slice(0, equalIndex).trim();
|
|
1037
|
+
let value = attribute.slice(equalIndex + 1).trim();
|
|
1038
|
+
if (
|
|
1039
|
+
value.length > 1 &&
|
|
1040
|
+
value[0] === '"' &&
|
|
1041
|
+
value[value.length - 1] === '"'
|
|
1042
|
+
) {
|
|
1043
|
+
value = value.slice(1, -1);
|
|
1044
|
+
}
|
|
1045
|
+
if (name && validateAttribute({ name, value })) {
|
|
1031
1046
|
props[name] = value;
|
|
1032
1047
|
}
|
|
1033
1048
|
}
|
|
@@ -1037,10 +1052,15 @@ const parseSingleHeaderWithAttributes = (
|
|
|
1037
1052
|
const parseHeaderProperties = (headerProperties, { validateProperty }) => {
|
|
1038
1053
|
const properties = {};
|
|
1039
1054
|
for (const propertySource of headerProperties) {
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1055
|
+
const equalIndex = propertySource.indexOf("=");
|
|
1056
|
+
// "text/html;foo": a property without value is kept as a flag
|
|
1057
|
+
const propertyName = (
|
|
1058
|
+
equalIndex === -1 ? propertySource : propertySource.slice(0, equalIndex)
|
|
1059
|
+
).trim();
|
|
1060
|
+
const propertyValue =
|
|
1061
|
+
equalIndex === -1
|
|
1062
|
+
? true
|
|
1063
|
+
: parseHeaderPropertyValue(propertySource.slice(equalIndex + 1).trim());
|
|
1044
1064
|
const property = { name: propertyName, value: propertyValue };
|
|
1045
1065
|
const propertyValidation = validateProperty(property);
|
|
1046
1066
|
if (!propertyValidation) {
|
|
@@ -1052,6 +1072,9 @@ const parseHeaderProperties = (headerProperties, { validateProperty }) => {
|
|
|
1052
1072
|
};
|
|
1053
1073
|
|
|
1054
1074
|
const parseHeaderPropertyValue = (headerPropertyValueString) => {
|
|
1075
|
+
if (headerPropertyValueString === "") {
|
|
1076
|
+
return "";
|
|
1077
|
+
}
|
|
1055
1078
|
const firstChar = headerPropertyValueString[0];
|
|
1056
1079
|
const lastChar =
|
|
1057
1080
|
headerPropertyValueString[headerPropertyValueString.length - 1];
|
|
@@ -1217,76 +1240,54 @@ const isObservable = (value) => {
|
|
|
1217
1240
|
|
|
1218
1241
|
// https://github.com/jamestalmage/stream-to-observable/blob/master/index.js
|
|
1219
1242
|
|
|
1220
|
-
const observableFromNodeStream = (
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
)
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
nodeStream.
|
|
1240
|
-
nodeStream.
|
|
1241
|
-
nodeStream.
|
|
1242
|
-
|
|
1243
|
-
nodeStream.removeListener("error", errorEventCallback);
|
|
1244
|
-
nodeStream.removeListener("data", dataEventCallback);
|
|
1245
|
-
nodeStream.removeListener("end", endEventCallback);
|
|
1246
|
-
nodeStream.removeListener("close", closeEventCallback); // not sure it's required
|
|
1247
|
-
});
|
|
1248
|
-
if (nodeStream.isPaused()) {
|
|
1249
|
-
nodeStream.resume();
|
|
1250
|
-
} else if (nodeStream.complete) {
|
|
1251
|
-
complete();
|
|
1252
|
-
}
|
|
1253
|
-
},
|
|
1254
|
-
);
|
|
1255
|
-
|
|
1256
|
-
if (readableLifetime && nodeStream instanceof Readable) {
|
|
1257
|
-
const timeout = setTimeout(() => {
|
|
1258
|
-
// disabled for now
|
|
1259
|
-
// process.emitWarning(
|
|
1260
|
-
// `Readable stream not used after ${readableLifetime / 1000} seconds.`,
|
|
1261
|
-
// {
|
|
1262
|
-
// CODE: "READABLE_STREAM_TIMEOUT",
|
|
1263
|
-
// // url is for http client request
|
|
1264
|
-
// detail: `path: ${nodeStream.path}, fd: ${nodeStream.fd}, url: ${nodeStream.url}`,
|
|
1265
|
-
// },
|
|
1266
|
-
// );
|
|
1267
|
-
}, readableLifetime).unref();
|
|
1268
|
-
onceReadableStreamUsedOrClosed(nodeStream, () => {
|
|
1269
|
-
clearTimeout(timeout);
|
|
1243
|
+
const observableFromNodeStream = (nodeStream) => {
|
|
1244
|
+
return createObservable(({ next, error, complete, addTeardown }) => {
|
|
1245
|
+
const errorEventCallback = (e) => {
|
|
1246
|
+
error(e);
|
|
1247
|
+
};
|
|
1248
|
+
const dataEventCallback = (data) => {
|
|
1249
|
+
next(data);
|
|
1250
|
+
};
|
|
1251
|
+
const closeEventCallback = () => {
|
|
1252
|
+
complete();
|
|
1253
|
+
};
|
|
1254
|
+
const endEventCallback = () => {
|
|
1255
|
+
complete();
|
|
1256
|
+
};
|
|
1257
|
+
nodeStream.once("error", errorEventCallback);
|
|
1258
|
+
nodeStream.on("data", dataEventCallback);
|
|
1259
|
+
nodeStream.once("end", endEventCallback);
|
|
1260
|
+
nodeStream.once("close", closeEventCallback); // not sure it's required
|
|
1261
|
+
addTeardown(() => {
|
|
1262
|
+
nodeStream.removeListener("error", errorEventCallback);
|
|
1263
|
+
nodeStream.removeListener("data", dataEventCallback);
|
|
1264
|
+
nodeStream.removeListener("end", endEventCallback);
|
|
1265
|
+
nodeStream.removeListener("close", closeEventCallback); // not sure it's required
|
|
1270
1266
|
});
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1267
|
+
if (nodeStream.isPaused()) {
|
|
1268
|
+
nodeStream.resume();
|
|
1269
|
+
} else if (nodeStream.complete) {
|
|
1270
|
+
complete();
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1275
1273
|
};
|
|
1276
1274
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1275
|
+
/*
|
|
1276
|
+
* Builds the `request` object handed to routes from a node request (http or
|
|
1277
|
+
* http2 compat api). It is frozen: a plugin wanting another request returns
|
|
1278
|
+
* new properties from "redirectRequest" and gets a copy (see
|
|
1279
|
+
* applyRedirectionToRequest). The shape is described in
|
|
1280
|
+
* docs/handling_requests.md.
|
|
1281
|
+
*
|
|
1282
|
+
* The values read from headers (`forwarded`, `cookie`...) come from the
|
|
1283
|
+
* network: the parsers must never throw on garbage, a request that cannot
|
|
1284
|
+
* be read would otherwise escape the request handler.
|
|
1285
|
+
*/
|
|
1286
|
+
|
|
1286
1287
|
|
|
1287
1288
|
const fromNodeRequest = (
|
|
1288
1289
|
nodeRequest,
|
|
1289
|
-
{ serverOrigin, signal,
|
|
1290
|
+
{ serverOrigin, signal, logger, requestBodyMaxSize = Infinity },
|
|
1290
1291
|
) => {
|
|
1291
1292
|
const requestLogger = createRequestLogger(nodeRequest, (type, value) => {
|
|
1292
1293
|
const logFunction = logger[type];
|
|
@@ -1326,12 +1327,7 @@ const fromNodeRequest = (
|
|
|
1326
1327
|
// Without this the request body readable stream
|
|
1327
1328
|
// might be closed when we'll try to attach "data" and "end" listeners to it
|
|
1328
1329
|
nodeRequest.pause();
|
|
1329
|
-
|
|
1330
|
-
nodeRequest.connection.setNoDelay(true);
|
|
1331
|
-
}
|
|
1332
|
-
const body = observableFromNodeStream(nodeRequest, {
|
|
1333
|
-
readableLifetime: requestBodyLifetime,
|
|
1334
|
-
});
|
|
1330
|
+
const body = observableFromNodeStream(nodeRequest);
|
|
1335
1331
|
|
|
1336
1332
|
let requestOrigin;
|
|
1337
1333
|
if (nodeRequest.upgrade) {
|
|
@@ -1350,15 +1346,15 @@ const fromNodeRequest = (
|
|
|
1350
1346
|
|
|
1351
1347
|
// check the following parsers if we want to support more request body content types
|
|
1352
1348
|
// https://github.com/node-formidable/formidable/tree/master/src/parsers
|
|
1353
|
-
|
|
1349
|
+
// Every reader below buffers the body in memory: past maxSize it throws an
|
|
1350
|
+
// error the server answers with 413 (see RequestBodyTooLargeError).
|
|
1351
|
+
const buffer = async ({ maxSize = requestBodyMaxSize } = {}) => {
|
|
1354
1352
|
// here we don't really need to warn, one might want to read anything as binary
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
// }
|
|
1361
|
-
const requestBodyBuffer = await readBody(body, { as: "buffer" });
|
|
1353
|
+
const requestBodyBuffer = await readBody(body, {
|
|
1354
|
+
as: "buffer",
|
|
1355
|
+
maxSize,
|
|
1356
|
+
headers,
|
|
1357
|
+
});
|
|
1362
1358
|
return requestBodyBuffer;
|
|
1363
1359
|
};
|
|
1364
1360
|
// maybe we could use https://github.com/form-data/form-data
|
|
@@ -1372,40 +1368,52 @@ const fromNodeRequest = (
|
|
|
1372
1368
|
}
|
|
1373
1369
|
const { formidable } = await import("./formidable_index/formidable_index.js");
|
|
1374
1370
|
const form = formidable({});
|
|
1375
|
-
nodeRequest.resume(); //
|
|
1371
|
+
nodeRequest.resume(); // paused above, formidable reads the node stream directly
|
|
1376
1372
|
const [fields, files] = await form.parse(nodeRequest);
|
|
1377
1373
|
const requestBodyFormData = { fields, files };
|
|
1378
1374
|
return requestBodyFormData;
|
|
1379
1375
|
};
|
|
1380
|
-
const text = async () => {
|
|
1376
|
+
const text = async ({ maxSize = requestBodyMaxSize } = {}) => {
|
|
1381
1377
|
const contentType = headers["content-type"];
|
|
1382
1378
|
if (!CONTENT_TYPE.isTextual(contentType)) {
|
|
1383
1379
|
console.warn(
|
|
1384
1380
|
`text() called on a request with content-type "${contentType}". A textual content-type was expected.`,
|
|
1385
1381
|
);
|
|
1386
1382
|
}
|
|
1387
|
-
const requestBodyString = await readBody(body, {
|
|
1383
|
+
const requestBodyString = await readBody(body, {
|
|
1384
|
+
as: "string",
|
|
1385
|
+
maxSize,
|
|
1386
|
+
headers,
|
|
1387
|
+
});
|
|
1388
1388
|
return requestBodyString;
|
|
1389
1389
|
};
|
|
1390
|
-
const json = async () => {
|
|
1390
|
+
const json = async ({ maxSize = requestBodyMaxSize } = {}) => {
|
|
1391
1391
|
const contentType = headers["content-type"];
|
|
1392
1392
|
if (!CONTENT_TYPE.isJson(contentType)) {
|
|
1393
1393
|
console.warn(
|
|
1394
1394
|
`json() called on a request with content-type "${contentType}". A json content-type was expected.`,
|
|
1395
1395
|
);
|
|
1396
1396
|
}
|
|
1397
|
-
const requestBodyString = await readBody(body, {
|
|
1397
|
+
const requestBodyString = await readBody(body, {
|
|
1398
|
+
as: "string",
|
|
1399
|
+
maxSize,
|
|
1400
|
+
headers,
|
|
1401
|
+
});
|
|
1398
1402
|
const requestBodyJSON = JSON.parse(requestBodyString);
|
|
1399
1403
|
return requestBodyJSON;
|
|
1400
1404
|
};
|
|
1401
|
-
const queryString = async () => {
|
|
1405
|
+
const queryString = async ({ maxSize = requestBodyMaxSize } = {}) => {
|
|
1402
1406
|
const contentType = headers["content-type"];
|
|
1403
1407
|
if (contentType !== "application/x-www-form-urlencoded") {
|
|
1404
1408
|
console.warn(
|
|
1405
1409
|
`queryString() called on a request with content-type "${contentType}". application/x-www-form-urlencoded was expected.`,
|
|
1406
1410
|
);
|
|
1407
1411
|
}
|
|
1408
|
-
const requestBodyString = await readBody(body, {
|
|
1412
|
+
const requestBodyString = await readBody(body, {
|
|
1413
|
+
as: "string",
|
|
1414
|
+
maxSize,
|
|
1415
|
+
headers,
|
|
1416
|
+
});
|
|
1409
1417
|
const requestBodyQueryStringParsed = parse(requestBodyString);
|
|
1410
1418
|
return requestBodyQueryStringParsed;
|
|
1411
1419
|
};
|
|
@@ -1413,6 +1421,7 @@ const fromNodeRequest = (
|
|
|
1413
1421
|
// request.ip -> request ip as received by the server
|
|
1414
1422
|
// request.ipForwarded -> ip of the client before proxying, undefined when there is no proxy
|
|
1415
1423
|
// same applies on request.proto and request.host
|
|
1424
|
+
// These forwarded values are what the headers say: any client can send them.
|
|
1416
1425
|
let ip = nodeRequest.socket.remoteAddress;
|
|
1417
1426
|
let proto = requestOrigin.startsWith("http:") ? "http" : "https";
|
|
1418
1427
|
let host = headers["host"];
|
|
@@ -1431,7 +1440,7 @@ const fromNodeRequest = (
|
|
|
1431
1440
|
const forwardedHost = headers["x-forwarded-host"];
|
|
1432
1441
|
if (forwardedFor) {
|
|
1433
1442
|
// format is <client-ip>, <proxy1>, <proxy2>
|
|
1434
|
-
ipForwarded = forwardedFor.split(",")[0];
|
|
1443
|
+
ipForwarded = forwardedFor.split(",")[0].trim();
|
|
1435
1444
|
}
|
|
1436
1445
|
if (forwardedProto) {
|
|
1437
1446
|
protoForwarded = forwardedProto;
|
|
@@ -1469,52 +1478,27 @@ const fromNodeRequest = (
|
|
|
1469
1478
|
});
|
|
1470
1479
|
};
|
|
1471
1480
|
|
|
1481
|
+
// Handling a request is asynchronous: its logs are buffered until the
|
|
1482
|
+
// response headers are sent (or the request is dropped) so that the logs of
|
|
1483
|
+
// concurrent requests do not interleave.
|
|
1472
1484
|
const createRequestLogger = (nodeRequest, write) => {
|
|
1473
|
-
// Handling request is asynchronous, we buffer logs for that request
|
|
1474
|
-
// until we know what happens with that request
|
|
1475
|
-
// It delays logs until we know of the request will be handled
|
|
1476
|
-
// but it's mandatory to make logs readable.
|
|
1477
|
-
|
|
1478
1485
|
const logArray = [];
|
|
1479
|
-
const
|
|
1480
|
-
const add = ({ type, value }) => {
|
|
1486
|
+
const add = (type, value) => {
|
|
1481
1487
|
logArray.push({ type, value });
|
|
1482
1488
|
};
|
|
1483
1489
|
|
|
1484
1490
|
const requestLogger = {
|
|
1485
|
-
logArray,
|
|
1486
|
-
childArray,
|
|
1487
|
-
hasPushChild: false,
|
|
1488
|
-
forPush: () => {
|
|
1489
|
-
const childLogBuffer = createRequestLogger(nodeRequest, write);
|
|
1490
|
-
childLogBuffer.isChild = true;
|
|
1491
|
-
childArray.push(childLogBuffer);
|
|
1492
|
-
requestLogger.hasPushChild = true;
|
|
1493
|
-
return childLogBuffer;
|
|
1494
|
-
},
|
|
1495
1491
|
debug: (value) => {
|
|
1496
|
-
add(
|
|
1497
|
-
type: "debug",
|
|
1498
|
-
value,
|
|
1499
|
-
});
|
|
1492
|
+
add("debug", value);
|
|
1500
1493
|
},
|
|
1501
1494
|
info: (value) => {
|
|
1502
|
-
add(
|
|
1503
|
-
type: "info",
|
|
1504
|
-
value,
|
|
1505
|
-
});
|
|
1495
|
+
add("info", value);
|
|
1506
1496
|
},
|
|
1507
1497
|
warn: (value) => {
|
|
1508
|
-
add(
|
|
1509
|
-
type: "warn",
|
|
1510
|
-
value,
|
|
1511
|
-
});
|
|
1498
|
+
add("warn", value);
|
|
1512
1499
|
},
|
|
1513
1500
|
error: (value) => {
|
|
1514
|
-
add(
|
|
1515
|
-
type: "error",
|
|
1516
|
-
value,
|
|
1517
|
-
});
|
|
1501
|
+
add("error", value);
|
|
1518
1502
|
},
|
|
1519
1503
|
onHeadersSent: ({ status, statusText }) => {
|
|
1520
1504
|
const isFaviconNotFound =
|
|
@@ -1530,8 +1514,8 @@ const createRequestLogger = (nodeRequest, write) => {
|
|
|
1530
1514
|
if (statusText) {
|
|
1531
1515
|
message += ` ${statusText}`;
|
|
1532
1516
|
}
|
|
1533
|
-
add(
|
|
1534
|
-
|
|
1517
|
+
add(
|
|
1518
|
+
isFaviconNotFound
|
|
1535
1519
|
? "debug"
|
|
1536
1520
|
: {
|
|
1537
1521
|
information: "info",
|
|
@@ -1540,8 +1524,8 @@ const createRequestLogger = (nodeRequest, write) => {
|
|
|
1540
1524
|
client_error: "warn",
|
|
1541
1525
|
server_error: "error",
|
|
1542
1526
|
}[statusType] || "error",
|
|
1543
|
-
|
|
1544
|
-
|
|
1527
|
+
message,
|
|
1528
|
+
);
|
|
1545
1529
|
},
|
|
1546
1530
|
ended: false,
|
|
1547
1531
|
end: () => {
|
|
@@ -1549,19 +1533,24 @@ const createRequestLogger = (nodeRequest, write) => {
|
|
|
1549
1533
|
return;
|
|
1550
1534
|
}
|
|
1551
1535
|
requestLogger.ended = true;
|
|
1552
|
-
if (
|
|
1553
|
-
// keep buffering until root request write logs for everyone
|
|
1536
|
+
if (logArray.length === 0) {
|
|
1554
1537
|
return;
|
|
1555
1538
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1539
|
+
let someLogIsError = false;
|
|
1540
|
+
let someLogIsWarn = false;
|
|
1541
|
+
for (const log of logArray) {
|
|
1542
|
+
if (log.type === "error") {
|
|
1543
|
+
someLogIsError = true;
|
|
1544
|
+
}
|
|
1545
|
+
if (log.type === "warn") {
|
|
1546
|
+
someLogIsWarn = true;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
// every info log of a request that went wrong is written at the
|
|
1550
|
+
// warn/error level, so that it shows up next to what went wrong
|
|
1551
|
+
const writeLog = ({ type, value }, { indent }) => {
|
|
1552
|
+
if (indent) {
|
|
1553
|
+
value = prefixLines(value, " ");
|
|
1565
1554
|
}
|
|
1566
1555
|
if (type === "info") {
|
|
1567
1556
|
if (someLogIsError) {
|
|
@@ -1572,84 +1561,87 @@ const createRequestLogger = (nodeRequest, write) => {
|
|
|
1572
1561
|
}
|
|
1573
1562
|
write(type, value);
|
|
1574
1563
|
};
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
if (log.type === "error") {
|
|
1584
|
-
someLogIsError = true;
|
|
1585
|
-
}
|
|
1586
|
-
if (log.type === "warn") {
|
|
1587
|
-
someLogIsWarn = true;
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
const firstLog = logArray.shift();
|
|
1591
|
-
const lastLog = logArray.pop();
|
|
1592
|
-
const middleLogs = logArray;
|
|
1593
|
-
writeLog(firstLog, {
|
|
1594
|
-
someLogIsError,
|
|
1595
|
-
someLogIsWarn,
|
|
1596
|
-
depth,
|
|
1597
|
-
});
|
|
1598
|
-
for (const middleLog of middleLogs) {
|
|
1599
|
-
writeLog(middleLog, {
|
|
1600
|
-
someLogIsError,
|
|
1601
|
-
someLogIsWarn,
|
|
1602
|
-
depth,
|
|
1603
|
-
});
|
|
1604
|
-
}
|
|
1605
|
-
for (const childLoggerToWrite of loggerToWrite.childArray) {
|
|
1606
|
-
writeLogs(childLoggerToWrite, depth + 1);
|
|
1607
|
-
}
|
|
1608
|
-
if (lastLog) {
|
|
1609
|
-
writeLog(lastLog, {
|
|
1610
|
-
someLogIsError,
|
|
1611
|
-
someLogIsWarn,
|
|
1612
|
-
depth: depth + 1,
|
|
1613
|
-
});
|
|
1614
|
-
}
|
|
1615
|
-
};
|
|
1616
|
-
writeLogs(requestLogger, 0);
|
|
1564
|
+
// the last log is the response status, shown under the request line
|
|
1565
|
+
const lastLog = logArray.length > 1 ? logArray.pop() : null;
|
|
1566
|
+
for (const log of logArray) {
|
|
1567
|
+
writeLog(log, { indent: false });
|
|
1568
|
+
}
|
|
1569
|
+
if (lastLog) {
|
|
1570
|
+
writeLog(lastLog, { indent: true });
|
|
1571
|
+
}
|
|
1617
1572
|
},
|
|
1618
1573
|
};
|
|
1619
1574
|
|
|
1620
1575
|
return requestLogger;
|
|
1621
1576
|
};
|
|
1622
1577
|
|
|
1623
|
-
const
|
|
1578
|
+
const prefixLines = (string, prefix) => {
|
|
1579
|
+
return string.replace(/^(?!\s*$)/gm, prefix);
|
|
1580
|
+
};
|
|
1581
|
+
|
|
1582
|
+
const readBody = (body, { as, maxSize, headers }) => {
|
|
1624
1583
|
return new Promise((resolve, reject) => {
|
|
1584
|
+
const contentLength = headers["content-length"];
|
|
1585
|
+
if (contentLength !== undefined && Number(contentLength) > maxSize) {
|
|
1586
|
+
reject(new RequestBodyTooLargeError({ maxSize }));
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1625
1589
|
const bufferArray = [];
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1590
|
+
let length = 0;
|
|
1591
|
+
const abortController = new AbortController();
|
|
1592
|
+
body.subscribe(
|
|
1593
|
+
{
|
|
1594
|
+
error: reject,
|
|
1595
|
+
next: (buffer) => {
|
|
1596
|
+
length += buffer.length;
|
|
1597
|
+
if (length > maxSize) {
|
|
1598
|
+
abortController.abort();
|
|
1599
|
+
reject(new RequestBodyTooLargeError({ maxSize }));
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
bufferArray.push(buffer);
|
|
1603
|
+
},
|
|
1604
|
+
complete: () => {
|
|
1605
|
+
const bodyAsBuffer = Buffer.concat(bufferArray);
|
|
1606
|
+
if (as === "buffer") {
|
|
1607
|
+
resolve(bodyAsBuffer);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (as === "string") {
|
|
1611
|
+
const bodyAsString = bodyAsBuffer.toString();
|
|
1612
|
+
resolve(bodyAsString);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
if (as === "json") {
|
|
1616
|
+
const bodyAsString = bodyAsBuffer.toString();
|
|
1617
|
+
const bodyAsJSON = JSON.parse(bodyAsString);
|
|
1618
|
+
resolve(bodyAsJSON);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1648
1622
|
},
|
|
1649
|
-
|
|
1623
|
+
{ signal: abortController.signal },
|
|
1624
|
+
);
|
|
1650
1625
|
});
|
|
1651
1626
|
};
|
|
1652
1627
|
|
|
1628
|
+
class RequestBodyTooLargeError extends Error {
|
|
1629
|
+
constructor({ maxSize }) {
|
|
1630
|
+
super(`request body exceeds ${maxSize} bytes`);
|
|
1631
|
+
this.name = "RequestBodyTooLargeError";
|
|
1632
|
+
this.code = "REQUEST_BODY_TOO_LARGE";
|
|
1633
|
+
this.maxSize = maxSize;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
asResponse() {
|
|
1637
|
+
return {
|
|
1638
|
+
status: 413,
|
|
1639
|
+
statusText: "Payload Too Large",
|
|
1640
|
+
statusMessage: `The request body exceeds ${this.maxSize} bytes.`,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1653
1645
|
const parseRequestCookieHeader = (cookieHeader) => {
|
|
1654
1646
|
const map = new Map();
|
|
1655
1647
|
if (!cookieHeader) {
|
|
@@ -1661,12 +1653,22 @@ const parseRequestCookieHeader = (cookieHeader) => {
|
|
|
1661
1653
|
continue;
|
|
1662
1654
|
}
|
|
1663
1655
|
const name = pair.slice(0, eqIndex).trim();
|
|
1664
|
-
const value =
|
|
1656
|
+
const value = decodeCookieValue(pair.slice(eqIndex + 1).trim());
|
|
1665
1657
|
map.set(name, value);
|
|
1666
1658
|
}
|
|
1667
1659
|
return map;
|
|
1668
1660
|
};
|
|
1669
1661
|
|
|
1662
|
+
// a cookie value is not necessarily percent-encoded: a malformed sequence
|
|
1663
|
+
// keeps the raw value
|
|
1664
|
+
const decodeCookieValue = (value) => {
|
|
1665
|
+
try {
|
|
1666
|
+
return decodeURIComponent(value);
|
|
1667
|
+
} catch {
|
|
1668
|
+
return value;
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1670
1672
|
const applyRedirectionToRequest = (
|
|
1671
1673
|
request,
|
|
1672
1674
|
{ resource, pathname, ...rest },
|
|
@@ -1705,41 +1707,22 @@ const getPropertiesFromPathname = ({ pathname, baseUrl }) => {
|
|
|
1705
1707
|
});
|
|
1706
1708
|
};
|
|
1707
1709
|
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
)
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
parent: request,
|
|
1716
|
-
signal,
|
|
1717
|
-
http2: true,
|
|
1718
|
-
...(pathname
|
|
1719
|
-
? getPropertiesFromPathname({
|
|
1720
|
-
pathname,
|
|
1721
|
-
baseUrl: request.url,
|
|
1722
|
-
})
|
|
1723
|
-
: {}),
|
|
1724
|
-
method: method || request.method,
|
|
1725
|
-
headers: getHeadersInheritedByPushRequest(request),
|
|
1726
|
-
body: undefined,
|
|
1727
|
-
});
|
|
1728
|
-
return pushRequest;
|
|
1710
|
+
// An HTTP/1.1 reason phrase allows HTAB, SP, VCHAR and obs-text (0x80-0xFF),
|
|
1711
|
+
// see RFC 9112 section 4. Node's writeHead and the Response constructor both
|
|
1712
|
+
// throw on anything else. A status text often echoes a request (a file path
|
|
1713
|
+
// decoded from the url for instance), so the characters outside that set are
|
|
1714
|
+
// percent-encoded rather than dropped: the text stays readable and reversible.
|
|
1715
|
+
const asReasonPhrase = (value) => {
|
|
1716
|
+
return String(value).replace(/[^\t\x20-\x7e\x80-\xff]/g, encodeChar);
|
|
1729
1717
|
};
|
|
1730
1718
|
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
// eTag sent by the client in request headers concerns the main request
|
|
1739
|
-
// A request made to an other resource must not inherit the eTag
|
|
1740
|
-
delete headersInherited["if-none-match"];
|
|
1741
|
-
|
|
1742
|
-
return headersInherited;
|
|
1719
|
+
const encodeChar = (char) => {
|
|
1720
|
+
try {
|
|
1721
|
+
return encodeURIComponent(char);
|
|
1722
|
+
} catch {
|
|
1723
|
+
// lone surrogate
|
|
1724
|
+
return "%3F";
|
|
1725
|
+
}
|
|
1743
1726
|
};
|
|
1744
1727
|
|
|
1745
1728
|
const isFileHandle = (value) => {
|
|
@@ -1751,23 +1734,7 @@ const observableFromFileHandle = (fileHandle) => {
|
|
|
1751
1734
|
};
|
|
1752
1735
|
|
|
1753
1736
|
const fileHandleToReadableStream = (fileHandle) => {
|
|
1754
|
-
|
|
1755
|
-
typeof fileHandle.createReadStream === "function"
|
|
1756
|
-
? fileHandle.createReadStream()
|
|
1757
|
-
: createReadStream(
|
|
1758
|
-
"/toto", // is it ok to pass a fake path like this?
|
|
1759
|
-
{
|
|
1760
|
-
fd: fileHandle.fd,
|
|
1761
|
-
emitClose: true,
|
|
1762
|
-
// autoClose: true
|
|
1763
|
-
},
|
|
1764
|
-
);
|
|
1765
|
-
// I suppose it's required only when doing fs.createReadStream()
|
|
1766
|
-
// and not fileHandle.createReadStream()
|
|
1767
|
-
// fileReadableStream.on("end", () => {
|
|
1768
|
-
// fileHandle.close()
|
|
1769
|
-
// })
|
|
1770
|
-
return fileReadableStream;
|
|
1737
|
+
return fileHandle.createReadStream();
|
|
1771
1738
|
};
|
|
1772
1739
|
|
|
1773
1740
|
const getObservableValueType = (value) => {
|
|
@@ -1884,22 +1851,9 @@ const observableFromValue = (value) => {
|
|
|
1884
1851
|
|
|
1885
1852
|
const writeNodeResponse = async (
|
|
1886
1853
|
responseStream,
|
|
1887
|
-
{ status, statusText, headers, body
|
|
1854
|
+
{ status, statusText, headers, body },
|
|
1888
1855
|
{ signal, ignoreBody, onAbort, onError, onHeadersSent, onEnd } = {},
|
|
1889
1856
|
) => {
|
|
1890
|
-
const isNetSocket = responseStream instanceof Socket;
|
|
1891
|
-
if (
|
|
1892
|
-
body &&
|
|
1893
|
-
body.isObservableBody &&
|
|
1894
|
-
headers["connection"] === undefined &&
|
|
1895
|
-
headers["content-length"] === undefined
|
|
1896
|
-
) {
|
|
1897
|
-
headers["transfer-encoding"] = "chunked";
|
|
1898
|
-
}
|
|
1899
|
-
// if (body && headers["content-length"] === undefined) {
|
|
1900
|
-
// headers["transfer-encoding"] = "chunked";
|
|
1901
|
-
// }
|
|
1902
|
-
|
|
1903
1857
|
const bodyObservableType = getObservableValueType(body);
|
|
1904
1858
|
const destroyBody = () => {
|
|
1905
1859
|
if (bodyObservableType === "file_handle") {
|
|
@@ -1943,46 +1897,8 @@ const writeNodeResponse = async (
|
|
|
1943
1897
|
return;
|
|
1944
1898
|
}
|
|
1945
1899
|
|
|
1946
|
-
if (bodyEncoding && !isNetSocket) {
|
|
1947
|
-
responseStream.setEncoding(bodyEncoding);
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
1900
|
await new Promise((resolve) => {
|
|
1951
|
-
const observable = observableFromValue(body);
|
|
1952
1901
|
const abortController = new AbortController();
|
|
1953
|
-
signal.addEventListener("abort", () => {
|
|
1954
|
-
abortController.abort();
|
|
1955
|
-
});
|
|
1956
|
-
observable.subscribe(
|
|
1957
|
-
{
|
|
1958
|
-
next: (data) => {
|
|
1959
|
-
try {
|
|
1960
|
-
responseStream.write(data);
|
|
1961
|
-
} catch (e) {
|
|
1962
|
-
// Something inside Node.js sometimes puts stream
|
|
1963
|
-
// in a state where .write() throw despites nodeResponse.destroyed
|
|
1964
|
-
// being undefined and "close" event not being emitted.
|
|
1965
|
-
// I have tested if we are the one calling destroy
|
|
1966
|
-
// (I have commented every .destroy() call)
|
|
1967
|
-
// but issue still occurs
|
|
1968
|
-
// For the record it's "hard" to reproduce but can be by running
|
|
1969
|
-
// a lot of tests against a browser in the context of @jsenv/core testing
|
|
1970
|
-
if (e.code === "ERR_HTTP2_INVALID_STREAM") {
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
responseStream.emit("error", e);
|
|
1974
|
-
}
|
|
1975
|
-
},
|
|
1976
|
-
error: (value) => {
|
|
1977
|
-
responseStream.emit("error", value);
|
|
1978
|
-
},
|
|
1979
|
-
complete: () => {
|
|
1980
|
-
responseStream.end();
|
|
1981
|
-
},
|
|
1982
|
-
},
|
|
1983
|
-
{ signal: abortController.signal },
|
|
1984
|
-
);
|
|
1985
|
-
|
|
1986
1902
|
raceCallbacks(
|
|
1987
1903
|
{
|
|
1988
1904
|
abort: (cb) => {
|
|
@@ -2043,6 +1959,46 @@ const writeNodeResponse = async (
|
|
|
2043
1959
|
raceEffects[winner.name](winner.data);
|
|
2044
1960
|
},
|
|
2045
1961
|
);
|
|
1962
|
+
|
|
1963
|
+
// a string or a buffer is written and ended in one go
|
|
1964
|
+
if (typeof body === "string" || body instanceof Uint8Array) {
|
|
1965
|
+
responseStream.end(body);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const observable = observableFromValue(body);
|
|
1970
|
+
signal.addEventListener("abort", () => {
|
|
1971
|
+
abortController.abort();
|
|
1972
|
+
});
|
|
1973
|
+
observable.subscribe(
|
|
1974
|
+
{
|
|
1975
|
+
next: (data) => {
|
|
1976
|
+
try {
|
|
1977
|
+
responseStream.write(data);
|
|
1978
|
+
} catch (e) {
|
|
1979
|
+
// Something inside Node.js sometimes puts stream
|
|
1980
|
+
// in a state where .write() throw despites nodeResponse.destroyed
|
|
1981
|
+
// being undefined and "close" event not being emitted.
|
|
1982
|
+
// I have tested if we are the one calling destroy
|
|
1983
|
+
// (I have commented every .destroy() call)
|
|
1984
|
+
// but issue still occurs
|
|
1985
|
+
// For the record it's "hard" to reproduce but can be by running
|
|
1986
|
+
// a lot of tests against a browser in the context of @jsenv/core testing
|
|
1987
|
+
if (e.code === "ERR_HTTP2_INVALID_STREAM") {
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
responseStream.emit("error", e);
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
error: (value) => {
|
|
1994
|
+
responseStream.emit("error", value);
|
|
1995
|
+
},
|
|
1996
|
+
complete: () => {
|
|
1997
|
+
responseStream.end();
|
|
1998
|
+
},
|
|
1999
|
+
},
|
|
2000
|
+
{ signal: abortController.signal },
|
|
2001
|
+
);
|
|
2046
2002
|
});
|
|
2047
2003
|
};
|
|
2048
2004
|
|
|
@@ -2053,38 +2009,23 @@ const writeHead = (
|
|
|
2053
2009
|
const responseIsNetSocket = responseStream instanceof Socket;
|
|
2054
2010
|
const responseIsHttp2ServerResponse =
|
|
2055
2011
|
responseStream instanceof Http2ServerResponse;
|
|
2056
|
-
const
|
|
2057
|
-
responseStream.constructor.name === "ServerHttp2Stream";
|
|
2058
|
-
let nodeHeaders = headersToNodeHeaders(headers, {
|
|
2012
|
+
const nodeHeaders = headersToNodeHeaders(headers, {
|
|
2059
2013
|
// https://github.com/nodejs/node/blob/79296dc2d02c0b9872bbfcbb89148ea036a546d0/lib/internal/http2/compat.js#L112
|
|
2060
|
-
ignoreConnectionHeader:
|
|
2061
|
-
responseIsHttp2ServerResponse || responseIsServerHttp2Stream,
|
|
2014
|
+
ignoreConnectionHeader: responseIsHttp2ServerResponse,
|
|
2062
2015
|
});
|
|
2063
|
-
|
|
2064
|
-
statusText
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
nodeHeaders = {
|
|
2070
|
-
...nodeHeaders,
|
|
2071
|
-
":status": status,
|
|
2072
|
-
};
|
|
2073
|
-
responseStream.respond(nodeHeaders);
|
|
2074
|
-
onHeadersSent({ nodeHeaders, status, statusText });
|
|
2075
|
-
return;
|
|
2076
|
-
}
|
|
2077
|
-
// nodejs strange signature for writeHead force this
|
|
2078
|
-
// https://nodejs.org/api/http.html#http_response_writehead_statuscode_statusmessage_headers
|
|
2079
|
-
if (
|
|
2080
|
-
// https://github.com/nodejs/node/blob/79296dc2d02c0b9872bbfcbb89148ea036a546d0/lib/internal/http2/compat.js#L97
|
|
2081
|
-
responseIsHttp2ServerResponse
|
|
2082
|
-
) {
|
|
2016
|
+
statusText =
|
|
2017
|
+
statusText === undefined
|
|
2018
|
+
? statusTextFromStatus(status)
|
|
2019
|
+
: asReasonPhrase(statusText);
|
|
2020
|
+
if (responseIsHttp2ServerResponse) {
|
|
2021
|
+
// http2 has no reason phrase: statusText only reaches the logs
|
|
2083
2022
|
responseStream.writeHead(status, nodeHeaders);
|
|
2084
2023
|
onHeadersSent({ nodeHeaders, status, statusText });
|
|
2085
2024
|
return;
|
|
2086
2025
|
}
|
|
2087
2026
|
if (responseIsNetSocket) {
|
|
2027
|
+
// a websocket upgrade request answered with something else than 101:
|
|
2028
|
+
// the response is written by hand on the socket
|
|
2088
2029
|
const headersString = Object.keys(nodeHeaders)
|
|
2089
2030
|
.map((h) => `${h}: ${nodeHeaders[h]}`)
|
|
2090
2031
|
.join("\r\n");
|
|
@@ -2094,20 +2035,7 @@ const writeHead = (
|
|
|
2094
2035
|
onHeadersSent({ nodeHeaders, status, statusText });
|
|
2095
2036
|
return;
|
|
2096
2037
|
}
|
|
2097
|
-
|
|
2098
|
-
try {
|
|
2099
|
-
responseStream.writeHead(status, statusText, nodeHeaders);
|
|
2100
|
-
} catch (e) {
|
|
2101
|
-
if (
|
|
2102
|
-
e.code === "ERR_INVALID_CHAR" &&
|
|
2103
|
-
e.message.includes("Invalid character in statusMessage")
|
|
2104
|
-
) {
|
|
2105
|
-
throw new Error(`Invalid character in statusMessage
|
|
2106
|
-
--- status message ---
|
|
2107
|
-
${statusText}`);
|
|
2108
|
-
}
|
|
2109
|
-
throw e;
|
|
2110
|
-
}
|
|
2038
|
+
responseStream.writeHead(status, statusText, nodeHeaders);
|
|
2111
2039
|
onHeadersSent({ nodeHeaders, status, statusText });
|
|
2112
2040
|
};
|
|
2113
2041
|
|
|
@@ -2116,21 +2044,15 @@ const statusTextFromStatus = (status) =>
|
|
|
2116
2044
|
|
|
2117
2045
|
const headersToNodeHeaders = (headers, { ignoreConnectionHeader }) => {
|
|
2118
2046
|
const nodeHeaders = {};
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
nodeHeaders[
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2047
|
+
for (const name of Object.keys(headers)) {
|
|
2048
|
+
if (name === "connection" && ignoreConnectionHeader) {
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
2051
|
+
nodeHeaders[name] = headers[name];
|
|
2052
|
+
}
|
|
2126
2053
|
return nodeHeaders;
|
|
2127
2054
|
};
|
|
2128
2055
|
|
|
2129
|
-
const mapping = {
|
|
2130
|
-
// "content-type": "Content-Type",
|
|
2131
|
-
// "last-modified": "Last-Modified",
|
|
2132
|
-
};
|
|
2133
|
-
|
|
2134
2056
|
const composeTwoObjects = (
|
|
2135
2057
|
firstObject,
|
|
2136
2058
|
secondObject,
|
|
@@ -2340,27 +2262,111 @@ const HEADER_NAMES_COMPOSITION = {
|
|
|
2340
2262
|
},
|
|
2341
2263
|
};
|
|
2342
2264
|
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2265
|
+
// The Host header of a request is compared to the names the server can
|
|
2266
|
+
// legitimately be reached at. Without this a page served by another site
|
|
2267
|
+
// reads the responses once its DNS name is rebound to this machine (DNS
|
|
2268
|
+
// rebinding): the request then comes from the developer's own browser, so
|
|
2269
|
+
// listening on localhost does not help, only the Host name tells.
|
|
2270
|
+
const createHostChecker = ({
|
|
2271
|
+
allowedHosts,
|
|
2272
|
+
hostname: hostname$1,
|
|
2273
|
+
serverOrigins,
|
|
2274
|
+
acceptAnyIp,
|
|
2349
2275
|
}) => {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
listeningOperation.addAbortSignal(signal);
|
|
2276
|
+
if (allowedHosts === true) {
|
|
2277
|
+
return () => true;
|
|
2278
|
+
}
|
|
2354
2279
|
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2280
|
+
const allowedHostSet = new Set();
|
|
2281
|
+
const allowedHostSuffixes = [];
|
|
2282
|
+
const allow = (host) => {
|
|
2283
|
+
if (host.startsWith(".")) {
|
|
2284
|
+
// ".example.com" allows example.com and every subdomain
|
|
2285
|
+
allowedHostSuffixes.push(host.toLowerCase());
|
|
2286
|
+
allowedHostSet.add(host.slice(1).toLowerCase());
|
|
2287
|
+
return;
|
|
2361
2288
|
}
|
|
2362
|
-
|
|
2363
|
-
|
|
2289
|
+
const hostNormalized = normalizeHost(host);
|
|
2290
|
+
if (hostNormalized) {
|
|
2291
|
+
allowedHostSet.add(hostNormalized);
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
allow("localhost");
|
|
2296
|
+
allow("127.0.0.1");
|
|
2297
|
+
allow("[::1]");
|
|
2298
|
+
allow(hostname$1);
|
|
2299
|
+
for (const origin of Object.values(serverOrigins)) {
|
|
2300
|
+
allow(new URL(origin).hostname);
|
|
2301
|
+
}
|
|
2302
|
+
// the name other machines use for this one (mDNS gives "name.local")
|
|
2303
|
+
const machineName = hostname();
|
|
2304
|
+
allow(machineName);
|
|
2305
|
+
if (!machineName.endsWith(".local")) {
|
|
2306
|
+
allow(`${machineName}.local`);
|
|
2307
|
+
}
|
|
2308
|
+
if (acceptAnyIp) {
|
|
2309
|
+
for (const addresses of Object.values(networkInterfaces())) {
|
|
2310
|
+
for (const { address, family } of addresses) {
|
|
2311
|
+
allow(family === "IPv6" || family === 6 ? `[${address}]` : address);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
for (const allowedHost of allowedHosts) {
|
|
2316
|
+
allow(allowedHost);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
return (host) => {
|
|
2320
|
+
if (host === undefined) {
|
|
2321
|
+
// an http/1.0 client; a browser always sends the header
|
|
2322
|
+
return true;
|
|
2323
|
+
}
|
|
2324
|
+
const hostNormalized = normalizeHost(host);
|
|
2325
|
+
if (hostNormalized === null) {
|
|
2326
|
+
return false;
|
|
2327
|
+
}
|
|
2328
|
+
if (allowedHostSet.has(hostNormalized)) {
|
|
2329
|
+
return true;
|
|
2330
|
+
}
|
|
2331
|
+
for (const suffix of allowedHostSuffixes) {
|
|
2332
|
+
if (hostNormalized.endsWith(suffix)) {
|
|
2333
|
+
return true;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
return false;
|
|
2337
|
+
};
|
|
2338
|
+
};
|
|
2339
|
+
|
|
2340
|
+
// "example.com:3456" or "[::1]:3456": the port is not part of the name
|
|
2341
|
+
const normalizeHost = (host) => {
|
|
2342
|
+
const url = `http://${host}`;
|
|
2343
|
+
if (!URL.canParse(url)) {
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
return new URL(url).hostname.toLowerCase();
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
const listen = async ({
|
|
2350
|
+
signal = new AbortController().signal,
|
|
2351
|
+
server,
|
|
2352
|
+
port,
|
|
2353
|
+
portHint,
|
|
2354
|
+
hostname,
|
|
2355
|
+
}) => {
|
|
2356
|
+
const listeningOperation = Abort.startOperation();
|
|
2357
|
+
|
|
2358
|
+
try {
|
|
2359
|
+
listeningOperation.addAbortSignal(signal);
|
|
2360
|
+
|
|
2361
|
+
if (portHint) {
|
|
2362
|
+
listeningOperation.throwIfAborted();
|
|
2363
|
+
port = await findFreePort(portHint, {
|
|
2364
|
+
signal: listeningOperation.signal,
|
|
2365
|
+
hostname,
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
listeningOperation.throwIfAborted();
|
|
2369
|
+
port = await startListening({ server, port, hostname });
|
|
2364
2370
|
listeningOperation.addAbortCallback(() => stopListening(server));
|
|
2365
2371
|
listeningOperation.throwIfAborted();
|
|
2366
2372
|
|
|
@@ -2370,6 +2376,19 @@ const listen = async ({
|
|
|
2370
2376
|
}
|
|
2371
2377
|
};
|
|
2372
2378
|
|
|
2379
|
+
/**
|
|
2380
|
+
* Find a port nobody listens to on the given hostname, trying `initialPort`
|
|
2381
|
+
* first then the following ones.
|
|
2382
|
+
*
|
|
2383
|
+
* @param {number} [initialPort=1] - First port to try.
|
|
2384
|
+
* @param {Object} [options]
|
|
2385
|
+
* @param {AbortSignal} [options.signal]
|
|
2386
|
+
* @param {string} [options.hostname="127.0.0.1"] - Interface the port must be free on.
|
|
2387
|
+
* @param {number} [options.min=1]
|
|
2388
|
+
* @param {number} [options.max=65534] - Give up (throw) past this port.
|
|
2389
|
+
* @param {(port: number) => number} [options.next] - How to pick the next port to try.
|
|
2390
|
+
* @returns {Promise<number>}
|
|
2391
|
+
*/
|
|
2373
2392
|
const findFreePort = async (
|
|
2374
2393
|
initialPort = 1,
|
|
2375
2394
|
{
|
|
@@ -2432,20 +2451,34 @@ const portIsFree = async (port, hostname) => {
|
|
|
2432
2451
|
|
|
2433
2452
|
const startListening = ({ server, port, hostname }) => {
|
|
2434
2453
|
return new Promise((resolve, reject) => {
|
|
2435
|
-
|
|
2436
|
-
|
|
2454
|
+
const onError = (error) => {
|
|
2455
|
+
server.removeListener("listening", onListening);
|
|
2456
|
+
reject(error);
|
|
2457
|
+
};
|
|
2458
|
+
const onListening = () => {
|
|
2459
|
+
server.removeListener("error", onError);
|
|
2437
2460
|
// in case port is 0 (randomly assign an available port)
|
|
2438
2461
|
// https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback
|
|
2439
2462
|
resolve(server.address().port);
|
|
2440
|
-
}
|
|
2463
|
+
};
|
|
2464
|
+
server.once("error", onError);
|
|
2465
|
+
server.once("listening", onListening);
|
|
2441
2466
|
server.listen(port, hostname);
|
|
2442
2467
|
});
|
|
2443
2468
|
};
|
|
2444
2469
|
|
|
2445
2470
|
const stopListening = (server) => {
|
|
2446
2471
|
return new Promise((resolve, reject) => {
|
|
2447
|
-
|
|
2448
|
-
|
|
2472
|
+
const onError = (error) => {
|
|
2473
|
+
server.removeListener("close", onClose);
|
|
2474
|
+
reject(error);
|
|
2475
|
+
};
|
|
2476
|
+
const onClose = () => {
|
|
2477
|
+
server.removeListener("error", onError);
|
|
2478
|
+
resolve();
|
|
2479
|
+
};
|
|
2480
|
+
server.once("error", onError);
|
|
2481
|
+
server.once("close", onClose);
|
|
2449
2482
|
server.close();
|
|
2450
2483
|
});
|
|
2451
2484
|
};
|
|
@@ -2541,12 +2574,22 @@ const asResponseProperties = (value) => {
|
|
|
2541
2574
|
statusText: value.statusText,
|
|
2542
2575
|
headers: Object.fromEntries(value.headers),
|
|
2543
2576
|
body: value.body,
|
|
2544
|
-
bodyEncoding: value.bodyEncoding,
|
|
2545
2577
|
};
|
|
2546
2578
|
}
|
|
2547
2579
|
return value;
|
|
2548
2580
|
};
|
|
2549
2581
|
|
|
2582
|
+
/**
|
|
2583
|
+
* Merge two responses into one. Each can be a `Response` instance or a plain
|
|
2584
|
+
* `{ status, statusText, statusMessage, headers, body, timing }` object.
|
|
2585
|
+
* The second response wins for status, statusText, statusMessage and body;
|
|
2586
|
+
* headers are composed (list headers such as `vary` or `allow` accumulate
|
|
2587
|
+
* their values, `set-cookie` becomes an array) and `timing` objects are merged.
|
|
2588
|
+
*
|
|
2589
|
+
* @param {Response|Object} firstResponse
|
|
2590
|
+
* @param {Response|Object} secondResponse
|
|
2591
|
+
* @returns {Object} Plain response properties.
|
|
2592
|
+
*/
|
|
2550
2593
|
const composeTwoResponses = (firstResponse, secondResponse) => {
|
|
2551
2594
|
firstResponse = asResponseProperties(firstResponse);
|
|
2552
2595
|
secondResponse = asResponseProperties(secondResponse);
|
|
@@ -2563,17 +2606,227 @@ const RESPONSE_KEYS_COMPOSITION = {
|
|
|
2563
2606
|
statusMessage: (prevStatusMessage, statusMessage) => statusMessage,
|
|
2564
2607
|
headers: composeTwoHeaders,
|
|
2565
2608
|
body: (prevBody, body) => body,
|
|
2566
|
-
bodyEncoding: (prevEncoding, encoding) => encoding,
|
|
2567
2609
|
// measures a response hands back, merged into the server-timing header when
|
|
2568
2610
|
// the server has serverTiming enabled (see finalizeResponseProperties)
|
|
2569
2611
|
timing: (prevTiming, timing) => ({ ...prevTiming, ...timing }),
|
|
2570
2612
|
};
|
|
2571
2613
|
|
|
2572
|
-
|
|
2614
|
+
// The imports are dynamic so that "node:http2" / "node:https" are only parsed
|
|
2615
|
+
// when a secure server is actually created.
|
|
2616
|
+
const createSecureServer = async ({
|
|
2617
|
+
certificate,
|
|
2618
|
+
privateKey,
|
|
2619
|
+
http2,
|
|
2620
|
+
http1Allowed,
|
|
2621
|
+
}) => {
|
|
2622
|
+
if (http2) {
|
|
2623
|
+
const { createSecureServer } = await import("node:http2");
|
|
2624
|
+
return createSecureServer({
|
|
2625
|
+
cert: certificate,
|
|
2626
|
+
key: privateKey,
|
|
2627
|
+
allowHTTP1: http1Allowed,
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
const { createServer } = await import("node:https");
|
|
2631
|
+
return createServer({
|
|
2632
|
+
cert: certificate,
|
|
2633
|
+
key: privateKey,
|
|
2634
|
+
});
|
|
2635
|
+
};
|
|
2573
2636
|
|
|
2574
|
-
|
|
2637
|
+
const applyDnsResolution = async (
|
|
2638
|
+
hostname,
|
|
2639
|
+
{ verbatim = false } = {},
|
|
2640
|
+
) => {
|
|
2641
|
+
const dnsResolution = await new Promise((resolve, reject) => {
|
|
2642
|
+
lookup(hostname, { verbatim }, (error, address, family) => {
|
|
2643
|
+
if (error) {
|
|
2644
|
+
reject(error);
|
|
2645
|
+
} else {
|
|
2646
|
+
resolve({ address, family });
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
});
|
|
2650
|
+
return dnsResolution;
|
|
2651
|
+
};
|
|
2575
2652
|
|
|
2576
|
-
|
|
2653
|
+
const parseHostname = (hostname) => {
|
|
2654
|
+
if (hostname === "0.0.0.0") {
|
|
2655
|
+
return {
|
|
2656
|
+
type: "ip",
|
|
2657
|
+
label: "unspecified",
|
|
2658
|
+
version: 4,
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
if (
|
|
2662
|
+
hostname === "::" ||
|
|
2663
|
+
hostname === "0000:0000:0000:0000:0000:0000:0000:0000"
|
|
2664
|
+
) {
|
|
2665
|
+
return {
|
|
2666
|
+
type: "ip",
|
|
2667
|
+
label: "unspecified",
|
|
2668
|
+
version: 6,
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
if (hostname === "127.0.0.1") {
|
|
2672
|
+
return {
|
|
2673
|
+
type: "ip",
|
|
2674
|
+
label: "loopback",
|
|
2675
|
+
version: 4,
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
if (
|
|
2679
|
+
hostname === "::1" ||
|
|
2680
|
+
hostname === "0000:0000:0000:0000:0000:0000:0000:0001"
|
|
2681
|
+
) {
|
|
2682
|
+
return {
|
|
2683
|
+
type: "ip",
|
|
2684
|
+
label: "loopback",
|
|
2685
|
+
version: 6,
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
const ipVersion = isIP(hostname);
|
|
2689
|
+
if (ipVersion === 0) {
|
|
2690
|
+
return {
|
|
2691
|
+
type: "hostname",
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
return {
|
|
2695
|
+
type: "ip",
|
|
2696
|
+
version: ipVersion,
|
|
2697
|
+
};
|
|
2698
|
+
};
|
|
2699
|
+
|
|
2700
|
+
const createIpGetters = () => {
|
|
2701
|
+
const networkAddresses = [];
|
|
2702
|
+
const networkInterfaceMap = networkInterfaces();
|
|
2703
|
+
for (const key of Object.keys(networkInterfaceMap)) {
|
|
2704
|
+
for (const networkAddress of networkInterfaceMap[key]) {
|
|
2705
|
+
networkAddresses.push(networkAddress);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
return {
|
|
2709
|
+
getFirstInternalIp: ({ preferIpv6 }) => {
|
|
2710
|
+
const isPref = preferIpv6 ? isIpV6 : isIpV4;
|
|
2711
|
+
let firstInternalIp;
|
|
2712
|
+
for (const networkAddress of networkAddresses) {
|
|
2713
|
+
if (networkAddress.internal) {
|
|
2714
|
+
firstInternalIp = networkAddress.address;
|
|
2715
|
+
if (isPref(networkAddress)) {
|
|
2716
|
+
break;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
return firstInternalIp;
|
|
2721
|
+
},
|
|
2722
|
+
getFirstExternalIp: ({ preferIpv6 }) => {
|
|
2723
|
+
const isPref = preferIpv6 ? isIpV6 : isIpV4;
|
|
2724
|
+
let firstExternalIp;
|
|
2725
|
+
for (const networkAddress of networkAddresses) {
|
|
2726
|
+
if (!networkAddress.internal) {
|
|
2727
|
+
firstExternalIp = networkAddress.address;
|
|
2728
|
+
if (isPref(networkAddress)) {
|
|
2729
|
+
break;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
return firstExternalIp;
|
|
2734
|
+
},
|
|
2735
|
+
};
|
|
2736
|
+
};
|
|
2737
|
+
|
|
2738
|
+
const isIpV4 = (networkAddress) => {
|
|
2739
|
+
// node 18.5
|
|
2740
|
+
if (typeof networkAddress.family === "number") {
|
|
2741
|
+
return networkAddress.family === 4;
|
|
2742
|
+
}
|
|
2743
|
+
return networkAddress.family === "IPv4";
|
|
2744
|
+
};
|
|
2745
|
+
|
|
2746
|
+
const isIpV6 = (networkAddress) => !isIpV4(networkAddress);
|
|
2747
|
+
|
|
2748
|
+
// Decides what the server listens to and the origins it can be reached at:
|
|
2749
|
+
// - local: favors the hostname (an https certificate is for a hostname, not an ip)
|
|
2750
|
+
// - localip: the ip behind the hostname
|
|
2751
|
+
// - externalip: the machine ip on the network, when the server accepts it
|
|
2752
|
+
// The port is not known yet: the origins are completed once listening.
|
|
2753
|
+
const resolveServerOrigins = async ({
|
|
2754
|
+
https,
|
|
2755
|
+
hostname,
|
|
2756
|
+
acceptAnyIp,
|
|
2757
|
+
preferIpv6,
|
|
2758
|
+
}) => {
|
|
2759
|
+
const createOrigin = (host) => {
|
|
2760
|
+
const protocol = https ? "https" : "http";
|
|
2761
|
+
if (isIP(host) === 6) {
|
|
2762
|
+
return `${protocol}://[${host}]`;
|
|
2763
|
+
}
|
|
2764
|
+
return `${protocol}://${host}`;
|
|
2765
|
+
};
|
|
2766
|
+
|
|
2767
|
+
const serverOrigins = {
|
|
2768
|
+
local: "",
|
|
2769
|
+
};
|
|
2770
|
+
const ipGetters = createIpGetters();
|
|
2771
|
+
let hostnameToListen;
|
|
2772
|
+
if (acceptAnyIp) {
|
|
2773
|
+
const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
|
|
2774
|
+
serverOrigins.local = createOrigin(firstInternalIp);
|
|
2775
|
+
serverOrigins.localip = createOrigin(firstInternalIp);
|
|
2776
|
+
const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
|
|
2777
|
+
serverOrigins.externalip = createOrigin(firstExternalIp);
|
|
2778
|
+
hostnameToListen = preferIpv6 ? "::" : "0.0.0.0";
|
|
2779
|
+
} else {
|
|
2780
|
+
hostnameToListen = hostname;
|
|
2781
|
+
}
|
|
2782
|
+
const hostnameInfo = parseHostname(hostname);
|
|
2783
|
+
if (hostnameInfo.type === "ip") {
|
|
2784
|
+
if (acceptAnyIp) {
|
|
2785
|
+
throw new Error(
|
|
2786
|
+
`hostname cannot be an ip when acceptAnyIp is enabled, got ${hostname}`,
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
preferIpv6 = hostnameInfo.version === 6;
|
|
2791
|
+
const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
|
|
2792
|
+
serverOrigins.local = createOrigin(firstInternalIp);
|
|
2793
|
+
serverOrigins.localip = createOrigin(firstInternalIp);
|
|
2794
|
+
if (hostnameInfo.label === "unspecified") {
|
|
2795
|
+
const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
|
|
2796
|
+
serverOrigins.externalip = createOrigin(firstExternalIp);
|
|
2797
|
+
} else if (hostnameInfo.label === "loopback") ; else {
|
|
2798
|
+
serverOrigins.local = createOrigin(hostname);
|
|
2799
|
+
}
|
|
2800
|
+
} else {
|
|
2801
|
+
const hostnameDnsResolution = await applyDnsResolution(hostname, {
|
|
2802
|
+
verbatim: true,
|
|
2803
|
+
});
|
|
2804
|
+
if (hostnameDnsResolution) {
|
|
2805
|
+
const hostnameIp = hostnameDnsResolution.address;
|
|
2806
|
+
serverOrigins.localip = createOrigin(hostnameIp);
|
|
2807
|
+
serverOrigins.local = createOrigin(hostname);
|
|
2808
|
+
} else {
|
|
2809
|
+
const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
|
|
2810
|
+
// fallback to internal ip because there is no ip
|
|
2811
|
+
// associated to this hostname on operating system (in hosts file)
|
|
2812
|
+
hostname = firstInternalIp;
|
|
2813
|
+
hostnameToListen = firstInternalIp;
|
|
2814
|
+
serverOrigins.local = createOrigin(firstInternalIp);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
return { hostname, hostnameToListen, serverOrigins };
|
|
2818
|
+
};
|
|
2819
|
+
|
|
2820
|
+
/*
|
|
2821
|
+
* A net server accepting both http and tls on the same port: the first byte
|
|
2822
|
+
* of each connection tells which one it is (0x16 is a TLS handshake) and the
|
|
2823
|
+
* socket is handed to the matching http or tls server.
|
|
2824
|
+
* https://stackoverflow.com/a/42019773/2634179
|
|
2825
|
+
*
|
|
2826
|
+
* The http and tls servers are exposed as _httpServer/_tlsServer because
|
|
2827
|
+
* "request", "upgrade" and "clientError" are emitted on them, not on the net
|
|
2828
|
+
* server (see listen_request.js).
|
|
2829
|
+
*/
|
|
2577
2830
|
|
|
2578
2831
|
|
|
2579
2832
|
const createPolyglotServer = async ({
|
|
@@ -2628,38 +2881,11 @@ const createPolyglotServer = async ({
|
|
|
2628
2881
|
return netServer;
|
|
2629
2882
|
};
|
|
2630
2883
|
|
|
2631
|
-
// The async part is just to lazyly import "http2" or "https"
|
|
2632
|
-
// so that these module are parsed only if used.
|
|
2633
|
-
// https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions
|
|
2634
|
-
const createSecureServer = async ({
|
|
2635
|
-
certificate,
|
|
2636
|
-
privateKey,
|
|
2637
|
-
http2,
|
|
2638
|
-
http1Allowed,
|
|
2639
|
-
}) => {
|
|
2640
|
-
if (http2) {
|
|
2641
|
-
const { createSecureServer } = await import("node:http2");
|
|
2642
|
-
return createSecureServer({
|
|
2643
|
-
cert: certificate,
|
|
2644
|
-
key: privateKey,
|
|
2645
|
-
allowHTTP1: http1Allowed,
|
|
2646
|
-
});
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
const { createServer } = await import("node:https");
|
|
2650
|
-
return createServer({
|
|
2651
|
-
cert: certificate,
|
|
2652
|
-
key: privateKey,
|
|
2653
|
-
});
|
|
2654
|
-
};
|
|
2655
|
-
|
|
2656
2884
|
const detectSocketProtocol = (socket, protocolDetectedCallback) => {
|
|
2657
|
-
let removeOnceReadableListener = () => {};
|
|
2658
|
-
|
|
2659
2885
|
const tryToRead = () => {
|
|
2660
2886
|
const buffer = socket.read(1);
|
|
2661
2887
|
if (buffer === null) {
|
|
2662
|
-
|
|
2888
|
+
socket.once("readable", tryToRead);
|
|
2663
2889
|
return;
|
|
2664
2890
|
}
|
|
2665
2891
|
|
|
@@ -2677,23 +2903,9 @@ const detectSocketProtocol = (socket, protocolDetectedCallback) => {
|
|
|
2677
2903
|
};
|
|
2678
2904
|
|
|
2679
2905
|
tryToRead();
|
|
2680
|
-
|
|
2681
|
-
return () => {
|
|
2682
|
-
removeOnceReadableListener();
|
|
2683
|
-
};
|
|
2684
|
-
};
|
|
2685
|
-
|
|
2686
|
-
const trackServerPendingConnections = (nodeServer, { http2 }) => {
|
|
2687
|
-
if (http2) {
|
|
2688
|
-
// see http2.js: we rely on https://nodejs.org/api/http2.html#http2_compatibility_api
|
|
2689
|
-
return trackHttp1ServerPendingConnections(nodeServer);
|
|
2690
|
-
}
|
|
2691
|
-
return trackHttp1ServerPendingConnections(nodeServer);
|
|
2692
2906
|
};
|
|
2693
2907
|
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
const trackHttp1ServerPendingConnections = (nodeServer) => {
|
|
2908
|
+
const trackServerPendingConnections = (nodeServer) => {
|
|
2697
2909
|
const pendingConnections = new Set();
|
|
2698
2910
|
|
|
2699
2911
|
const removeConnectionListener = listenEvent(
|
|
@@ -2716,10 +2928,9 @@ const trackHttp1ServerPendingConnections = (nodeServer) => {
|
|
|
2716
2928
|
removeConnectionListener();
|
|
2717
2929
|
const pendingConnectionsArray = Array.from(pendingConnections);
|
|
2718
2930
|
pendingConnections.clear();
|
|
2719
|
-
|
|
2720
2931
|
await Promise.all(
|
|
2721
|
-
pendingConnectionsArray.map(
|
|
2722
|
-
|
|
2932
|
+
pendingConnectionsArray.map((pendingConnection) => {
|
|
2933
|
+
return destroyConnection(pendingConnection, reason);
|
|
2723
2934
|
}),
|
|
2724
2935
|
);
|
|
2725
2936
|
};
|
|
@@ -2743,99 +2954,7 @@ const destroyConnection = (connection, reason) => {
|
|
|
2743
2954
|
});
|
|
2744
2955
|
};
|
|
2745
2956
|
|
|
2746
|
-
|
|
2747
|
-
// const pendingClients = new Set()
|
|
2748
|
-
|
|
2749
|
-
// const streamListener = (http2Stream, headers, flags) => {
|
|
2750
|
-
// const client = { http2Stream, headers, flags }
|
|
2751
|
-
|
|
2752
|
-
// pendingClients.add(client)
|
|
2753
|
-
// http2Stream.on("close", () => {
|
|
2754
|
-
// pendingClients.delete(client)
|
|
2755
|
-
// })
|
|
2756
|
-
// }
|
|
2757
|
-
|
|
2758
|
-
// nodeServer.on("stream", streamListener)
|
|
2759
|
-
|
|
2760
|
-
// const stop = ({
|
|
2761
|
-
// status,
|
|
2762
|
-
// // reason
|
|
2763
|
-
// }) => {
|
|
2764
|
-
// nodeServer.removeListener("stream", streamListener)
|
|
2765
|
-
|
|
2766
|
-
// return Promise.all(
|
|
2767
|
-
// Array.from(pendingClients).map(({ http2Stream }) => {
|
|
2768
|
-
// if (http2Stream.sentHeaders === false) {
|
|
2769
|
-
// http2Stream.respond({ ":status": status }, { endStream: true })
|
|
2770
|
-
// }
|
|
2771
|
-
|
|
2772
|
-
// return new Promise((resolve, reject) => {
|
|
2773
|
-
// if (http2Stream.closed) {
|
|
2774
|
-
// resolve()
|
|
2775
|
-
// } else {
|
|
2776
|
-
// http2Stream.close(NGHTTP2_NO_ERROR, (error) => {
|
|
2777
|
-
// if (error) {
|
|
2778
|
-
// reject(error)
|
|
2779
|
-
// } else {
|
|
2780
|
-
// resolve()
|
|
2781
|
-
// }
|
|
2782
|
-
// })
|
|
2783
|
-
// }
|
|
2784
|
-
// })
|
|
2785
|
-
// }),
|
|
2786
|
-
// )
|
|
2787
|
-
// }
|
|
2788
|
-
|
|
2789
|
-
// return { stop }
|
|
2790
|
-
// }
|
|
2791
|
-
|
|
2792
|
-
// export const trackServerPendingSessions = (nodeServer, { onSessionError }) => {
|
|
2793
|
-
// const pendingSessions = new Set()
|
|
2794
|
-
|
|
2795
|
-
// const sessionListener = (session) => {
|
|
2796
|
-
// session.on("close", () => {
|
|
2797
|
-
// pendingSessions.delete(session)
|
|
2798
|
-
// })
|
|
2799
|
-
// session.on("error", onSessionError)
|
|
2800
|
-
// pendingSessions.add(session)
|
|
2801
|
-
// }
|
|
2802
|
-
|
|
2803
|
-
// nodeServer.on("session", sessionListener)
|
|
2804
|
-
|
|
2805
|
-
// const stop = async (reason) => {
|
|
2806
|
-
// nodeServer.removeListener("session", sessionListener)
|
|
2807
|
-
|
|
2808
|
-
// await Promise.all(
|
|
2809
|
-
// Array.from(pendingSessions).map((pendingSession) => {
|
|
2810
|
-
// return new Promise((resolve, reject) => {
|
|
2811
|
-
// pendingSession.close((error) => {
|
|
2812
|
-
// if (error) {
|
|
2813
|
-
// if (error === reason || error.code === "ENOTCONN") {
|
|
2814
|
-
// resolve()
|
|
2815
|
-
// } else {
|
|
2816
|
-
// reject(error)
|
|
2817
|
-
// }
|
|
2818
|
-
// } else {
|
|
2819
|
-
// resolve()
|
|
2820
|
-
// }
|
|
2821
|
-
// })
|
|
2822
|
-
// })
|
|
2823
|
-
// }),
|
|
2824
|
-
// )
|
|
2825
|
-
// }
|
|
2826
|
-
|
|
2827
|
-
// return { stop }
|
|
2828
|
-
// }
|
|
2829
|
-
|
|
2830
|
-
const trackServerPendingRequests = (nodeServer, { http2 }) => {
|
|
2831
|
-
if (http2) {
|
|
2832
|
-
// see http2.js: we rely on https://nodejs.org/api/http2.html#http2_compatibility_api
|
|
2833
|
-
return trackHttp1ServerPendingRequests(nodeServer);
|
|
2834
|
-
}
|
|
2835
|
-
return trackHttp1ServerPendingRequests(nodeServer);
|
|
2836
|
-
};
|
|
2837
|
-
|
|
2838
|
-
const trackHttp1ServerPendingRequests = (nodeServer) => {
|
|
2957
|
+
const trackServerPendingRequests = (nodeServer) => {
|
|
2839
2958
|
const pendingClients = new Set();
|
|
2840
2959
|
|
|
2841
2960
|
const removeRequestListener = listenRequest(
|
|
@@ -2854,38 +2973,24 @@ const trackHttp1ServerPendingRequests = (nodeServer) => {
|
|
|
2854
2973
|
const pendingClientsArray = Array.from(pendingClients);
|
|
2855
2974
|
pendingClients.clear();
|
|
2856
2975
|
await Promise.all(
|
|
2857
|
-
pendingClientsArray.map(({ nodeResponse }) => {
|
|
2976
|
+
pendingClientsArray.map(({ nodeRequest, nodeResponse }) => {
|
|
2858
2977
|
if (nodeResponse.headersSent === false) {
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
if (nodeResponse.closed) {
|
|
2866
|
-
resolve();
|
|
2867
|
-
} else {
|
|
2868
|
-
nodeResponse.close((error) => {
|
|
2869
|
-
if (error) {
|
|
2870
|
-
reject(error);
|
|
2871
|
-
} else {
|
|
2872
|
-
resolve();
|
|
2873
|
-
}
|
|
2874
|
-
});
|
|
2875
|
-
}
|
|
2876
|
-
});
|
|
2978
|
+
if (nodeRequest.stream) {
|
|
2979
|
+
// http2 has no reason phrase
|
|
2980
|
+
nodeResponse.writeHead(status);
|
|
2981
|
+
} else {
|
|
2982
|
+
nodeResponse.writeHead(status, asReasonPhrase(reason));
|
|
2983
|
+
}
|
|
2877
2984
|
}
|
|
2878
|
-
|
|
2879
|
-
// http
|
|
2880
2985
|
return new Promise((resolve) => {
|
|
2881
2986
|
if (nodeResponse.destroyed) {
|
|
2882
2987
|
resolve();
|
|
2883
|
-
|
|
2884
|
-
nodeResponse.once("close", () => {
|
|
2885
|
-
resolve();
|
|
2886
|
-
});
|
|
2887
|
-
nodeResponse.destroy();
|
|
2988
|
+
return;
|
|
2888
2989
|
}
|
|
2990
|
+
nodeResponse.once("close", () => {
|
|
2991
|
+
resolve();
|
|
2992
|
+
});
|
|
2993
|
+
nodeResponse.destroy();
|
|
2889
2994
|
});
|
|
2890
2995
|
}),
|
|
2891
2996
|
);
|
|
@@ -3275,7 +3380,9 @@ const createServerEvents = ({
|
|
|
3275
3380
|
if (history) {
|
|
3276
3381
|
addEventToHistory(event);
|
|
3277
3382
|
}
|
|
3278
|
-
logger.debug(
|
|
3383
|
+
logger.debug(
|
|
3384
|
+
`send "${event.type}" event to ${clientArray.length} client(s)`,
|
|
3385
|
+
);
|
|
3279
3386
|
for (const client of clientArray) {
|
|
3280
3387
|
client.sendEvent(event);
|
|
3281
3388
|
}
|
|
@@ -3573,6 +3680,16 @@ const pickAcceptedContent = ({
|
|
|
3573
3680
|
return availableWithHighestScore;
|
|
3574
3681
|
};
|
|
3575
3682
|
|
|
3683
|
+
/**
|
|
3684
|
+
* Pick, among the media types the server can produce, the one the request
|
|
3685
|
+
* prefers according to its `accept` header (quality values honored, `text/*`
|
|
3686
|
+
* style wildcards understood).
|
|
3687
|
+
*
|
|
3688
|
+
* @param {{ headers: Object }} request - Anything carrying lowercased `headers`.
|
|
3689
|
+
* @param {Array<string>} availableContentTypes - In order of server preference: on a tie the first one wins.
|
|
3690
|
+
* @returns {string|null} The media type to respond with, `null` when the request
|
|
3691
|
+
* accepts none of them or has no `accept` header.
|
|
3692
|
+
*/
|
|
3576
3693
|
const pickContentType = (request, availableContentTypes) => {
|
|
3577
3694
|
const { headers = {} } = request;
|
|
3578
3695
|
const requestAcceptHeader = headers.accept;
|
|
@@ -3634,6 +3751,18 @@ const decomposeContentType = (fullType) => {
|
|
|
3634
3751
|
return [type, subtype];
|
|
3635
3752
|
};
|
|
3636
3753
|
|
|
3754
|
+
const HTML_ESCAPES = {
|
|
3755
|
+
"&": "&",
|
|
3756
|
+
"<": "<",
|
|
3757
|
+
">": ">",
|
|
3758
|
+
'"': """,
|
|
3759
|
+
"'": "'",
|
|
3760
|
+
};
|
|
3761
|
+
|
|
3762
|
+
const escapeHtml = (value) => {
|
|
3763
|
+
return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
|
3764
|
+
};
|
|
3765
|
+
|
|
3637
3766
|
const replacePlaceholdersInHtml = (html, replacers) => {
|
|
3638
3767
|
return html.replace(/\$\{(\w+)\}/g, (match, name) => {
|
|
3639
3768
|
const replacer = replacers[name];
|
|
@@ -3648,6 +3777,16 @@ const replacePlaceholdersInHtml = (html, replacers) => {
|
|
|
3648
3777
|
};
|
|
3649
3778
|
|
|
3650
3779
|
const clientErrorHtmlTemplateFileUrl = import.meta.resolve("./client/default_body_4xx_5xx/4xx.html");
|
|
3780
|
+
let clientErrorHtmlTemplate;
|
|
3781
|
+
const readClientErrorHtmlTemplate = () => {
|
|
3782
|
+
if (clientErrorHtmlTemplate === undefined) {
|
|
3783
|
+
clientErrorHtmlTemplate = readFileSync(
|
|
3784
|
+
new URL(clientErrorHtmlTemplateFileUrl),
|
|
3785
|
+
"utf8",
|
|
3786
|
+
);
|
|
3787
|
+
}
|
|
3788
|
+
return clientErrorHtmlTemplate;
|
|
3789
|
+
};
|
|
3651
3790
|
|
|
3652
3791
|
const serverPluginDefaultBody4xx5xx = () => {
|
|
3653
3792
|
return {
|
|
@@ -3657,10 +3796,7 @@ const serverPluginDefaultBody4xx5xx = () => {
|
|
|
3657
3796
|
if (responseProperties.body !== undefined) {
|
|
3658
3797
|
return null;
|
|
3659
3798
|
}
|
|
3660
|
-
if (responseProperties.status >= 400 && responseProperties.status <
|
|
3661
|
-
return generateBadStatusResponse(request, responseProperties);
|
|
3662
|
-
}
|
|
3663
|
-
if (responseProperties.status >= 500 && responseProperties.status < 600) {
|
|
3799
|
+
if (responseProperties.status >= 400 && responseProperties.status < 600) {
|
|
3664
3800
|
return generateBadStatusResponse(request, responseProperties);
|
|
3665
3801
|
}
|
|
3666
3802
|
return null;
|
|
@@ -3672,38 +3808,17 @@ const generateBadStatusResponse = (
|
|
|
3672
3808
|
request,
|
|
3673
3809
|
{ status, statusText, statusMessage },
|
|
3674
3810
|
) => {
|
|
3811
|
+
statusText = asReasonPhrase(statusText || STATUS_CODES[status] || "");
|
|
3675
3812
|
const contentTypeNegotiated = pickContentType(request, [
|
|
3676
3813
|
"text/html",
|
|
3677
3814
|
"text/plain",
|
|
3678
3815
|
"application/json",
|
|
3679
3816
|
]);
|
|
3680
3817
|
if (contentTypeNegotiated === "text/html") {
|
|
3681
|
-
const
|
|
3682
|
-
new URL(clientErrorHtmlTemplateFileUrl),
|
|
3683
|
-
"utf8",
|
|
3684
|
-
);
|
|
3685
|
-
if (statusMessage) {
|
|
3686
|
-
statusMessage = statusMessage.replace(/https?:\/\/\S+/g, (url) => {
|
|
3687
|
-
return `<a href="${url}">${url}</a>`;
|
|
3688
|
-
});
|
|
3689
|
-
statusMessage = statusMessage.replace(
|
|
3690
|
-
/(^|\s)(\/\S+)/g,
|
|
3691
|
-
(match, startOrSpace, resource) => {
|
|
3692
|
-
let end = "";
|
|
3693
|
-
if (resource[resource.length - 1] === ".") {
|
|
3694
|
-
resource = resource.slice(0, -1);
|
|
3695
|
-
end = ".";
|
|
3696
|
-
}
|
|
3697
|
-
return `${startOrSpace}<a href="${resource}">${resource}</a>${end}`;
|
|
3698
|
-
},
|
|
3699
|
-
);
|
|
3700
|
-
statusMessage = statusMessage.replace(/\r\n|\r|\n/g, "<br />");
|
|
3701
|
-
}
|
|
3702
|
-
|
|
3703
|
-
const html = replacePlaceholdersInHtml(htmlTemplate, {
|
|
3818
|
+
const html = replacePlaceholdersInHtml(readClientErrorHtmlTemplate(), {
|
|
3704
3819
|
status,
|
|
3705
|
-
statusText,
|
|
3706
|
-
statusMessage: statusMessage
|
|
3820
|
+
statusText: escapeHtml(statusText),
|
|
3821
|
+
statusMessage: statusMessage ? statusMessageToHtml(statusMessage) : "",
|
|
3707
3822
|
});
|
|
3708
3823
|
return new Response(html, {
|
|
3709
3824
|
headers: { "content-type": "text/html" },
|
|
@@ -3726,6 +3841,25 @@ const generateBadStatusResponse = (
|
|
|
3726
3841
|
);
|
|
3727
3842
|
};
|
|
3728
3843
|
|
|
3844
|
+
// A status message echoes the request (its url for instance): it is escaped
|
|
3845
|
+
// before urls and resources are turned into links.
|
|
3846
|
+
const statusMessageToHtml = (statusMessage) => {
|
|
3847
|
+
let html = escapeHtml(statusMessage);
|
|
3848
|
+
html = html.replace(/https?:\/\/\S+/g, (url) => {
|
|
3849
|
+
return `<a href="${url}">${url}</a>`;
|
|
3850
|
+
});
|
|
3851
|
+
html = html.replace(/(^|\s)(\/\S+)/g, (match, startOrSpace, resource) => {
|
|
3852
|
+
let end = "";
|
|
3853
|
+
if (resource[resource.length - 1] === ".") {
|
|
3854
|
+
resource = resource.slice(0, -1);
|
|
3855
|
+
end = ".";
|
|
3856
|
+
}
|
|
3857
|
+
return `${startOrSpace}<a href="${resource}">${resource}</a>${end}`;
|
|
3858
|
+
});
|
|
3859
|
+
html = html.replace(/\r\n|\r|\n/g, "<br />");
|
|
3860
|
+
return html;
|
|
3861
|
+
};
|
|
3862
|
+
|
|
3729
3863
|
const jsenvServerRootDirectoryUrl = import.meta.resolve("./");
|
|
3730
3864
|
|
|
3731
3865
|
const serverPluginInternalClientFiles = () => {
|
|
@@ -4046,6 +4180,16 @@ const serverPluginOpenFile = () => {
|
|
|
4046
4180
|
|
|
4047
4181
|
const routeInspectorHtmlFileUrl = import.meta
|
|
4048
4182
|
.resolve("./client/route_inspector/route_inspector.html");
|
|
4183
|
+
let routeInspectorHtml;
|
|
4184
|
+
const readRouteInspectorHtml = () => {
|
|
4185
|
+
if (routeInspectorHtml === undefined) {
|
|
4186
|
+
routeInspectorHtml = readFileSync(
|
|
4187
|
+
new URL(routeInspectorHtmlFileUrl),
|
|
4188
|
+
"utf8",
|
|
4189
|
+
);
|
|
4190
|
+
}
|
|
4191
|
+
return routeInspectorHtml;
|
|
4192
|
+
};
|
|
4049
4193
|
|
|
4050
4194
|
const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
|
|
4051
4195
|
return {
|
|
@@ -4058,11 +4202,7 @@ const serverPluginRouteInspector = ({ canExposeSensitiveData }) => {
|
|
|
4058
4202
|
availableMediaTypes: ["text/html"],
|
|
4059
4203
|
declarationSource: import.meta.url,
|
|
4060
4204
|
fetch: () => {
|
|
4061
|
-
|
|
4062
|
-
new URL(routeInspectorHtmlFileUrl),
|
|
4063
|
-
"utf8",
|
|
4064
|
-
);
|
|
4065
|
-
return new Response(inspectorHtml, {
|
|
4205
|
+
return new Response(readRouteInspectorHtml(), {
|
|
4066
4206
|
headers: { "content-type": "text/html" },
|
|
4067
4207
|
});
|
|
4068
4208
|
},
|
|
@@ -4467,25 +4607,92 @@ const defaultGetHookFunction = (hook, info = {}) => {
|
|
|
4467
4607
|
return hookValue;
|
|
4468
4608
|
};
|
|
4469
4609
|
|
|
4610
|
+
// The hooks a server plugin can implement, in the order they run for a
|
|
4611
|
+
// request. See docs/plugins.md for what each one is meant for.
|
|
4612
|
+
const SERVER_PLUGIN_PROPERTIES = {
|
|
4613
|
+
// ({ port }) once the server listens
|
|
4614
|
+
serverListening: { type: "hook" },
|
|
4615
|
+
// (request) => { resource | pathname, ...requestProperties } | null
|
|
4616
|
+
redirectRequest: { type: "hook" },
|
|
4617
|
+
// async (request, helpers) => { ...helpersToAdd } | null
|
|
4618
|
+
augmentRouteFetchSecondArg: { type: "hook" },
|
|
4619
|
+
// async (request) => ["permission", ...] | null, called lazily and at most
|
|
4620
|
+
// once per request when a route declares permissions
|
|
4621
|
+
grantPermissions: { type: "hook" },
|
|
4622
|
+
// async (error, { request }) => response | null
|
|
4623
|
+
handleError: { type: "hook" },
|
|
4624
|
+
// (request, { response, warn }) to look at the response before it is sent
|
|
4625
|
+
inspectResponse: { type: "hook" },
|
|
4626
|
+
// (request, response) => responseToCompose | null
|
|
4627
|
+
injectResponseProperties: { type: "hook" },
|
|
4628
|
+
// ({ reason }) once the server is stopped
|
|
4629
|
+
serverStopped: { type: "hook" },
|
|
4630
|
+
// route descriptors, appended after the routes given to startServer
|
|
4631
|
+
routes: {},
|
|
4632
|
+
};
|
|
4633
|
+
|
|
4470
4634
|
const createServerPluginsController = async (serverPlugins) => {
|
|
4471
|
-
const
|
|
4635
|
+
const serverPluginsController = await createPluginsController({
|
|
4472
4636
|
plugins: serverPlugins,
|
|
4473
4637
|
pluginDescription: {
|
|
4474
4638
|
name: "server plugin",
|
|
4475
|
-
properties:
|
|
4476
|
-
serverListening: { type: "hook" },
|
|
4477
|
-
redirectRequest: { type: "hook" },
|
|
4478
|
-
augmentRouteFetchSecondArg: { type: "hook" },
|
|
4479
|
-
grantPermissions: { type: "hook" },
|
|
4480
|
-
handleError: { type: "hook" },
|
|
4481
|
-
onResponsePush: { type: "hook" },
|
|
4482
|
-
injectResponseProperties: { type: "hook" },
|
|
4483
|
-
serverStopped: { type: "hook" },
|
|
4484
|
-
routes: {}, // routes are handled separately (used to populate the router)
|
|
4485
|
-
},
|
|
4639
|
+
properties: SERVER_PLUGIN_PROPERTIES,
|
|
4486
4640
|
},
|
|
4487
4641
|
});
|
|
4488
|
-
return
|
|
4642
|
+
return serverPluginsController;
|
|
4643
|
+
};
|
|
4644
|
+
|
|
4645
|
+
const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
4646
|
+
for (const permission of permissionsRequired) {
|
|
4647
|
+
if (!permissionsSet.has(permission)) {
|
|
4648
|
+
return false;
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
return true;
|
|
4652
|
+
};
|
|
4653
|
+
|
|
4654
|
+
// Both helpers share one cursor over the "grantPermissions" hooks so that each
|
|
4655
|
+
// plugin runs at most once per request: hasPermissions stops as soon as the
|
|
4656
|
+
// required permissions are all granted, getAllPermissions drains the plugins
|
|
4657
|
+
// left.
|
|
4658
|
+
const createPermissionHelpers = (serverPluginsController, request) => {
|
|
4659
|
+
const permissionsSet = new Set();
|
|
4660
|
+
const nextPermissionsHook = serverPluginsController.createAsyncHookIterator(
|
|
4661
|
+
"grantPermissions",
|
|
4662
|
+
request,
|
|
4663
|
+
);
|
|
4664
|
+
const drainPermissionsUntil = async (permissionsRequired) => {
|
|
4665
|
+
for (;;) {
|
|
4666
|
+
if (
|
|
4667
|
+
permissionsRequired !== undefined &&
|
|
4668
|
+
permissionsSatisfy(permissionsSet, permissionsRequired)
|
|
4669
|
+
) {
|
|
4670
|
+
return true;
|
|
4671
|
+
}
|
|
4672
|
+
const { done, value } = await nextPermissionsHook();
|
|
4673
|
+
if (done) {
|
|
4674
|
+
break;
|
|
4675
|
+
}
|
|
4676
|
+
if (Array.isArray(value)) {
|
|
4677
|
+
for (const permission of value) {
|
|
4678
|
+
permissionsSet.add(permission);
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
return false;
|
|
4683
|
+
};
|
|
4684
|
+
|
|
4685
|
+
const getAllPermissions = async () => {
|
|
4686
|
+
await drainPermissionsUntil(undefined);
|
|
4687
|
+
return permissionsSet;
|
|
4688
|
+
};
|
|
4689
|
+
const hasPermissions = async (permissionsRequired) => {
|
|
4690
|
+
if (permissionsRequired.length === 0) {
|
|
4691
|
+
return true;
|
|
4692
|
+
}
|
|
4693
|
+
return drainPermissionsUntil(permissionsRequired);
|
|
4694
|
+
};
|
|
4695
|
+
return { getAllPermissions, hasPermissions };
|
|
4489
4696
|
};
|
|
4490
4697
|
|
|
4491
4698
|
// This file is used just for test and internal tests
|
|
@@ -5693,6 +5900,16 @@ const createResourcePattern = (pattern) => {
|
|
|
5693
5900
|
// return resource;
|
|
5694
5901
|
// };
|
|
5695
5902
|
|
|
5903
|
+
/**
|
|
5904
|
+
* Pick, among the encodings the server can produce, the one the request
|
|
5905
|
+
* prefers according to its `accept-encoding` header (`br` and `brotli` are
|
|
5906
|
+
* synonyms).
|
|
5907
|
+
*
|
|
5908
|
+
* @param {{ headers: Object }} request - Anything carrying lowercased `headers`.
|
|
5909
|
+
* @param {Array<string>} availableEncodings - In order of server preference: on a tie the first one wins.
|
|
5910
|
+
* @returns {string|null} The encoding to respond with, `null` when the request
|
|
5911
|
+
* accepts none of them or has no `accept-encoding` header.
|
|
5912
|
+
*/
|
|
5696
5913
|
const pickContentEncoding = (request, availableEncodings) => {
|
|
5697
5914
|
const { headers = {} } = request;
|
|
5698
5915
|
const requestAcceptEncodingHeader = headers["accept-encoding"];
|
|
@@ -5749,6 +5966,16 @@ const getEncodingAcceptanceScore = ({ value, quality }, availableEncoding) => {
|
|
|
5749
5966
|
return -1;
|
|
5750
5967
|
};
|
|
5751
5968
|
|
|
5969
|
+
/**
|
|
5970
|
+
* Pick, among the languages the server can produce, the one the request
|
|
5971
|
+
* prefers according to its `accept-language` header. An exact match
|
|
5972
|
+
* (`fr-FR`) scores higher than a primary language match (`fr` for `fr-CA`).
|
|
5973
|
+
*
|
|
5974
|
+
* @param {{ headers: Object }} request - Anything carrying lowercased `headers`.
|
|
5975
|
+
* @param {Array<string>} availableLanguages - In order of server preference: on a tie the first one wins.
|
|
5976
|
+
* @returns {string|null} The language to respond with, `null` when the request
|
|
5977
|
+
* accepts none of them or has no `accept-language` header.
|
|
5978
|
+
*/
|
|
5752
5979
|
const pickContentLanguage = (request, availableLanguages) => {
|
|
5753
5980
|
const { headers = {} } = request;
|
|
5754
5981
|
const requestAcceptLanguageHeader = headers["accept-language"];
|
|
@@ -5900,7 +6127,7 @@ const HTTP_METHODS = [
|
|
|
5900
6127
|
|
|
5901
6128
|
const createRouter = (
|
|
5902
6129
|
routeDescriptionArray,
|
|
5903
|
-
{ optionsFallback, logLevel
|
|
6130
|
+
{ optionsFallback, logLevel } = {},
|
|
5904
6131
|
) => {
|
|
5905
6132
|
const logger = createLogger({ logLevel });
|
|
5906
6133
|
const routeSet = new Set();
|
|
@@ -6157,7 +6384,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6157
6384
|
|
|
6158
6385
|
if (fetchReturnValue instanceof Response) {
|
|
6159
6386
|
status = fetchReturnValue.status;
|
|
6160
|
-
statusText
|
|
6387
|
+
// a Response built without statusText has an empty one: the standard
|
|
6388
|
+
// phrase for that status is used instead
|
|
6389
|
+
statusText = fetchReturnValue.statusText || undefined;
|
|
6161
6390
|
headers = Object.fromEntries(fetchReturnValue.headers);
|
|
6162
6391
|
body = fetchReturnValue.body;
|
|
6163
6392
|
} else if (
|
|
@@ -6165,7 +6394,10 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6165
6394
|
typeof fetchReturnValue === "object"
|
|
6166
6395
|
) {
|
|
6167
6396
|
status = fetchReturnValue.status || 404;
|
|
6168
|
-
statusText =
|
|
6397
|
+
statusText =
|
|
6398
|
+
fetchReturnValue.statusText === undefined
|
|
6399
|
+
? undefined
|
|
6400
|
+
: asReasonPhrase(fetchReturnValue.statusText);
|
|
6169
6401
|
statusMessage = fetchReturnValue.statusMessage;
|
|
6170
6402
|
headers = fetchReturnValue.headers || {};
|
|
6171
6403
|
body = fetchReturnValue.body;
|
|
@@ -6176,30 +6408,6 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6176
6408
|
);
|
|
6177
6409
|
}
|
|
6178
6410
|
|
|
6179
|
-
if (
|
|
6180
|
-
redirect === "follow" &&
|
|
6181
|
-
isRedirectStatus(status) &&
|
|
6182
|
-
headers["location"]
|
|
6183
|
-
) {
|
|
6184
|
-
const redirectUrl = new URL(headers["location"], request.url);
|
|
6185
|
-
if (redirectUrl.origin === new URL(request.url).origin) {
|
|
6186
|
-
const redirectRequest = {
|
|
6187
|
-
...request,
|
|
6188
|
-
url: redirectUrl.href,
|
|
6189
|
-
resource:
|
|
6190
|
-
redirectUrl.pathname + redirectUrl.search + redirectUrl.hash,
|
|
6191
|
-
// GET for 301/302/303, preserve method for 307/308
|
|
6192
|
-
method: status === 307 || status === 308 ? request.method : "GET",
|
|
6193
|
-
params: {},
|
|
6194
|
-
};
|
|
6195
|
-
const redirectedResponse = await matchRoutes(redirectRequest);
|
|
6196
|
-
onRouteMatch(route);
|
|
6197
|
-
onResponseHeaders(request, route, redirectedResponse.headers || {});
|
|
6198
|
-
return redirectedResponse;
|
|
6199
|
-
}
|
|
6200
|
-
// redirect to other origins are left to the client to handle
|
|
6201
|
-
}
|
|
6202
|
-
|
|
6203
6411
|
onRouteMatch(route);
|
|
6204
6412
|
onResponseHeaders(request, route, headers);
|
|
6205
6413
|
return { status, statusText, statusMessage, headers, body, timing };
|
|
@@ -6461,7 +6669,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6461
6669
|
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
6462
6670
|
normallyCanSee =
|
|
6463
6671
|
permissionsToSee.length === 0 ||
|
|
6464
|
-
permissionsSatisfy
|
|
6672
|
+
permissionsSatisfy(permsSet, permissionsToSee);
|
|
6465
6673
|
}
|
|
6466
6674
|
if (!normallyCanSee && !canExposeSensitiveData) {
|
|
6467
6675
|
continue;
|
|
@@ -6519,25 +6727,13 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6519
6727
|
return router;
|
|
6520
6728
|
};
|
|
6521
6729
|
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
* @param {Object} params - Route configuration object
|
|
6526
|
-
* @param {string} params.endpoint - String in format "METHOD /resource/path" (e.g. "GET /users/:id")
|
|
6527
|
-
* @param {Object} [params.headers] - Optional headers pattern to match
|
|
6528
|
-
* @param {Array<string>} [params.availableMediaTypes=[]] - Content types this route can produce
|
|
6529
|
-
* @param {Array<string>} [params.availableLanguages=[]] - Languages this route can respond with
|
|
6530
|
-
* @param {Array<string>} [params.availableEncodings=[]] - Encodings this route supports
|
|
6531
|
-
* @param {Array<string>} [params.acceptedMediaTypes=[]] - Content types this route accepts (for POST/PATCH/PUT)
|
|
6532
|
-
* @param {Function} params.fetch - Function to generate response for matching requests
|
|
6533
|
-
* @throws {TypeError} If endpoint is not a string
|
|
6534
|
-
* @returns {void}
|
|
6535
|
-
*/
|
|
6730
|
+
// Turns a route descriptor (documented on startServer's `routes` param) into
|
|
6731
|
+
// a route: matchers for the method, the resource and the headers, plus what
|
|
6732
|
+
// the inspector shows. Throws on a malformed descriptor.
|
|
6536
6733
|
const createRoute = ({
|
|
6537
6734
|
endpoint,
|
|
6538
6735
|
description,
|
|
6539
6736
|
headers,
|
|
6540
|
-
service,
|
|
6541
6737
|
serverPlugin,
|
|
6542
6738
|
permissionsRequired,
|
|
6543
6739
|
permissionsToSee,
|
|
@@ -6549,7 +6745,6 @@ const createRoute = ({
|
|
|
6549
6745
|
fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
|
|
6550
6746
|
clientCodeExample,
|
|
6551
6747
|
isFallback,
|
|
6552
|
-
subroutes,
|
|
6553
6748
|
declarationSource,
|
|
6554
6749
|
}) => {
|
|
6555
6750
|
if (!endpoint || typeof endpoint !== "string") {
|
|
@@ -6589,7 +6784,6 @@ const createRoute = ({
|
|
|
6589
6784
|
method,
|
|
6590
6785
|
resource,
|
|
6591
6786
|
description,
|
|
6592
|
-
service,
|
|
6593
6787
|
serverPlugin,
|
|
6594
6788
|
permissionsRequired,
|
|
6595
6789
|
permissionsToSee,
|
|
@@ -6672,14 +6866,10 @@ const createRoute = ({
|
|
|
6672
6866
|
resourcePattern,
|
|
6673
6867
|
isForWebSocket,
|
|
6674
6868
|
isFallback,
|
|
6675
|
-
subroutes,
|
|
6676
6869
|
};
|
|
6677
6870
|
return route;
|
|
6678
6871
|
};
|
|
6679
6872
|
|
|
6680
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
6681
|
-
const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
|
|
6682
|
-
|
|
6683
6873
|
// Returns a denied response if the request does not satisfy route access/visible config.
|
|
6684
6874
|
// Returns null if access is granted.
|
|
6685
6875
|
const checkRouteAccess = async (
|
|
@@ -6709,22 +6899,13 @@ const checkRouteAccess = async (
|
|
|
6709
6899
|
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
6710
6900
|
if (
|
|
6711
6901
|
permissionsToSee.length === 0 ||
|
|
6712
|
-
permissionsSatisfy
|
|
6902
|
+
permissionsSatisfy(permsSet, permissionsToSee)
|
|
6713
6903
|
) {
|
|
6714
6904
|
return createForbiddenResponse();
|
|
6715
6905
|
}
|
|
6716
6906
|
return createRouteNotFoundResponse(request);
|
|
6717
6907
|
};
|
|
6718
6908
|
|
|
6719
|
-
const permissionsSatisfy$1 = (permissionsSet, permissionsRequired) => {
|
|
6720
|
-
for (const p of permissionsRequired) {
|
|
6721
|
-
if (!permissionsSet.has(p)) {
|
|
6722
|
-
return false;
|
|
6723
|
-
}
|
|
6724
|
-
}
|
|
6725
|
-
return true;
|
|
6726
|
-
};
|
|
6727
|
-
|
|
6728
6909
|
const createForbiddenResponse = () => {
|
|
6729
6910
|
return {
|
|
6730
6911
|
status: 403,
|
|
@@ -6800,7 +6981,6 @@ const createNotAcceptableResponse = (
|
|
|
6800
6981
|
) => {
|
|
6801
6982
|
const unsupported = [];
|
|
6802
6983
|
const headers = {};
|
|
6803
|
-
const data = {};
|
|
6804
6984
|
|
|
6805
6985
|
if (availableMediaTypes.length) {
|
|
6806
6986
|
const requestAcceptHeader = request.headers["accept"];
|
|
@@ -6808,11 +6988,6 @@ const createNotAcceptableResponse = (
|
|
|
6808
6988
|
// Use a non-standard but semantic header name
|
|
6809
6989
|
headers["available-media-types"] = availableMediaTypes.join(", ");
|
|
6810
6990
|
|
|
6811
|
-
Object.assign(data, {
|
|
6812
|
-
requestAcceptHeader,
|
|
6813
|
-
availableMediaTypes,
|
|
6814
|
-
});
|
|
6815
|
-
|
|
6816
6991
|
unsupported.push({
|
|
6817
6992
|
type: "content-type",
|
|
6818
6993
|
message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
|
|
@@ -6825,11 +7000,6 @@ Available media types: ${availableMediaTypes.join(", ")}.`,
|
|
|
6825
7000
|
// Use a non-standard but semantic header name
|
|
6826
7001
|
headers["available-languages"] = availableLanguages.join(", ");
|
|
6827
7002
|
|
|
6828
|
-
Object.assign(data, {
|
|
6829
|
-
requestAcceptLanguageHeader,
|
|
6830
|
-
availableLanguages,
|
|
6831
|
-
});
|
|
6832
|
-
|
|
6833
7003
|
unsupported.push({
|
|
6834
7004
|
type: "language",
|
|
6835
7005
|
message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
|
|
@@ -6842,11 +7012,6 @@ Available languages: ${availableLanguages.join(", ")}.`,
|
|
|
6842
7012
|
// Use a non-standard but semantic header name
|
|
6843
7013
|
headers["available-versions"] = availableVersions.join(", ");
|
|
6844
7014
|
|
|
6845
|
-
Object.assign(data, {
|
|
6846
|
-
requestAcceptVersionHeader,
|
|
6847
|
-
availableLanguages,
|
|
6848
|
-
});
|
|
6849
|
-
|
|
6850
7015
|
unsupported.push({
|
|
6851
7016
|
type: "version",
|
|
6852
7017
|
message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
|
|
@@ -6859,11 +7024,6 @@ Available versions: ${availableVersions.join(", ")}.`,
|
|
|
6859
7024
|
// Use a non-standard but semantic header name
|
|
6860
7025
|
headers["available-encodings"] = availableEncodings.join(", ");
|
|
6861
7026
|
|
|
6862
|
-
Object.assign(data, {
|
|
6863
|
-
requestAcceptEncodingHeader,
|
|
6864
|
-
availableEncodings,
|
|
6865
|
-
});
|
|
6866
|
-
|
|
6867
7027
|
unsupported.push({
|
|
6868
7028
|
type: "encoding",
|
|
6869
7029
|
message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
|
|
@@ -6900,7 +7060,7 @@ const createMethodNotAllowedResponse = (
|
|
|
6900
7060
|
return {
|
|
6901
7061
|
status: 405,
|
|
6902
7062
|
statusText: "Method Not Allowed",
|
|
6903
|
-
|
|
7063
|
+
statusMessage: `The HTTP method "${request.method}" is not supported for this resource.
|
|
6904
7064
|
Allowed methods: ${allowedMethods.join(", ")}`,
|
|
6905
7065
|
headers: {
|
|
6906
7066
|
allow: allowedMethods.join(", "),
|
|
@@ -7021,199 +7181,131 @@ const letters = [
|
|
|
7021
7181
|
"y",
|
|
7022
7182
|
"z",
|
|
7023
7183
|
];
|
|
7024
|
-
|
|
7025
|
-
const createReason = (reasonString) => {
|
|
7026
|
-
return {
|
|
7027
|
-
toString: () => reasonString,
|
|
7028
|
-
};
|
|
7029
|
-
};
|
|
7030
|
-
|
|
7031
|
-
const STOP_REASON_INTERNAL_ERROR = createReason("Internal error");
|
|
7032
|
-
const STOP_REASON_PROCESS_SIGHUP = createReason("process SIGHUP");
|
|
7033
|
-
const STOP_REASON_PROCESS_SIGTERM = createReason("process SIGTERM");
|
|
7034
|
-
const STOP_REASON_PROCESS_SIGINT = createReason("process SIGINT");
|
|
7035
|
-
const STOP_REASON_PROCESS_BEFORE_EXIT = createReason(
|
|
7036
|
-
"process before exit",
|
|
7037
|
-
);
|
|
7038
|
-
const STOP_REASON_PROCESS_EXIT = createReason("process exit");
|
|
7039
|
-
const STOP_REASON_NOT_SPECIFIED = createReason("not specified");
|
|
7040
|
-
|
|
7041
|
-
const applyDnsResolution = async (
|
|
7042
|
-
hostname,
|
|
7043
|
-
{ verbatim = false } = {},
|
|
7044
|
-
) => {
|
|
7045
|
-
const dnsResolution = await new Promise((resolve, reject) => {
|
|
7046
|
-
lookup(hostname, { verbatim }, (error, address, family) => {
|
|
7047
|
-
if (error) {
|
|
7048
|
-
reject(error);
|
|
7049
|
-
} else {
|
|
7050
|
-
resolve({ address, family });
|
|
7051
|
-
}
|
|
7052
|
-
});
|
|
7053
|
-
});
|
|
7054
|
-
return dnsResolution;
|
|
7055
|
-
};
|
|
7056
|
-
|
|
7057
|
-
const parseHostname = (hostname) => {
|
|
7058
|
-
if (hostname === "0.0.0.0") {
|
|
7059
|
-
return {
|
|
7060
|
-
type: "ip",
|
|
7061
|
-
label: "unspecified",
|
|
7062
|
-
version: 4,
|
|
7063
|
-
};
|
|
7064
|
-
}
|
|
7065
|
-
if (
|
|
7066
|
-
hostname === "::" ||
|
|
7067
|
-
hostname === "0000:0000:0000:0000:0000:0000:0000:0000"
|
|
7068
|
-
) {
|
|
7069
|
-
return {
|
|
7070
|
-
type: "ip",
|
|
7071
|
-
label: "unspecified",
|
|
7072
|
-
version: 6,
|
|
7073
|
-
};
|
|
7074
|
-
}
|
|
7075
|
-
if (hostname === "127.0.0.1") {
|
|
7076
|
-
return {
|
|
7077
|
-
type: "ip",
|
|
7078
|
-
label: "loopback",
|
|
7079
|
-
version: 4,
|
|
7080
|
-
};
|
|
7081
|
-
}
|
|
7082
|
-
if (
|
|
7083
|
-
hostname === "::1" ||
|
|
7084
|
-
hostname === "0000:0000:0000:0000:0000:0000:0000:0001"
|
|
7085
|
-
) {
|
|
7086
|
-
return {
|
|
7087
|
-
type: "ip",
|
|
7088
|
-
label: "loopback",
|
|
7089
|
-
version: 6,
|
|
7090
|
-
};
|
|
7091
|
-
}
|
|
7092
|
-
const ipVersion = isIP(hostname);
|
|
7093
|
-
if (ipVersion === 0) {
|
|
7094
|
-
return {
|
|
7095
|
-
type: "hostname",
|
|
7096
|
-
};
|
|
7097
|
-
}
|
|
7098
|
-
return {
|
|
7099
|
-
type: "ip",
|
|
7100
|
-
version: ipVersion,
|
|
7101
|
-
};
|
|
7102
|
-
};
|
|
7103
|
-
|
|
7104
|
-
const createIpGetters = () => {
|
|
7105
|
-
const networkAddresses = [];
|
|
7106
|
-
const networkInterfaceMap = networkInterfaces();
|
|
7107
|
-
for (const key of Object.keys(networkInterfaceMap)) {
|
|
7108
|
-
for (const networkAddress of networkInterfaceMap[key]) {
|
|
7109
|
-
networkAddresses.push(networkAddress);
|
|
7110
|
-
}
|
|
7111
|
-
}
|
|
7184
|
+
|
|
7185
|
+
const createReason = (reasonString) => {
|
|
7112
7186
|
return {
|
|
7113
|
-
|
|
7114
|
-
const isPref = preferIpv6 ? isIpV6 : isIpV4;
|
|
7115
|
-
let firstInternalIp;
|
|
7116
|
-
for (const networkAddress of networkAddresses) {
|
|
7117
|
-
if (networkAddress.internal) {
|
|
7118
|
-
firstInternalIp = networkAddress.address;
|
|
7119
|
-
if (isPref(networkAddress)) {
|
|
7120
|
-
break;
|
|
7121
|
-
}
|
|
7122
|
-
}
|
|
7123
|
-
}
|
|
7124
|
-
return firstInternalIp;
|
|
7125
|
-
},
|
|
7126
|
-
getFirstExternalIp: ({ preferIpv6 }) => {
|
|
7127
|
-
const isPref = preferIpv6 ? isIpV6 : isIpV4;
|
|
7128
|
-
let firstExternalIp;
|
|
7129
|
-
for (const networkAddress of networkAddresses) {
|
|
7130
|
-
if (!networkAddress.internal) {
|
|
7131
|
-
firstExternalIp = networkAddress.address;
|
|
7132
|
-
if (isPref(networkAddress)) {
|
|
7133
|
-
break;
|
|
7134
|
-
}
|
|
7135
|
-
}
|
|
7136
|
-
}
|
|
7137
|
-
return firstExternalIp;
|
|
7138
|
-
},
|
|
7187
|
+
toString: () => reasonString,
|
|
7139
7188
|
};
|
|
7140
7189
|
};
|
|
7141
7190
|
|
|
7142
|
-
const
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
const
|
|
7191
|
+
const STOP_REASON_INTERNAL_ERROR = createReason("Internal error");
|
|
7192
|
+
const STOP_REASON_PROCESS_SIGHUP = createReason("process SIGHUP");
|
|
7193
|
+
const STOP_REASON_PROCESS_SIGTERM = createReason("process SIGTERM");
|
|
7194
|
+
const STOP_REASON_PROCESS_SIGINT = createReason("process SIGINT");
|
|
7195
|
+
const STOP_REASON_PROCESS_BEFORE_EXIT = createReason(
|
|
7196
|
+
"process before exit",
|
|
7197
|
+
);
|
|
7198
|
+
const STOP_REASON_PROCESS_EXIT = createReason("process exit");
|
|
7199
|
+
const STOP_REASON_NOT_SPECIFIED = createReason("not specified");
|
|
7151
7200
|
|
|
7152
7201
|
const TIMING_NOOP = () => {
|
|
7153
7202
|
return { end: () => {} };
|
|
7154
7203
|
};
|
|
7155
7204
|
|
|
7156
|
-
const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
|
|
7157
|
-
for (const p of permissionsRequired) {
|
|
7158
|
-
if (!permissionsSet.has(p)) {
|
|
7159
|
-
return false;
|
|
7160
|
-
}
|
|
7161
|
-
}
|
|
7162
|
-
return true;
|
|
7163
|
-
};
|
|
7164
|
-
|
|
7165
7205
|
/**
|
|
7166
|
-
*
|
|
7167
|
-
*
|
|
7168
|
-
* @param {Object} params
|
|
7206
|
+
* Start an http server (https and http2 optional) answering each request with
|
|
7207
|
+
* the first route producing a response.
|
|
7169
7208
|
*
|
|
7170
|
-
* @param {
|
|
7171
|
-
*
|
|
7172
|
-
* - `endpoint` {string} — Required.
|
|
7173
|
-
* and
|
|
7174
|
-
*
|
|
7175
|
-
*
|
|
7176
|
-
*
|
|
7177
|
-
*
|
|
7178
|
-
*
|
|
7179
|
-
*
|
|
7180
|
-
*
|
|
7181
|
-
*
|
|
7182
|
-
*
|
|
7183
|
-
*
|
|
7184
|
-
*
|
|
7185
|
-
*
|
|
7186
|
-
*
|
|
7187
|
-
* - `
|
|
7188
|
-
*
|
|
7189
|
-
*
|
|
7190
|
-
*
|
|
7191
|
-
*
|
|
7192
|
-
*
|
|
7193
|
-
*
|
|
7194
|
-
*
|
|
7209
|
+
* @param {Object} [params={}]
|
|
7210
|
+
* @param {Array<Object>} [params.routes=[]] - Route descriptors, tried in order. Each has:
|
|
7211
|
+
* - `endpoint` {string} — Required. `"GET /users/:id"`: an http method (or `*` for any)
|
|
7212
|
+
* and a resource pattern. `:name` captures a segment and `*` a run of segments, both
|
|
7213
|
+
* land in `request.params`; `?page=:page` captures a search param. `"*"` alone
|
|
7214
|
+
* matches everything. An endpoint ending in `.websocket` marks a websocket route.
|
|
7215
|
+
* - `fetch` {Function} — Required. `(request, helpers) => response`, async or not.
|
|
7216
|
+
* Returns a `Response`, a plain `{ status, statusText, statusMessage, headers, body,
|
|
7217
|
+
* timing }` object (`status` defaults to 404, `statusMessage` feeds the body of 4xx/5xx
|
|
7218
|
+
* responses), a `WebSocketResponse`, or `null`/`undefined` to let the next route try.
|
|
7219
|
+
* `request` is described in docs/handling_requests.md. `helpers` holds `timing(name)`,
|
|
7220
|
+
* `injectResponseHeader(name, value)`, `contentNegotiation` (`{ mediaType, language,
|
|
7221
|
+
* version, encoding }` picked from the `available*` lists below), `responseCookies`
|
|
7222
|
+
* (`set`/`delete`), `hasPermissions`, `getAllPermissions`, `router`,
|
|
7223
|
+
* `canExposeSensitiveData`, plus whatever plugins add with "augmentRouteFetchSecondArg".
|
|
7224
|
+
* - `headers` {Object} — Header pattern the request must match
|
|
7225
|
+
* (`{ upgrade: "websocket" }` marks a websocket route).
|
|
7226
|
+
* - `availableMediaTypes`, `availableLanguages`, `availableVersions`, `availableEncodings`
|
|
7227
|
+
* {Array} — What the route can produce, in order of preference. Drives content
|
|
7228
|
+
* negotiation, the `vary` header and 406 responses. Media types are inferred from the
|
|
7229
|
+
* endpoint extension when omitted.
|
|
7230
|
+
* - `acceptedMediaTypes` {Array<string>} — Request body media types accepted by
|
|
7231
|
+
* POST/PATCH/PUT (415 otherwise).
|
|
7232
|
+
* - `permissionsRequired` {Array<string>} — Permissions (granted by "grantPermissions"
|
|
7233
|
+
* plugins) needed to access the route; `[]` opens it to everyone. Once any route
|
|
7234
|
+
* declares permissions, a route without them is hidden (404) from everyone.
|
|
7235
|
+
* - `permissionsToSee` {Array<string>} — Permissions needed to learn the route exists
|
|
7236
|
+
* (403 instead of 404 when access is denied); `[]` makes it visible to everyone.
|
|
7237
|
+
* - `description` {string}, `clientCodeExample` {string|Function}, `declarationSource`
|
|
7238
|
+
* {string} — Shown by the route inspector at `/.internal/route_inspector`.
|
|
7195
7239
|
*
|
|
7196
|
-
* @param {Array<Object>} [params.plugins=[]] - Server plugins
|
|
7197
|
-
*
|
|
7198
|
-
*
|
|
7240
|
+
* @param {Array<Object>} [params.plugins=[]] - Server plugins, see docs/plugins.md.
|
|
7241
|
+
* @param {number} [params.port=0] - `0` lets the OS pick a free port.
|
|
7242
|
+
* @param {number} [params.portHint] - With `port: 0`, try this port first, then the next ones.
|
|
7243
|
+
* @param {string} [params.hostname="localhost"] - Hostname or ip to listen to; `server.origin` is built from it.
|
|
7244
|
+
* @param {boolean} [params.acceptAnyIp=false] - Listen on every interface (`0.0.0.0` or `::`)
|
|
7245
|
+
* so that other machines on the network can reach the server; `server.origins.externalip`
|
|
7246
|
+
* then tells the address to use from there.
|
|
7247
|
+
* @param {boolean} [params.preferIpv6] - Favor ipv6 addresses in `server.origins`.
|
|
7248
|
+
* @param {Array<string>|true} [params.allowedHosts=[]] - Names the server may be requested
|
|
7249
|
+
* with (the `host` header), on top of localhost, `hostname`, the machine name and ips,
|
|
7250
|
+
* and every ip when `acceptAnyIp`. `".example.com"` covers the subdomains. Any other
|
|
7251
|
+
* host is answered 403: a page served elsewhere cannot read the responses by rebinding
|
|
7252
|
+
* its DNS name to this machine. `true` disables the check.
|
|
7253
|
+
* @param {Object|false} [params.https=false] - `{ certificate, privateKey }` (PEM strings) to serve https.
|
|
7254
|
+
* @param {boolean} [params.redirectHttpToHttps] - With https, answer http requests with a 301
|
|
7255
|
+
* to the https origin. Defaults to true unless `allowHttpRequestOnHttps` is set.
|
|
7256
|
+
* @param {boolean} [params.allowHttpRequestOnHttps=false] - With https, also serve plain http
|
|
7257
|
+
* requests on the same port (`request.origin` tells them apart).
|
|
7258
|
+
* @param {boolean} [params.http2=false] - Serve http2 (needs `https`, http/1.1 clients are still
|
|
7259
|
+
* accepted). Brings nothing on localhost; a page loading many modules from another machine
|
|
7260
|
+
* loads several times faster. Http2 has no reason phrase: `statusText` then only reaches the
|
|
7261
|
+
* logs and the body of 4xx/5xx responses.
|
|
7262
|
+
* @param {boolean} [params.http1Allowed=true] - With http2, still accept http/1.1 clients.
|
|
7263
|
+
* @param {string} [params.logLevel] - `"debug"`, `"info"` (default), `"warn"`, `"error"` or `"off"`.
|
|
7264
|
+
* @param {string} [params.routerLogLevel] - Same, for the router logs (which route matched).
|
|
7265
|
+
* @param {boolean} [params.startLog=true] - Log "server started at …" once listening.
|
|
7266
|
+
* @param {string} [params.serverName="server"] - Name used in the logs.
|
|
7267
|
+
* @param {AbortSignal} [params.signal] - Cancels the start (an AbortError is thrown). Once
|
|
7268
|
+
* listening the server is stopped with `stop`.
|
|
7269
|
+
* @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
|
|
7270
|
+
* inside a cluster worker where the primary process is in charge.
|
|
7271
|
+
* @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
|
|
7272
|
+
* @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
|
|
7273
|
+
* "handleError" plugins answered).
|
|
7274
|
+
* @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
|
|
7275
|
+
* the process alive.
|
|
7276
|
+
* @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
|
|
7277
|
+
* belongs to the machine it runs on. Development only, never in production. It unlocks:
|
|
7278
|
+
* - file paths in status texts (a 404 from `createFileSystemFetch` says which path);
|
|
7279
|
+
* - the declaration source of every route in the route inspector, where every route is
|
|
7280
|
+
* listed whatever its permissions;
|
|
7281
|
+
* - `GET /.internal/open_file/*`, opening a file of the machine in the editor;
|
|
7282
|
+
* - `GET /@jsenv/server/*`, serving this package's own client files;
|
|
7283
|
+
* - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
|
|
7284
|
+
* subscribes to in order to reload when the server restarts.
|
|
7285
|
+
* @param {boolean|Object} [params.serverTiming=false] - `true` or `{ minDuration }` to send
|
|
7286
|
+
* `server-timing` response headers: the time to start responding, the routing of each
|
|
7287
|
+
* plugin, what routes measure with `helpers.timing` and the `timing` a response hands
|
|
7288
|
+
* back. `minDuration` (ms) drops the entries that took less: 0 keeps everything (what a
|
|
7289
|
+
* test wants), a human reading devtools usually wants the sub-millisecond noise gone.
|
|
7290
|
+
* @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
|
|
7291
|
+
* still has no response after that many ms (0 disables).
|
|
7292
|
+
* @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
|
|
7293
|
+
* @param {number} [params.responseTimeout=600000] - Ms a route can take to start responding
|
|
7294
|
+
* before a 504 is sent instead (10 minutes).
|
|
7295
|
+
* @param {number} [params.requestBodyMaxSize=1048576] - Bytes `request.json()`, `text()`,
|
|
7296
|
+
* `buffer()` and `queryString()` accept before answering 413 (1 MiB). A route can pass
|
|
7297
|
+
* its own `{ maxSize }` to these readers; `request.body` is never limited.
|
|
7199
7298
|
*
|
|
7200
|
-
* @
|
|
7201
|
-
*
|
|
7202
|
-
* `
|
|
7203
|
-
*
|
|
7204
|
-
*
|
|
7205
|
-
*
|
|
7206
|
-
*
|
|
7207
|
-
*
|
|
7208
|
-
*
|
|
7209
|
-
*
|
|
7210
|
-
* - Declaration source links in the route inspector (shows where each route is defined).
|
|
7211
|
-
* - The `/.internal/open_file` endpoint that can open files on the server machine.
|
|
7212
|
-
* - Auto-reload client notification when the server restarts.
|
|
7213
|
-
* - All routes are visible in the route inspector regardless of permission configuration.
|
|
7214
|
-
* Set to `true` during local development for a better DX.
|
|
7215
|
-
*
|
|
7216
|
-
* @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
|
|
7299
|
+
* @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
|
|
7300
|
+
* webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
|
|
7301
|
+
* - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
|
|
7302
|
+
* - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
|
|
7303
|
+
* and defaults to `STOP_REASON_NOT_SPECIFIED`.
|
|
7304
|
+
* - `stoppedPromise`: resolves with the reason the server stopped for (one of the
|
|
7305
|
+
* `STOP_REASON_*` exports or what was given to `stop`).
|
|
7306
|
+
* - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
|
|
7307
|
+
* - `addEffect(callback)`: runs `callback` right away; the function it returns runs
|
|
7308
|
+
* when the server stops.
|
|
7217
7309
|
*/
|
|
7218
7310
|
const startServer = async ({
|
|
7219
7311
|
signal = new AbortController().signal,
|
|
@@ -7229,6 +7321,7 @@ const startServer = async ({
|
|
|
7229
7321
|
allowHttpRequestOnHttps = false,
|
|
7230
7322
|
acceptAnyIp = false,
|
|
7231
7323
|
preferIpv6,
|
|
7324
|
+
allowedHosts = [],
|
|
7232
7325
|
hostname = "localhost",
|
|
7233
7326
|
port = 0, // assign a random available port
|
|
7234
7327
|
portHint,
|
|
@@ -7244,17 +7337,7 @@ const startServer = async ({
|
|
|
7244
7337
|
keepProcessAlive = true,
|
|
7245
7338
|
routes = [],
|
|
7246
7339
|
plugins = [],
|
|
7247
|
-
// When enabled, the server gives more power:
|
|
7248
|
-
// - each route show the source where it was declared
|
|
7249
|
-
// - server can be requested to open a file on the machine
|
|
7250
|
-
// - client can subscribe to server to detect when it restarts
|
|
7251
|
-
// This param should be enabled ONLY during development on your machine
|
|
7252
7340
|
canExposeSensitiveData = false,
|
|
7253
|
-
nagle = true,
|
|
7254
|
-
// false | true | { minDuration }: server-timing response headers.
|
|
7255
|
-
// minDuration (ms) drops entries that took less — 0 keeps everything, which
|
|
7256
|
-
// is what a test wants; a human reading devtools usually wants the noise
|
|
7257
|
-
// gone (the jsenv dev server passes 0.5 for that).
|
|
7258
7341
|
serverTiming = false,
|
|
7259
7342
|
requestWaitingMs = 0,
|
|
7260
7343
|
requestWaitingCallback = ({ request, requestWaitingMs }) => {
|
|
@@ -7268,13 +7351,8 @@ const startServer = async ({
|
|
|
7268
7351
|
),
|
|
7269
7352
|
);
|
|
7270
7353
|
},
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
responseTimeout = 60_000 * 10, // 10s
|
|
7274
|
-
// time allocated to server code to start reading the request body
|
|
7275
|
-
// after this delay the underlying stream is destroyed, attempting to read it would throw
|
|
7276
|
-
// if used the stream stays opened, it's only if the stream is not read at all that it gets destroyed
|
|
7277
|
-
requestBodyLifetime = 60_000 * 2, // 2s
|
|
7354
|
+
responseTimeout = 60_000 * 10, // 10 minutes
|
|
7355
|
+
requestBodyMaxSize = 1024 * 1024,
|
|
7278
7356
|
...rest
|
|
7279
7357
|
} = {}) => {
|
|
7280
7358
|
// param validations
|
|
@@ -7299,6 +7377,11 @@ const startServer = async ({
|
|
|
7299
7377
|
if (http2 && !https) {
|
|
7300
7378
|
throw new Error(`http2 needs https`);
|
|
7301
7379
|
}
|
|
7380
|
+
if (allowedHosts !== true && !Array.isArray(allowedHosts)) {
|
|
7381
|
+
throw new TypeError(
|
|
7382
|
+
`allowedHosts must be an array of hosts or true, got ${allowedHosts}`,
|
|
7383
|
+
);
|
|
7384
|
+
}
|
|
7302
7385
|
}
|
|
7303
7386
|
const logger = createLogger({ logLevel });
|
|
7304
7387
|
// param warnings and normalization
|
|
@@ -7370,9 +7453,7 @@ const startServer = async ({
|
|
|
7370
7453
|
let nodeServer;
|
|
7371
7454
|
const startServerOperation = Abort.startOperation();
|
|
7372
7455
|
const stopCallbackSet = new Set();
|
|
7373
|
-
|
|
7374
|
-
local: "", // favors hostname when possible
|
|
7375
|
-
};
|
|
7456
|
+
let serverOrigins;
|
|
7376
7457
|
|
|
7377
7458
|
try {
|
|
7378
7459
|
startServerOperation.addAbortSignal(signal);
|
|
@@ -7397,63 +7478,15 @@ const startServer = async ({
|
|
|
7397
7478
|
nodeServer.unref();
|
|
7398
7479
|
}
|
|
7399
7480
|
|
|
7400
|
-
const
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
const
|
|
7409
|
-
let hostnameToListen;
|
|
7410
|
-
if (acceptAnyIp) {
|
|
7411
|
-
const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
|
|
7412
|
-
serverOrigins.local = createOrigin(firstInternalIp);
|
|
7413
|
-
serverOrigins.localip = createOrigin(firstInternalIp);
|
|
7414
|
-
const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
|
|
7415
|
-
serverOrigins.externalip = createOrigin(firstExternalIp);
|
|
7416
|
-
hostnameToListen = preferIpv6 ? "::" : "0.0.0.0";
|
|
7417
|
-
} else {
|
|
7418
|
-
hostnameToListen = hostname;
|
|
7419
|
-
}
|
|
7420
|
-
const hostnameInfo = parseHostname(hostname);
|
|
7421
|
-
if (hostnameInfo.type === "ip") {
|
|
7422
|
-
if (acceptAnyIp) {
|
|
7423
|
-
throw new Error(
|
|
7424
|
-
`hostname cannot be an ip when acceptAnyIp is enabled, got ${hostname}`,
|
|
7425
|
-
);
|
|
7426
|
-
}
|
|
7427
|
-
|
|
7428
|
-
preferIpv6 = hostnameInfo.version === 6;
|
|
7429
|
-
const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
|
|
7430
|
-
serverOrigins.local = createOrigin(firstInternalIp);
|
|
7431
|
-
serverOrigins.localip = createOrigin(firstInternalIp);
|
|
7432
|
-
if (hostnameInfo.label === "unspecified") {
|
|
7433
|
-
const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
|
|
7434
|
-
serverOrigins.externalip = createOrigin(firstExternalIp);
|
|
7435
|
-
} else if (hostnameInfo.label === "loopback") {
|
|
7436
|
-
// nothing
|
|
7437
|
-
} else {
|
|
7438
|
-
serverOrigins.local = createOrigin(hostname);
|
|
7439
|
-
}
|
|
7440
|
-
} else {
|
|
7441
|
-
const hostnameDnsResolution = await applyDnsResolution(hostname, {
|
|
7442
|
-
verbatim: true,
|
|
7443
|
-
});
|
|
7444
|
-
if (hostnameDnsResolution) {
|
|
7445
|
-
const hostnameIp = hostnameDnsResolution.address;
|
|
7446
|
-
serverOrigins.localip = createOrigin(hostnameIp);
|
|
7447
|
-
serverOrigins.local = createOrigin(hostname);
|
|
7448
|
-
} else {
|
|
7449
|
-
const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
|
|
7450
|
-
// fallback to internal ip because there is no ip
|
|
7451
|
-
// associated to this hostname on operating system (in hosts file)
|
|
7452
|
-
hostname = firstInternalIp;
|
|
7453
|
-
hostnameToListen = firstInternalIp;
|
|
7454
|
-
serverOrigins.local = createOrigin(firstInternalIp);
|
|
7455
|
-
}
|
|
7456
|
-
}
|
|
7481
|
+
const resolved = await resolveServerOrigins({
|
|
7482
|
+
https,
|
|
7483
|
+
hostname,
|
|
7484
|
+
acceptAnyIp,
|
|
7485
|
+
preferIpv6,
|
|
7486
|
+
});
|
|
7487
|
+
hostname = resolved.hostname;
|
|
7488
|
+
serverOrigins = resolved.serverOrigins;
|
|
7489
|
+
const hostnameToListen = resolved.hostnameToListen;
|
|
7457
7490
|
|
|
7458
7491
|
port = await listen({
|
|
7459
7492
|
signal: startServerOperation.signal,
|
|
@@ -7488,6 +7521,12 @@ const startServer = async ({
|
|
|
7488
7521
|
// so we prefer https://locahost or https://local_hostname
|
|
7489
7522
|
// over the ip
|
|
7490
7523
|
const serverOrigin = serverOrigins.local;
|
|
7524
|
+
const isHostAllowed = createHostChecker({
|
|
7525
|
+
allowedHosts,
|
|
7526
|
+
hostname,
|
|
7527
|
+
serverOrigins,
|
|
7528
|
+
acceptAnyIp,
|
|
7529
|
+
});
|
|
7491
7530
|
|
|
7492
7531
|
// now the server is started (listening) it cannot be aborted anymore
|
|
7493
7532
|
// (otherwise an AbortError is thrown to the code calling "startServer")
|
|
@@ -7551,15 +7590,11 @@ const startServer = async ({
|
|
|
7551
7590
|
);
|
|
7552
7591
|
stopCallbackSet.add(removeConnectionErrorListener);
|
|
7553
7592
|
|
|
7554
|
-
const connectionsTracker = trackServerPendingConnections(nodeServer
|
|
7555
|
-
http2,
|
|
7556
|
-
});
|
|
7593
|
+
const connectionsTracker = trackServerPendingConnections(nodeServer);
|
|
7557
7594
|
// opened connection must be shutdown before the close event is emitted
|
|
7558
7595
|
stopCallbackSet.add(connectionsTracker.stop);
|
|
7559
7596
|
|
|
7560
|
-
const pendingRequestsTracker = trackServerPendingRequests(nodeServer
|
|
7561
|
-
http2,
|
|
7562
|
-
});
|
|
7597
|
+
const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
|
|
7563
7598
|
// ensure pending requests got a response from the server
|
|
7564
7599
|
stopCallbackSet.add((reason) => {
|
|
7565
7600
|
pendingRequestsTracker.stop({
|
|
@@ -7612,7 +7647,7 @@ const startServer = async ({
|
|
|
7612
7647
|
serverTiming && typeof serverTiming === "object"
|
|
7613
7648
|
? serverTiming.minDuration || 0
|
|
7614
7649
|
: 0;
|
|
7615
|
-
const getResponseProperties = async (request
|
|
7650
|
+
const getResponseProperties = async (request) => {
|
|
7616
7651
|
const timings = {};
|
|
7617
7652
|
const timing = serverTiming
|
|
7618
7653
|
? (name) => {
|
|
@@ -7633,9 +7668,7 @@ const startServer = async ({
|
|
|
7633
7668
|
request.logger.info(
|
|
7634
7669
|
request.headers["upgrade"] === "websocket"
|
|
7635
7670
|
? `GET ${request.url} ${websocketSuffixColorized}`
|
|
7636
|
-
: request.
|
|
7637
|
-
? `Push ${request.resource}`
|
|
7638
|
-
: `${request.method} ${request.url}`,
|
|
7671
|
+
: `${request.method} ${request.url}`,
|
|
7639
7672
|
);
|
|
7640
7673
|
let requestWaitingTimeout;
|
|
7641
7674
|
if (requestWaitingMs) {
|
|
@@ -7715,23 +7748,22 @@ const startServer = async ({
|
|
|
7715
7748
|
);
|
|
7716
7749
|
},
|
|
7717
7750
|
);
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
) {
|
|
7725
|
-
clearTimeout(request.body.timeout);
|
|
7726
|
-
}
|
|
7751
|
+
serverPluginsController.callHooks("inspectResponse", request, {
|
|
7752
|
+
response: responseProperties,
|
|
7753
|
+
warn: (message) => {
|
|
7754
|
+
request.logger.warn(message);
|
|
7755
|
+
},
|
|
7756
|
+
});
|
|
7727
7757
|
return responseProperties;
|
|
7728
7758
|
};
|
|
7729
7759
|
|
|
7730
7760
|
let timeout;
|
|
7761
|
+
let timedOut = false;
|
|
7731
7762
|
try {
|
|
7732
7763
|
request = applyRequestInternalRedirection(request);
|
|
7733
7764
|
const timeoutResponsePropertiesPromise = new Promise((resolve) => {
|
|
7734
7765
|
timeout = setTimeout(() => {
|
|
7766
|
+
timedOut = true;
|
|
7735
7767
|
resolve({
|
|
7736
7768
|
// the correct status code should be 500 because it's
|
|
7737
7769
|
// we don't really know what takes time
|
|
@@ -7747,7 +7779,7 @@ const startServer = async ({
|
|
|
7747
7779
|
const routerResponsePropertiesPromise = (async () => {
|
|
7748
7780
|
const fetchSecondArg = {
|
|
7749
7781
|
timing,
|
|
7750
|
-
|
|
7782
|
+
canExposeSensitiveData,
|
|
7751
7783
|
injectResponseHeader: (name, value) => {
|
|
7752
7784
|
if (!headersToInject) {
|
|
7753
7785
|
headersToInject = {};
|
|
@@ -7769,54 +7801,28 @@ const startServer = async ({
|
|
|
7769
7801
|
}
|
|
7770
7802
|
},
|
|
7771
7803
|
);
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
const permissionsSet = new Set();
|
|
7777
|
-
const nextPermissionsHook =
|
|
7778
|
-
serverPluginsController.createAsyncHookIterator(
|
|
7779
|
-
"grantPermissions",
|
|
7780
|
-
request,
|
|
7781
|
-
);
|
|
7782
|
-
const drainPermissionsUntil = async (permissionsRequired) => {
|
|
7783
|
-
for (;;) {
|
|
7784
|
-
if (
|
|
7785
|
-
permissionsRequired !== undefined &&
|
|
7786
|
-
permissionsSatisfy(permissionsSet, permissionsRequired)
|
|
7787
|
-
) {
|
|
7788
|
-
return true;
|
|
7789
|
-
}
|
|
7790
|
-
const { done, value } = await nextPermissionsHook();
|
|
7791
|
-
if (done) {
|
|
7792
|
-
break;
|
|
7793
|
-
}
|
|
7794
|
-
if (Array.isArray(value)) {
|
|
7795
|
-
for (const p of value) {
|
|
7796
|
-
permissionsSet.add(p);
|
|
7797
|
-
}
|
|
7798
|
-
}
|
|
7799
|
-
}
|
|
7800
|
-
return false;
|
|
7801
|
-
};
|
|
7802
|
-
const getAllPermissions = async () => {
|
|
7803
|
-
await drainPermissionsUntil(undefined);
|
|
7804
|
-
return permissionsSet;
|
|
7805
|
-
};
|
|
7806
|
-
const hasPermissions = async (permissionsRequired) => {
|
|
7807
|
-
if (permissionsRequired.length === 0) {
|
|
7808
|
-
return true;
|
|
7809
|
-
}
|
|
7810
|
-
return drainPermissionsUntil(permissionsRequired);
|
|
7811
|
-
};
|
|
7812
|
-
fetchSecondArg.getAllPermissions = getAllPermissions;
|
|
7813
|
-
fetchSecondArg.hasPermissions = hasPermissions;
|
|
7804
|
+
Object.assign(
|
|
7805
|
+
fetchSecondArg,
|
|
7806
|
+
createPermissionHelpers(serverPluginsController, request),
|
|
7807
|
+
);
|
|
7814
7808
|
const routerResponseProperties = await router.match(
|
|
7815
7809
|
request,
|
|
7816
7810
|
fetchSecondArg,
|
|
7817
7811
|
);
|
|
7818
7812
|
return routerResponseProperties;
|
|
7819
7813
|
})();
|
|
7814
|
+
// once the 504 is sent the route keeps running: what it throws
|
|
7815
|
+
// afterwards would have nobody to reject to
|
|
7816
|
+
routerResponsePropertiesPromise.catch((e) => {
|
|
7817
|
+
if (timedOut) {
|
|
7818
|
+
logger.error(
|
|
7819
|
+
createDetailedMessage(`error after the 504 timeout response`, {
|
|
7820
|
+
"request url": request.url,
|
|
7821
|
+
"error stack": e.stack,
|
|
7822
|
+
}),
|
|
7823
|
+
);
|
|
7824
|
+
}
|
|
7825
|
+
});
|
|
7820
7826
|
const responseProperties = await Promise.race([
|
|
7821
7827
|
timeoutResponsePropertiesPromise,
|
|
7822
7828
|
routerResponsePropertiesPromise,
|
|
@@ -7829,6 +7835,21 @@ const startServer = async ({
|
|
|
7829
7835
|
// let it propagate to the caller that should catch this
|
|
7830
7836
|
throw e;
|
|
7831
7837
|
}
|
|
7838
|
+
// an error carrying its own response (a request body too large for
|
|
7839
|
+
// instance) is not an internal error: it is answered as such, whatever
|
|
7840
|
+
// the plugins
|
|
7841
|
+
if (e && typeof e.asResponse === "function") {
|
|
7842
|
+
const responseProperties = composeTwoResponses(
|
|
7843
|
+
{
|
|
7844
|
+
status: 500,
|
|
7845
|
+
headers: {
|
|
7846
|
+
"cache-control": "no-store",
|
|
7847
|
+
},
|
|
7848
|
+
},
|
|
7849
|
+
await e.asResponse(),
|
|
7850
|
+
);
|
|
7851
|
+
return finalizeResponseProperties(responseProperties);
|
|
7852
|
+
}
|
|
7832
7853
|
// internal error, create 500 response
|
|
7833
7854
|
if (
|
|
7834
7855
|
// stopOnInternalError stops server only if requestToResponse generated
|
|
@@ -7866,20 +7887,28 @@ const startServer = async ({
|
|
|
7866
7887
|
return finalizeResponseProperties(responseProperties);
|
|
7867
7888
|
}
|
|
7868
7889
|
};
|
|
7890
|
+
// When a response is sent before the request body was read (a 413, a 404
|
|
7891
|
+
// on an upload...) node keeps draining what the client sends so that the
|
|
7892
|
+
// client gets to read the response, as nginx does with its lingering close.
|
|
7893
|
+
// A client that has not finished sending after that delay is cut off:
|
|
7894
|
+
// draining a body forever is what the 413 was meant to avoid.
|
|
7895
|
+
const lingerAfterEarlyResponse = (nodeRequest) => {
|
|
7896
|
+
if (nodeRequest.complete) {
|
|
7897
|
+
return;
|
|
7898
|
+
}
|
|
7899
|
+
const lingerTimeout = setTimeout(() => {
|
|
7900
|
+
nodeRequest.socket.destroy();
|
|
7901
|
+
}, REQUEST_BODY_LINGER_MS).unref();
|
|
7902
|
+
nodeRequest.once("close", () => {
|
|
7903
|
+
clearTimeout(lingerTimeout);
|
|
7904
|
+
});
|
|
7905
|
+
};
|
|
7869
7906
|
const sendResponse = async (
|
|
7870
7907
|
responseStream,
|
|
7871
7908
|
responseProperties,
|
|
7872
7909
|
{ signal, request },
|
|
7873
7910
|
) => {
|
|
7874
|
-
// When "pushResponse" is called and the parent response has no body
|
|
7875
|
-
// the parent response is immediatly ended. It means child responses (pushed streams)
|
|
7876
|
-
// won't get a chance to be pushed.
|
|
7877
|
-
// To let a chance to pushed streams we wait a little before sending the response
|
|
7878
7911
|
const ignoreBody = request.method === "HEAD";
|
|
7879
|
-
const bodyIsEmpty = !responseProperties.body || ignoreBody;
|
|
7880
|
-
if (bodyIsEmpty && request.logger.hasPushChild) {
|
|
7881
|
-
await new Promise((resolve) => setTimeout(resolve));
|
|
7882
|
-
}
|
|
7883
7912
|
await writeNodeResponse(responseStream, responseProperties, {
|
|
7884
7913
|
signal,
|
|
7885
7914
|
ignoreBody,
|
|
@@ -7910,6 +7939,15 @@ const startServer = async ({
|
|
|
7910
7939
|
|
|
7911
7940
|
{
|
|
7912
7941
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
7942
|
+
const requestHost = nodeRequest.authority || nodeRequest.headers.host;
|
|
7943
|
+
if (!isHostAllowed(requestHost)) {
|
|
7944
|
+
logger.warn(
|
|
7945
|
+
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
7946
|
+
);
|
|
7947
|
+
nodeResponse.writeHead(403, { "content-type": "text/plain" });
|
|
7948
|
+
nodeResponse.end("Host not allowed");
|
|
7949
|
+
return;
|
|
7950
|
+
}
|
|
7913
7951
|
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
7914
7952
|
nodeResponse.writeHead(301, {
|
|
7915
7953
|
location: `${serverOrigin}${nodeRequest.url}`,
|
|
@@ -7928,164 +7966,29 @@ const startServer = async ({
|
|
|
7928
7966
|
|
|
7929
7967
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
7930
7968
|
prepareHandleRequestOperations(nodeRequest, nodeResponse);
|
|
7931
|
-
|
|
7932
|
-
signal: stopAbortSignal,
|
|
7933
|
-
serverOrigin,
|
|
7934
|
-
requestBodyLifetime,
|
|
7935
|
-
logger,
|
|
7936
|
-
nagle,
|
|
7937
|
-
});
|
|
7938
|
-
|
|
7969
|
+
let request;
|
|
7939
7970
|
try {
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
`response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
|
|
7946
|
-
);
|
|
7947
|
-
return;
|
|
7948
|
-
}
|
|
7949
|
-
if (!request.http2) {
|
|
7950
|
-
pushRequestLogger.warn(
|
|
7951
|
-
`response push ignored because request is not http2`,
|
|
7952
|
-
);
|
|
7953
|
-
return;
|
|
7954
|
-
}
|
|
7955
|
-
const canPushStream = testCanPushStream(nodeResponse.stream);
|
|
7956
|
-
if (!canPushStream.can) {
|
|
7957
|
-
pushRequestLogger.debug(
|
|
7958
|
-
`response push ignored because ${canPushStream.reason}`,
|
|
7959
|
-
);
|
|
7960
|
-
return;
|
|
7961
|
-
}
|
|
7962
|
-
|
|
7963
|
-
let preventedByPlugin = null;
|
|
7964
|
-
const prevent = () => {
|
|
7965
|
-
preventedByPlugin = serverPluginsController.getCurrentPlugin();
|
|
7966
|
-
};
|
|
7967
|
-
serverPluginsController.callHooksUntil(
|
|
7968
|
-
"onResponsePush",
|
|
7969
|
-
{ path, method },
|
|
7970
|
-
{ request, prevent },
|
|
7971
|
-
() => preventedByPlugin,
|
|
7972
|
-
);
|
|
7973
|
-
if (preventedByPlugin) {
|
|
7974
|
-
pushRequestLogger.debug(
|
|
7975
|
-
`response push prevented by "${preventedByPlugin.name}" plugin`,
|
|
7976
|
-
);
|
|
7977
|
-
return;
|
|
7978
|
-
}
|
|
7979
|
-
|
|
7980
|
-
const http2Stream = nodeResponse.stream;
|
|
7981
|
-
|
|
7982
|
-
// being able to push a stream is nice to have
|
|
7983
|
-
// so when it fails it's not critical
|
|
7984
|
-
const onPushStreamError = (e) => {
|
|
7985
|
-
pushRequestLogger.error(
|
|
7986
|
-
createDetailedMessage(
|
|
7987
|
-
`An error occured while pushing a stream to the response for ${request.resource}`,
|
|
7988
|
-
{
|
|
7989
|
-
"error stack": e.stack,
|
|
7990
|
-
},
|
|
7991
|
-
),
|
|
7992
|
-
);
|
|
7993
|
-
};
|
|
7994
|
-
|
|
7995
|
-
// not aborted, let's try to push a stream into that response
|
|
7996
|
-
// https://nodejs.org/docs/latest-v16.x/api/http2.html#http2streampushstreamheaders-options-callback
|
|
7997
|
-
let pushStream;
|
|
7998
|
-
try {
|
|
7999
|
-
pushStream = await new Promise((resolve, reject) => {
|
|
8000
|
-
http2Stream.pushStream(
|
|
8001
|
-
{
|
|
8002
|
-
":path": path,
|
|
8003
|
-
...(method ? { ":method": method } : {}),
|
|
8004
|
-
},
|
|
8005
|
-
async (
|
|
8006
|
-
error,
|
|
8007
|
-
pushStream,
|
|
8008
|
-
// headers
|
|
8009
|
-
) => {
|
|
8010
|
-
if (error) {
|
|
8011
|
-
reject(error);
|
|
8012
|
-
}
|
|
8013
|
-
resolve(pushStream);
|
|
8014
|
-
},
|
|
8015
|
-
);
|
|
8016
|
-
});
|
|
8017
|
-
} catch (e) {
|
|
8018
|
-
onPushStreamError(e);
|
|
8019
|
-
return;
|
|
8020
|
-
}
|
|
8021
|
-
|
|
8022
|
-
const abortController = new AbortController();
|
|
8023
|
-
// It's possible to get NGHTTP2_REFUSED_STREAM errors here
|
|
8024
|
-
// https://github.com/nodejs/node/issues/20824
|
|
8025
|
-
const pushErrorCallback = (error) => {
|
|
8026
|
-
onPushStreamError(error);
|
|
8027
|
-
abortController.abort();
|
|
8028
|
-
};
|
|
8029
|
-
pushStream.on("error", pushErrorCallback);
|
|
8030
|
-
sendResponseOperation.addEndCallback(() => {
|
|
8031
|
-
pushStream.removeListener("error", onPushStreamError);
|
|
8032
|
-
});
|
|
8033
|
-
|
|
8034
|
-
await sendResponseOperation.withSignal(async (signal) => {
|
|
8035
|
-
const pushResponseOperation = Abort.startOperation();
|
|
8036
|
-
pushResponseOperation.addAbortSignal(signal);
|
|
8037
|
-
pushResponseOperation.addAbortSignal(abortController.signal);
|
|
8038
|
-
|
|
8039
|
-
const pushRequest = createPushRequest(request, {
|
|
8040
|
-
signal: pushResponseOperation.signal,
|
|
8041
|
-
pathname: path,
|
|
8042
|
-
method,
|
|
8043
|
-
logger: pushRequestLogger,
|
|
8044
|
-
});
|
|
8045
|
-
|
|
8046
|
-
try {
|
|
8047
|
-
const responseProperties = await getResponseProperties(
|
|
8048
|
-
pushRequest,
|
|
8049
|
-
{
|
|
8050
|
-
pushResponse: () => {
|
|
8051
|
-
pushRequest.logger.warn(
|
|
8052
|
-
`response push ignored because nested push is not supported`,
|
|
8053
|
-
);
|
|
8054
|
-
},
|
|
8055
|
-
},
|
|
8056
|
-
);
|
|
8057
|
-
if (!abortController.signal.aborted) {
|
|
8058
|
-
if (pushStream.destroyed) {
|
|
8059
|
-
abortController.abort();
|
|
8060
|
-
} else if (!http2Stream.pushAllowed) {
|
|
8061
|
-
abortController.abort();
|
|
8062
|
-
} else if (responseProperties.requestAborted) {
|
|
8063
|
-
} else {
|
|
8064
|
-
const responseLength =
|
|
8065
|
-
responseProperties.headers["content-length"] || 0;
|
|
8066
|
-
const { effectiveRecvDataLength, remoteWindowSize } =
|
|
8067
|
-
http2Stream.session.state;
|
|
8068
|
-
if (
|
|
8069
|
-
effectiveRecvDataLength + responseLength >
|
|
8070
|
-
remoteWindowSize
|
|
8071
|
-
) {
|
|
8072
|
-
pushRequest.logger.debug(
|
|
8073
|
-
`Aborting stream to prevent exceeding remoteWindowSize`,
|
|
8074
|
-
);
|
|
8075
|
-
abortController.abort();
|
|
8076
|
-
}
|
|
8077
|
-
}
|
|
8078
|
-
}
|
|
8079
|
-
await sendResponse(pushStream, responseProperties, {
|
|
8080
|
-
signal: pushResponseOperation.signal,
|
|
8081
|
-
request: pushRequest,
|
|
8082
|
-
});
|
|
8083
|
-
} finally {
|
|
8084
|
-
await pushResponseOperation.end();
|
|
8085
|
-
}
|
|
8086
|
-
});
|
|
8087
|
-
},
|
|
7971
|
+
request = fromNodeRequest(nodeRequest, {
|
|
7972
|
+
signal: stopAbortSignal,
|
|
7973
|
+
serverOrigin,
|
|
7974
|
+
logger,
|
|
7975
|
+
requestBodyMaxSize,
|
|
8088
7976
|
});
|
|
7977
|
+
} catch (e) {
|
|
7978
|
+
// the request cannot even be read: there is nothing to hand to the routes
|
|
7979
|
+
logger.error(
|
|
7980
|
+
createDetailedMessage(`error while reading request`, {
|
|
7981
|
+
"request url": nodeRequest.url,
|
|
7982
|
+
"error stack": e.stack,
|
|
7983
|
+
}),
|
|
7984
|
+
);
|
|
7985
|
+
nodeResponse.writeHead(500);
|
|
7986
|
+
nodeResponse.end();
|
|
7987
|
+
return;
|
|
7988
|
+
}
|
|
7989
|
+
|
|
7990
|
+
try {
|
|
7991
|
+
const responseProperties = await getResponseProperties(request);
|
|
8089
7992
|
const webSocketHandler = getWebSocketHandler(responseProperties);
|
|
8090
7993
|
if (webSocketHandler) {
|
|
8091
7994
|
throw new Error(
|
|
@@ -8099,6 +8002,7 @@ const startServer = async ({
|
|
|
8099
8002
|
signal: sendResponseOperation.signal,
|
|
8100
8003
|
request,
|
|
8101
8004
|
});
|
|
8005
|
+
lingerAfterEarlyResponse(nodeRequest);
|
|
8102
8006
|
} finally {
|
|
8103
8007
|
await sendResponseOperation.end();
|
|
8104
8008
|
}
|
|
@@ -8135,22 +8039,39 @@ const startServer = async ({
|
|
|
8135
8039
|
};
|
|
8136
8040
|
// https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
|
|
8137
8041
|
const upgradeEventHandler = async (nodeRequest, socket, head) => {
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
8042
|
+
const requestHost = nodeRequest.headers.host;
|
|
8043
|
+
if (!isHostAllowed(requestHost)) {
|
|
8044
|
+
logger.warn(
|
|
8045
|
+
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
8046
|
+
);
|
|
8047
|
+
socket.write(
|
|
8048
|
+
`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: 16\r\n\r\nHost not allowed`,
|
|
8049
|
+
);
|
|
8050
|
+
socket.destroy();
|
|
8051
|
+
return;
|
|
8052
|
+
}
|
|
8053
|
+
let request;
|
|
8054
|
+
try {
|
|
8055
|
+
request = fromNodeRequest(nodeRequest, {
|
|
8056
|
+
signal: stopAbortSignal,
|
|
8057
|
+
serverOrigin,
|
|
8058
|
+
logger,
|
|
8059
|
+
requestBodyMaxSize,
|
|
8060
|
+
});
|
|
8061
|
+
} catch (e) {
|
|
8062
|
+
logger.error(
|
|
8063
|
+
createDetailedMessage(`error while reading upgrade request`, {
|
|
8064
|
+
"request url": nodeRequest.url,
|
|
8065
|
+
"error stack": e.stack,
|
|
8066
|
+
}),
|
|
8067
|
+
);
|
|
8068
|
+
socket.write(`HTTP/1.1 500 Internal Server Error\r\n\r\n`);
|
|
8069
|
+
socket.destroy();
|
|
8070
|
+
return;
|
|
8071
|
+
}
|
|
8145
8072
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
8146
8073
|
prepareHandleRequestOperations(nodeRequest, socket);
|
|
8147
|
-
const responseProperties = await getResponseProperties(request
|
|
8148
|
-
pushResponse: () => {
|
|
8149
|
-
request.logger.warn(
|
|
8150
|
-
`pushResponse ignored because it's not supported in websocket`,
|
|
8151
|
-
);
|
|
8152
|
-
},
|
|
8153
|
-
});
|
|
8074
|
+
const responseProperties = await getResponseProperties(request);
|
|
8154
8075
|
if (receiveRequestOperation.signal.aborted) {
|
|
8155
8076
|
return;
|
|
8156
8077
|
}
|
|
@@ -8202,7 +8123,7 @@ const startServer = async ({
|
|
|
8202
8123
|
}
|
|
8203
8124
|
return;
|
|
8204
8125
|
};
|
|
8205
|
-
// see
|
|
8126
|
+
// see server_polyglot.js, upgrade must be listened on https server when used
|
|
8206
8127
|
const facadeServer = nodeServer._tlsServer || nodeServer;
|
|
8207
8128
|
const removeUpgradeCallback = listenEvent(
|
|
8208
8129
|
facadeServer,
|
|
@@ -8219,9 +8140,9 @@ const startServer = async ({
|
|
|
8219
8140
|
}
|
|
8220
8141
|
|
|
8221
8142
|
if (startLog) {
|
|
8222
|
-
if (serverOrigins.
|
|
8143
|
+
if (serverOrigins.externalip) {
|
|
8223
8144
|
logger.info(
|
|
8224
|
-
`${serverName} started at ${serverOrigins.local} (${serverOrigins.
|
|
8145
|
+
`${serverName} started at ${serverOrigins.local} (${serverOrigins.externalip})`,
|
|
8225
8146
|
);
|
|
8226
8147
|
} else {
|
|
8227
8148
|
logger.info(`${serverName} started at ${serverOrigins.local}`);
|
|
@@ -8264,38 +8185,18 @@ const createNodeServer = async ({
|
|
|
8264
8185
|
http1Allowed,
|
|
8265
8186
|
});
|
|
8266
8187
|
}
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8188
|
+
return createSecureServer({
|
|
8189
|
+
certificate,
|
|
8190
|
+
privateKey,
|
|
8191
|
+
http2,
|
|
8192
|
+
http1Allowed,
|
|
8271
8193
|
});
|
|
8272
8194
|
}
|
|
8273
8195
|
const { createServer } = await import("node:http");
|
|
8274
8196
|
return createServer();
|
|
8275
8197
|
};
|
|
8276
8198
|
|
|
8277
|
-
const
|
|
8278
|
-
if (!http2Stream.pushAllowed) {
|
|
8279
|
-
return {
|
|
8280
|
-
can: false,
|
|
8281
|
-
reason: `stream.pushAllowed is false`,
|
|
8282
|
-
};
|
|
8283
|
-
}
|
|
8284
|
-
|
|
8285
|
-
// See https://nodejs.org/dist/latest-v16.x/docs/api/http2.html#http2sessionstate
|
|
8286
|
-
// And https://github.com/google/node-h2-auto-push/blob/67a36c04cbbd6da7b066a4e8d361c593d38853a4/src/index.ts#L100-L106
|
|
8287
|
-
const { remoteWindowSize } = http2Stream.session.state;
|
|
8288
|
-
if (remoteWindowSize === 0) {
|
|
8289
|
-
return {
|
|
8290
|
-
can: false,
|
|
8291
|
-
reason: `no more remoteWindowSize`,
|
|
8292
|
-
};
|
|
8293
|
-
}
|
|
8294
|
-
|
|
8295
|
-
return {
|
|
8296
|
-
can: true,
|
|
8297
|
-
};
|
|
8298
|
-
};
|
|
8199
|
+
const REQUEST_BODY_LINGER_MS = 5_000;
|
|
8299
8200
|
|
|
8300
8201
|
const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
8301
8202
|
SIGHUP: STOP_REASON_PROCESS_SIGHUP,
|
|
@@ -8306,7 +8207,30 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
|
8306
8207
|
};
|
|
8307
8208
|
|
|
8308
8209
|
const internalErrorHtmlFileUrl = import.meta.resolve("./client/error_handler/500.html");
|
|
8210
|
+
let internalErrorHtmlTemplate;
|
|
8211
|
+
const readInternalErrorHtmlTemplate = () => {
|
|
8212
|
+
if (internalErrorHtmlTemplate === undefined) {
|
|
8213
|
+
internalErrorHtmlTemplate = readFileSync(
|
|
8214
|
+
new URL(internalErrorHtmlFileUrl),
|
|
8215
|
+
"utf8",
|
|
8216
|
+
);
|
|
8217
|
+
}
|
|
8218
|
+
return internalErrorHtmlTemplate;
|
|
8219
|
+
};
|
|
8309
8220
|
|
|
8221
|
+
/**
|
|
8222
|
+
* Server plugin turning an error thrown while handling a request into a 500
|
|
8223
|
+
* response (html, text or json depending on what the request accepts).
|
|
8224
|
+
* Without a plugin handling errors, a route that throws makes the process
|
|
8225
|
+
* exit. Put it last: it catches every error, so plugins handling a subset of
|
|
8226
|
+
* them must come before. (An error exposing `asResponse()` never reaches it:
|
|
8227
|
+
* the server answers it directly.)
|
|
8228
|
+
*
|
|
8229
|
+
* @param {Object} [params]
|
|
8230
|
+
* @param {boolean} [params.sendErrorDetails=false] - Put the error stack (and
|
|
8231
|
+
* its own properties for json) in the response. Development only: a stack
|
|
8232
|
+
* reveals file paths and code.
|
|
8233
|
+
*/
|
|
8310
8234
|
const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
8311
8235
|
return {
|
|
8312
8236
|
name: "jsenv:error_handler",
|
|
@@ -8315,9 +8239,6 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8315
8239
|
serverInternalError === null ||
|
|
8316
8240
|
(typeof serverInternalError !== "object" &&
|
|
8317
8241
|
typeof serverInternalError !== "function");
|
|
8318
|
-
if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
|
|
8319
|
-
return serverInternalError.asResponse();
|
|
8320
|
-
}
|
|
8321
8242
|
const dataToSend = serverInternalErrorIsAPrimitive
|
|
8322
8243
|
? {
|
|
8323
8244
|
code: "VALUE_THROWED",
|
|
@@ -8336,18 +8257,14 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8336
8257
|
const availableContentTypes = {
|
|
8337
8258
|
"text/html": () => {
|
|
8338
8259
|
const renderHtmlForErrorWithoutDetails = () => {
|
|
8339
|
-
return `<p>Details not available: to enable them use
|
|
8260
|
+
return `<p>Details not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).</p>`;
|
|
8340
8261
|
};
|
|
8341
8262
|
const renderHtmlForErrorWithDetails = () => {
|
|
8342
8263
|
return errorToHTML(serverInternalError);
|
|
8343
8264
|
};
|
|
8344
8265
|
|
|
8345
|
-
const internalErrorHtmlTemplate = readFileSync(
|
|
8346
|
-
new URL(internalErrorHtmlFileUrl),
|
|
8347
|
-
"utf8",
|
|
8348
|
-
);
|
|
8349
8266
|
const internalErrorHtml = replacePlaceholdersInHtml(
|
|
8350
|
-
|
|
8267
|
+
readInternalErrorHtmlTemplate(),
|
|
8351
8268
|
{
|
|
8352
8269
|
errorMessage: serverInternalErrorIsAPrimitive
|
|
8353
8270
|
? `Code inside server has thrown a literal.`
|
|
@@ -8381,7 +8298,7 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8381
8298
|
internalErrorMessage += `\n${serverInternalError.stack}`;
|
|
8382
8299
|
}
|
|
8383
8300
|
} else {
|
|
8384
|
-
internalErrorMessage += `\nDetails not available: to enable them use
|
|
8301
|
+
internalErrorMessage += `\nDetails not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).`;
|
|
8385
8302
|
}
|
|
8386
8303
|
|
|
8387
8304
|
return {
|
|
@@ -8412,6 +8329,30 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8412
8329
|
};
|
|
8413
8330
|
};
|
|
8414
8331
|
|
|
8332
|
+
/**
|
|
8333
|
+
* A response whose body is written over time (long polling, progress
|
|
8334
|
+
* reporting). Return it from a route `fetch`: the headers are sent right away
|
|
8335
|
+
* and the handler writes the body chunks whenever it wants.
|
|
8336
|
+
*
|
|
8337
|
+
* @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
|
|
8338
|
+
* Receives `write` and `end`. If it returns a function, that function runs
|
|
8339
|
+
* when the client disconnects before `end` was called (cleanup).
|
|
8340
|
+
* @param {Object} [init]
|
|
8341
|
+
* @param {number} [init.status=200]
|
|
8342
|
+
* @param {string} [init.statusText]
|
|
8343
|
+
* @param {Object} [init.headers] - `content-type` decides whether the first
|
|
8344
|
+
* (empty) chunk is text or binary; defaults to text/plain.
|
|
8345
|
+
*
|
|
8346
|
+
* @example
|
|
8347
|
+
* {
|
|
8348
|
+
* endpoint: "GET /progress",
|
|
8349
|
+
* fetch: () => new ProgressiveResponse(({ write, end }) => {
|
|
8350
|
+
* const interval = setInterval(() => write("."), 100);
|
|
8351
|
+
* setTimeout(() => { clearInterval(interval); end(); }, 1000);
|
|
8352
|
+
* return () => clearInterval(interval);
|
|
8353
|
+
* }),
|
|
8354
|
+
* }
|
|
8355
|
+
*/
|
|
8415
8356
|
class ProgressiveResponse {
|
|
8416
8357
|
constructor(responseBodyHandler, { status = 200, statusText, headers } = {}) {
|
|
8417
8358
|
const contentType = headers ? headers["content-type"] : "text/plain";
|
|
@@ -8463,6 +8404,32 @@ const jsenvAccessControlAllowedMethods = [
|
|
|
8463
8404
|
"OPTIONS",
|
|
8464
8405
|
];
|
|
8465
8406
|
|
|
8407
|
+
/**
|
|
8408
|
+
* Server plugin adding the CORS response headers to every response, including
|
|
8409
|
+
* errors (a browser treats a 500 without them as a CORS failure). Disabled
|
|
8410
|
+
* (returns no plugin) unless `accessControlAllowRequestOrigin` is true or
|
|
8411
|
+
* `accessControlAllowedOrigins` is non empty.
|
|
8412
|
+
*
|
|
8413
|
+
* @param {Object} [params]
|
|
8414
|
+
* @param {Array<string>} [params.accessControlAllowedOrigins=[]] - Origins allowed to
|
|
8415
|
+
* read responses. `*` stands for any run of characters except "/":
|
|
8416
|
+
* `"https://pr-*-my-app.fly.dev"` matches every preview deployment.
|
|
8417
|
+
* @param {Array<string>} [params.accessControlAllowedMethods] - Defaults to
|
|
8418
|
+
* GET, POST, PUT, DELETE, OPTIONS.
|
|
8419
|
+
* @param {Array<string>} [params.accessControlAllowedHeaders] - Defaults to `["x-requested-with"]`.
|
|
8420
|
+
* @param {boolean} [params.accessControlAllowRequestOrigin=false] - Reflect any request
|
|
8421
|
+
* origin, whatever `accessControlAllowedOrigins` says.
|
|
8422
|
+
* @param {boolean} [params.accessControlAllowRequestMethod=false] - Also allow the method a
|
|
8423
|
+
* preflight asks for (`access-control-request-method`).
|
|
8424
|
+
* @param {boolean} [params.accessControlAllowRequestHeaders=false] - Also allow the headers a
|
|
8425
|
+
* preflight asks for (`access-control-request-headers`).
|
|
8426
|
+
* @param {boolean} [params.accessControlAllowCredentials=false] - Send
|
|
8427
|
+
* `access-control-allow-credentials: true`.
|
|
8428
|
+
* @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
|
|
8429
|
+
* @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin` so the
|
|
8430
|
+
* allowed origin can read resource timing.
|
|
8431
|
+
* @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
|
|
8432
|
+
*/
|
|
8466
8433
|
const serverPluginCORS = ({
|
|
8467
8434
|
accessControlAllowedOrigins = [],
|
|
8468
8435
|
accessControlAllowedMethods = jsenvAccessControlAllowedMethods,
|
|
@@ -8576,12 +8543,7 @@ const generateAccessControlHeaders = ({
|
|
|
8576
8543
|
// If no origin matches we fall back to "*" (only when not using credentials).
|
|
8577
8544
|
let allowOrigin = null;
|
|
8578
8545
|
|
|
8579
|
-
const requestOrigin =
|
|
8580
|
-
"origin" in headers && headers.origin !== "null"
|
|
8581
|
-
? headers.origin
|
|
8582
|
-
: "referer" in headers
|
|
8583
|
-
? new URL(headers.referer).origin
|
|
8584
|
-
: null;
|
|
8546
|
+
const requestOrigin = readRequestOrigin(headers);
|
|
8585
8547
|
|
|
8586
8548
|
if (requestOrigin) {
|
|
8587
8549
|
if (allowedOriginChecker.isAllowed(requestOrigin)) {
|
|
@@ -8642,6 +8604,18 @@ const generateAccessControlHeaders = ({
|
|
|
8642
8604
|
};
|
|
8643
8605
|
};
|
|
8644
8606
|
|
|
8607
|
+
// the referer is a fallback for clients not sending "origin"; it comes from
|
|
8608
|
+
// the network and may not be a url at all
|
|
8609
|
+
const readRequestOrigin = (headers) => {
|
|
8610
|
+
if ("origin" in headers && headers.origin !== "null") {
|
|
8611
|
+
return headers.origin;
|
|
8612
|
+
}
|
|
8613
|
+
if ("referer" in headers && URL.canParse(headers.referer)) {
|
|
8614
|
+
return new URL(headers.referer).origin;
|
|
8615
|
+
}
|
|
8616
|
+
return null;
|
|
8617
|
+
};
|
|
8618
|
+
|
|
8645
8619
|
const ETAG_FOR_EMPTY_CONTENT = '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"';
|
|
8646
8620
|
|
|
8647
8621
|
const bufferToEtag = (buffer) => {
|
|
@@ -8663,6 +8637,17 @@ const bufferToEtag = (buffer) => {
|
|
|
8663
8637
|
return `"${length.toString(16)}-${hashBase64StringSubset}"`;
|
|
8664
8638
|
};
|
|
8665
8639
|
|
|
8640
|
+
/**
|
|
8641
|
+
* Response listing a directory, as json (an array of names) or as an html
|
|
8642
|
+
* page of links depending on what the request accepts (json by default).
|
|
8643
|
+
*
|
|
8644
|
+
* @param {string|URL} url - The directory.
|
|
8645
|
+
* @param {Object} [params]
|
|
8646
|
+
* @param {Object} [params.headers={}] - Request headers, read for `accept`.
|
|
8647
|
+
* @param {string|URL} [params.rootDirectoryUrl] - Directory served at "/", so
|
|
8648
|
+
* that the html links are relative to the server.
|
|
8649
|
+
* @returns {{ status: number, headers: Object, body: string }}
|
|
8650
|
+
*/
|
|
8666
8651
|
const fetchDirectory = (
|
|
8667
8652
|
url,
|
|
8668
8653
|
{ headers = {}, rootDirectoryUrl } = {},
|
|
@@ -8681,7 +8666,7 @@ const fetchDirectory = (
|
|
|
8681
8666
|
status: 200,
|
|
8682
8667
|
headers: {
|
|
8683
8668
|
"content-type": "application/json",
|
|
8684
|
-
"content-length": directoryContentJson
|
|
8669
|
+
"content-length": Buffer.byteLength(directoryContentJson),
|
|
8685
8670
|
},
|
|
8686
8671
|
body: directoryContentJson,
|
|
8687
8672
|
};
|
|
@@ -8696,7 +8681,7 @@ const fetchDirectory = (
|
|
|
8696
8681
|
</head>
|
|
8697
8682
|
|
|
8698
8683
|
<body>
|
|
8699
|
-
<h1>Content of directory ${url}</h1>
|
|
8684
|
+
<h1>Content of directory ${escapeHtml(url)}</h1>
|
|
8700
8685
|
<ul>
|
|
8701
8686
|
${directoryContentArray.map((filename) => {
|
|
8702
8687
|
const fileUrlObject = new URL(filename, url);
|
|
@@ -8707,8 +8692,9 @@ const fetchDirectory = (
|
|
|
8707
8692
|
if (lstatSync(fileUrlObject).isDirectory()) {
|
|
8708
8693
|
fileUrlRelativeToServer += "/";
|
|
8709
8694
|
}
|
|
8695
|
+
const linkHtml = escapeHtml(fileUrlRelativeToServer);
|
|
8710
8696
|
return `<li>
|
|
8711
|
-
<a href="/${
|
|
8697
|
+
<a href="/${linkHtml}">${linkHtml}</a>
|
|
8712
8698
|
</li>`;
|
|
8713
8699
|
}).join(`
|
|
8714
8700
|
`)}
|
|
@@ -8733,24 +8719,30 @@ const fetchDirectory = (
|
|
|
8733
8719
|
return responseProducers[bestContentType || "application/json"]();
|
|
8734
8720
|
};
|
|
8735
8721
|
|
|
8736
|
-
|
|
8722
|
+
// The file path belongs to the server machine: it joins the status text only
|
|
8723
|
+
// when the server was started with canExposeSensitiveData.
|
|
8724
|
+
const convertFileSystemErrorToResponseProperties = (
|
|
8725
|
+
error,
|
|
8726
|
+
{ canExposeSensitiveData },
|
|
8727
|
+
) => {
|
|
8728
|
+
const location = canExposeSensitiveData ? ` at ${error.path}` : "";
|
|
8737
8729
|
// https://iojs.org/api/errors.html#errors_eacces_permission_denied
|
|
8738
8730
|
if (isErrorWithCode(error, "EACCES")) {
|
|
8739
8731
|
return {
|
|
8740
8732
|
status: 403,
|
|
8741
|
-
statusText: `EACCES: No permission to read file
|
|
8733
|
+
statusText: `EACCES: No permission to read file${location}`,
|
|
8742
8734
|
};
|
|
8743
8735
|
}
|
|
8744
8736
|
if (isErrorWithCode(error, "EPERM")) {
|
|
8745
8737
|
return {
|
|
8746
8738
|
status: 403,
|
|
8747
|
-
statusText: `EPERM: No permission to read file
|
|
8739
|
+
statusText: `EPERM: No permission to read file${location}`,
|
|
8748
8740
|
};
|
|
8749
8741
|
}
|
|
8750
8742
|
if (isErrorWithCode(error, "ENOENT")) {
|
|
8751
8743
|
return {
|
|
8752
8744
|
status: 404,
|
|
8753
|
-
statusText: `ENOENT: File not found
|
|
8745
|
+
statusText: `ENOENT: File not found${location}`,
|
|
8754
8746
|
};
|
|
8755
8747
|
}
|
|
8756
8748
|
// file access may be temporarily blocked
|
|
@@ -8758,7 +8750,7 @@ const convertFileSystemErrorToResponseProperties = (error) => {
|
|
|
8758
8750
|
if (isErrorWithCode(error, "EBUSY")) {
|
|
8759
8751
|
return {
|
|
8760
8752
|
status: 503,
|
|
8761
|
-
statusText: `EBUSY: File is busy
|
|
8753
|
+
statusText: `EBUSY: File is busy${location}`,
|
|
8762
8754
|
headers: {
|
|
8763
8755
|
"retry-after": 0.01, // retry in 10ms
|
|
8764
8756
|
},
|
|
@@ -8777,7 +8769,7 @@ const convertFileSystemErrorToResponseProperties = (error) => {
|
|
|
8777
8769
|
if (isErrorWithCode(error, "EISDIR")) {
|
|
8778
8770
|
return {
|
|
8779
8771
|
status: 500,
|
|
8780
|
-
statusText: `EISDIR: Unexpected directory operation
|
|
8772
|
+
statusText: `EISDIR: Unexpected directory operation${location}`,
|
|
8781
8773
|
};
|
|
8782
8774
|
}
|
|
8783
8775
|
return null;
|
|
@@ -8819,18 +8811,66 @@ const fileSystemPathToUrl = (value) => {
|
|
|
8819
8811
|
};
|
|
8820
8812
|
|
|
8821
8813
|
/*
|
|
8822
|
-
*
|
|
8823
|
-
*
|
|
8824
|
-
*
|
|
8814
|
+
* Turns a request for a file into response properties: content type, client
|
|
8815
|
+
* cache (etag or mtime), compression, directory listing or main file. The
|
|
8816
|
+
* response is a plain object so that it composes with what the router and
|
|
8817
|
+
* the plugins add.
|
|
8825
8818
|
*/
|
|
8826
8819
|
|
|
8827
8820
|
|
|
8821
|
+
/**
|
|
8822
|
+
* A route `fetch` serving the files of a directory. The part of the url
|
|
8823
|
+
* captured by the endpoint `*` (or the whole resource when there is none) is
|
|
8824
|
+
* resolved inside `directoryUrl`; a url escaping it is answered with 403.
|
|
8825
|
+
*
|
|
8826
|
+
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
8827
|
+
* @param {Object} [options] - See {@link fetchFileSystem}.
|
|
8828
|
+
* @returns {(request: Object, helpers: Object) => Promise<Object|null>}
|
|
8829
|
+
*
|
|
8830
|
+
* @example
|
|
8831
|
+
* {
|
|
8832
|
+
* endpoint: "GET /assets/*",
|
|
8833
|
+
* fetch: createFileSystemFetch(import.meta.resolve("./assets/"), { etagEnabled: true }),
|
|
8834
|
+
* }
|
|
8835
|
+
*/
|
|
8828
8836
|
const createFileSystemFetch = (directoryUrl, options) => {
|
|
8829
8837
|
return (request, helpers) => {
|
|
8830
8838
|
return fetchFileSystem(request, helpers, directoryUrl, options);
|
|
8831
8839
|
};
|
|
8832
8840
|
};
|
|
8833
8841
|
|
|
8842
|
+
/**
|
|
8843
|
+
* Serve a file of `directoryUrl` for `request`. Declines (`null`) any method
|
|
8844
|
+
* other than GET and HEAD. File paths appear in the status text only when the
|
|
8845
|
+
* server runs with `canExposeSensitiveData`.
|
|
8846
|
+
*
|
|
8847
|
+
* @param {Object} request - The request given to a route `fetch`.
|
|
8848
|
+
* @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
|
|
8849
|
+
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
8850
|
+
* @param {Object} [options]
|
|
8851
|
+
* @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
|
|
8852
|
+
* as a fallback, for extension-less urls that do not exist (client side routing).
|
|
8853
|
+
* @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
|
|
8854
|
+
* answer 304 to a matching `if-none-match`.
|
|
8855
|
+
* @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
|
|
8856
|
+
* file stats change) so that a file is hashed once.
|
|
8857
|
+
* @param {number} [options.etagMemoryMaxSize=1000] - Files remembered at most.
|
|
8858
|
+
* @param {boolean} [options.mtimeEnabled=false] - Send `last-modified` and answer 304 to
|
|
8859
|
+
* `if-modified-since` (second precision). Ignored when `etagEnabled` is set.
|
|
8860
|
+
* @param {boolean} [options.compressionEnabled=false] - Compress textual files with brotli,
|
|
8861
|
+
* gzip or deflate according to `accept-encoding`.
|
|
8862
|
+
* @param {number} [options.compressionSizeThreshold=1024] - Files smaller than that (bytes) are not compressed.
|
|
8863
|
+
* @param {string|((request: Object) => string)} [options.cacheControl] - The `cache-control`
|
|
8864
|
+
* response header. Defaults to 30 days immutable for versioned urls (see `isVersioned`) and
|
|
8865
|
+
* `private,max-age=0,must-revalidate` otherwise. `"no-store"` disables etag and mtime.
|
|
8866
|
+
* @param {(request: Object) => boolean} [options.isVersioned] - Tells if a url is versioned
|
|
8867
|
+
* (its content never changes). Defaults to "has a `v` search param".
|
|
8868
|
+
* @param {boolean} [options.canReadDirectory=false] - Answer a directory with its listing
|
|
8869
|
+
* (json or html, see {@link fetchDirectory}) instead of 403.
|
|
8870
|
+
* @param {() => string|URL|null} [options.ENOENTFallback] - Called when the file does not exist;
|
|
8871
|
+
* the file url it returns is served instead.
|
|
8872
|
+
* @returns {Promise<Object|null>} Response properties, `null` when the method is not GET/HEAD.
|
|
8873
|
+
*/
|
|
8834
8874
|
const fetchFileSystem = async (
|
|
8835
8875
|
request,
|
|
8836
8876
|
helpers,
|
|
@@ -8863,6 +8903,9 @@ const fetchFileSystem = async (
|
|
|
8863
8903
|
if (!directoryUrlString.endsWith("/")) {
|
|
8864
8904
|
directoryUrlString = `${directoryUrlString}/`;
|
|
8865
8905
|
}
|
|
8906
|
+
const canExposeSensitiveData = Boolean(
|
|
8907
|
+
helpers && helpers.canExposeSensitiveData,
|
|
8908
|
+
);
|
|
8866
8909
|
let resource;
|
|
8867
8910
|
if (request.params && "0" in request.params) {
|
|
8868
8911
|
resource = request.params["0"];
|
|
@@ -8913,15 +8956,14 @@ const fetchFileSystem = async (
|
|
|
8913
8956
|
const serveFile = async (fileUrl) => {
|
|
8914
8957
|
try {
|
|
8915
8958
|
const readStatTiming = helpers?.timing("file service>read file stat");
|
|
8916
|
-
const fileStat =
|
|
8959
|
+
const fileStat = await stat(new URL(fileUrl));
|
|
8917
8960
|
readStatTiming?.end();
|
|
8918
8961
|
|
|
8919
8962
|
if (fileStat.isDirectory()) {
|
|
8920
8963
|
if (canReadDirectory) {
|
|
8921
8964
|
return fetchDirectory(fileUrl, {
|
|
8922
8965
|
headers: request.headers,
|
|
8923
|
-
|
|
8924
|
-
rootDirectoryUrl: directoryUrl,
|
|
8966
|
+
rootDirectoryUrl: directoryUrlString,
|
|
8925
8967
|
});
|
|
8926
8968
|
}
|
|
8927
8969
|
if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
|
|
@@ -9017,7 +9059,9 @@ const fetchFileSystem = async (
|
|
|
9017
9059
|
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
9018
9060
|
},
|
|
9019
9061
|
},
|
|
9020
|
-
convertFileSystemErrorToResponseProperties(e
|
|
9062
|
+
convertFileSystemErrorToResponseProperties(e, {
|
|
9063
|
+
canExposeSensitiveData,
|
|
9064
|
+
}) || {},
|
|
9021
9065
|
);
|
|
9022
9066
|
}
|
|
9023
9067
|
};
|
|
@@ -9119,20 +9163,13 @@ const computeEtag = async ({
|
|
|
9119
9163
|
return etagMemoryEntry.eTag;
|
|
9120
9164
|
}
|
|
9121
9165
|
}
|
|
9122
|
-
const fileContentAsBuffer = await new
|
|
9123
|
-
readFile(new URL(fileUrl), (error, buffer) => {
|
|
9124
|
-
if (error) {
|
|
9125
|
-
reject(error);
|
|
9126
|
-
} else {
|
|
9127
|
-
resolve(buffer);
|
|
9128
|
-
}
|
|
9129
|
-
});
|
|
9130
|
-
});
|
|
9166
|
+
const fileContentAsBuffer = await readFile(new URL(fileUrl));
|
|
9131
9167
|
const eTag = bufferToEtag(fileContentAsBuffer);
|
|
9132
9168
|
if (etagMemory) {
|
|
9133
9169
|
if (ETAG_MEMORY_MAP.size >= etagMemoryMaxSize) {
|
|
9134
|
-
|
|
9135
|
-
ETAG_MEMORY_MAP.
|
|
9170
|
+
// a Map iterates in insertion order: the first key is the oldest entry
|
|
9171
|
+
const oldestKey = ETAG_MEMORY_MAP.keys().next().value;
|
|
9172
|
+
ETAG_MEMORY_MAP.delete(oldestKey);
|
|
9136
9173
|
}
|
|
9137
9174
|
ETAG_MEMORY_MAP.set(fileUrl, { fileStat, eTag });
|
|
9138
9175
|
}
|
|
@@ -9224,10 +9261,23 @@ const fileUrlToReadableStream = (fileUrl) => {
|
|
|
9224
9261
|
});
|
|
9225
9262
|
};
|
|
9226
9263
|
|
|
9264
|
+
// Brotli's default quality (11) is meant for compressing assets once, ahead
|
|
9265
|
+
// of time: it is an order of magnitude slower than gzip. For compression at
|
|
9266
|
+
// request time, quality 4 costs about as much cpu as gzip while still
|
|
9267
|
+
// compressing better.
|
|
9268
|
+
const BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION = 4;
|
|
9269
|
+
|
|
9227
9270
|
const availableCompressionFormats = {
|
|
9228
9271
|
br: async (fileReadableStream) => {
|
|
9229
|
-
const { createBrotliCompress } = await import("node:zlib");
|
|
9230
|
-
return fileReadableStream.pipe(
|
|
9272
|
+
const { constants, createBrotliCompress } = await import("node:zlib");
|
|
9273
|
+
return fileReadableStream.pipe(
|
|
9274
|
+
createBrotliCompress({
|
|
9275
|
+
params: {
|
|
9276
|
+
[constants.BROTLI_PARAM_QUALITY]:
|
|
9277
|
+
BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION,
|
|
9278
|
+
},
|
|
9279
|
+
}),
|
|
9280
|
+
);
|
|
9231
9281
|
},
|
|
9232
9282
|
deflate: async (fileReadableStream) => {
|
|
9233
9283
|
const { createDeflate } = await import("node:zlib");
|
|
@@ -9286,6 +9336,11 @@ const defaultIsVersioned = (request) => {
|
|
|
9286
9336
|
return new URL(request.url).searchParams.has("v");
|
|
9287
9337
|
};
|
|
9288
9338
|
|
|
9339
|
+
/**
|
|
9340
|
+
* Server plugin logging a warning when a response does not honor what the
|
|
9341
|
+
* request accepts (`accept`, `accept-language`, `accept-encoding`). A
|
|
9342
|
+
* development aid: it changes nothing in the response.
|
|
9343
|
+
*/
|
|
9289
9344
|
const serverPluginResponseAcceptanceCheck = () => {
|
|
9290
9345
|
return {
|
|
9291
9346
|
name: "jsenv:response_acceptance_check",
|
|
@@ -9327,9 +9382,9 @@ ${requestAcceptLanguageHeader}`);
|
|
|
9327
9382
|
const requestAcceptEncodingHeader = request.headers["accept-encoding"];
|
|
9328
9383
|
const responseContentEncodingHeader = response.headers["content-encoding"];
|
|
9329
9384
|
if (
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
!pickContentEncoding(request, [
|
|
9385
|
+
requestAcceptEncodingHeader &&
|
|
9386
|
+
responseContentEncodingHeader &&
|
|
9387
|
+
!pickContentEncoding(request, [responseContentEncodingHeader])
|
|
9333
9388
|
) {
|
|
9334
9389
|
warn(`response encoding is not in the request accepted encoding.
|
|
9335
9390
|
--- response content-encoding header ---
|
|
@@ -9339,6 +9394,14 @@ ${requestAcceptEncodingHeader}`);
|
|
|
9339
9394
|
}
|
|
9340
9395
|
};
|
|
9341
9396
|
|
|
9397
|
+
/**
|
|
9398
|
+
* Server plugin rewriting request resources before routing, from url
|
|
9399
|
+
* patterns to their replacement: `{ "/old/*": "/new/*", "/index": "/" }`
|
|
9400
|
+
* (patterns as in @jsenv/url-meta). The search params of the request are
|
|
9401
|
+
* kept unless the replacement has its own.
|
|
9402
|
+
*
|
|
9403
|
+
* @param {Object<string, string>} resourceAliases
|
|
9404
|
+
*/
|
|
9342
9405
|
const serverPluginRequestAliases = (resourceAliases) => {
|
|
9343
9406
|
const aliases = {};
|
|
9344
9407
|
Object.keys(resourceAliases).forEach((key) => {
|