@jsenv/server 17.6.1 → 17.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -48
- package/dist/jsenv_server.js +1336 -1270
- package/package.json +2 -2
- package/src/content_negotiation/pick_content_encoding.js +11 -1
- package/src/content_negotiation/pick_content_language.js +11 -1
- package/src/content_negotiation/pick_content_type.js +11 -1
- package/src/content_negotiation/pick_content_version.js +1 -1
- package/src/interfacing_with_node/from_node_request.js +154 -178
- package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
- package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
- package/src/interfacing_with_node/write_node_response.js +59 -103
- package/src/internal/allowed_hosts.js +85 -0
- package/src/internal/escape_html.js +11 -0
- package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
- package/src/internal/listen.js +32 -5
- package/src/internal/{listenRequest.js → listen_request.js} +1 -1
- package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
- package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
- package/src/internal/reason_phrase.js +17 -0
- package/src/internal/response_composition.js +11 -2
- package/src/internal/secure_server.js +22 -0
- package/src/internal/server_origins.js +78 -0
- package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
- package/src/internal/track_server_pending_connections.js +50 -0
- package/src/internal/track_server_pending_requests.js +47 -0
- package/src/plugins/cors/server_plugin_cors.js +39 -6
- package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
- package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
- package/src/plugins/filesystem/fetch_directory.js +16 -3
- package/src/plugins/filesystem/fetch_file.js +80 -21
- package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
- package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
- package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
- package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
- package/src/plugins/server_plugins_controller.js +27 -13
- package/src/progressive_response.js +24 -0
- package/src/router/permissions.js +52 -0
- package/src/router/router.js +18 -80
- 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 };
|
|
@@ -6244,7 +6452,10 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6244
6452
|
}
|
|
6245
6453
|
}
|
|
6246
6454
|
if (!route.matchMethod(request.method)) {
|
|
6247
|
-
|
|
6455
|
+
// a 405 asserts the resource exists with other methods: a route
|
|
6456
|
+
// matching any resource ("GET *") cannot assert that, so it does
|
|
6457
|
+
// not turn an unknown resource into a 405
|
|
6458
|
+
if (!route.isFallback && route.resource !== "*") {
|
|
6248
6459
|
wouldHaveMatched.methodSet.add(route.method);
|
|
6249
6460
|
}
|
|
6250
6461
|
continue;
|
|
@@ -6461,7 +6672,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6461
6672
|
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
6462
6673
|
normallyCanSee =
|
|
6463
6674
|
permissionsToSee.length === 0 ||
|
|
6464
|
-
permissionsSatisfy
|
|
6675
|
+
permissionsSatisfy(permsSet, permissionsToSee);
|
|
6465
6676
|
}
|
|
6466
6677
|
if (!normallyCanSee && !canExposeSensitiveData) {
|
|
6467
6678
|
continue;
|
|
@@ -6519,25 +6730,13 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6519
6730
|
return router;
|
|
6520
6731
|
};
|
|
6521
6732
|
|
|
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
|
-
*/
|
|
6733
|
+
// Turns a route descriptor (documented on startServer's `routes` param) into
|
|
6734
|
+
// a route: matchers for the method, the resource and the headers, plus what
|
|
6735
|
+
// the inspector shows. Throws on a malformed descriptor.
|
|
6536
6736
|
const createRoute = ({
|
|
6537
6737
|
endpoint,
|
|
6538
6738
|
description,
|
|
6539
6739
|
headers,
|
|
6540
|
-
service,
|
|
6541
6740
|
serverPlugin,
|
|
6542
6741
|
permissionsRequired,
|
|
6543
6742
|
permissionsToSee,
|
|
@@ -6549,7 +6748,6 @@ const createRoute = ({
|
|
|
6549
6748
|
fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
|
|
6550
6749
|
clientCodeExample,
|
|
6551
6750
|
isFallback,
|
|
6552
|
-
subroutes,
|
|
6553
6751
|
declarationSource,
|
|
6554
6752
|
}) => {
|
|
6555
6753
|
if (!endpoint || typeof endpoint !== "string") {
|
|
@@ -6589,7 +6787,6 @@ const createRoute = ({
|
|
|
6589
6787
|
method,
|
|
6590
6788
|
resource,
|
|
6591
6789
|
description,
|
|
6592
|
-
service,
|
|
6593
6790
|
serverPlugin,
|
|
6594
6791
|
permissionsRequired,
|
|
6595
6792
|
permissionsToSee,
|
|
@@ -6672,14 +6869,10 @@ const createRoute = ({
|
|
|
6672
6869
|
resourcePattern,
|
|
6673
6870
|
isForWebSocket,
|
|
6674
6871
|
isFallback,
|
|
6675
|
-
subroutes,
|
|
6676
6872
|
};
|
|
6677
6873
|
return route;
|
|
6678
6874
|
};
|
|
6679
6875
|
|
|
6680
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
6681
|
-
const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
|
|
6682
|
-
|
|
6683
6876
|
// Returns a denied response if the request does not satisfy route access/visible config.
|
|
6684
6877
|
// Returns null if access is granted.
|
|
6685
6878
|
const checkRouteAccess = async (
|
|
@@ -6709,22 +6902,13 @@ const checkRouteAccess = async (
|
|
|
6709
6902
|
const permsSet = await fetchSecondArg.getAllPermissions();
|
|
6710
6903
|
if (
|
|
6711
6904
|
permissionsToSee.length === 0 ||
|
|
6712
|
-
permissionsSatisfy
|
|
6905
|
+
permissionsSatisfy(permsSet, permissionsToSee)
|
|
6713
6906
|
) {
|
|
6714
6907
|
return createForbiddenResponse();
|
|
6715
6908
|
}
|
|
6716
6909
|
return createRouteNotFoundResponse(request);
|
|
6717
6910
|
};
|
|
6718
6911
|
|
|
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
6912
|
const createForbiddenResponse = () => {
|
|
6729
6913
|
return {
|
|
6730
6914
|
status: 403,
|
|
@@ -6800,7 +6984,6 @@ const createNotAcceptableResponse = (
|
|
|
6800
6984
|
) => {
|
|
6801
6985
|
const unsupported = [];
|
|
6802
6986
|
const headers = {};
|
|
6803
|
-
const data = {};
|
|
6804
6987
|
|
|
6805
6988
|
if (availableMediaTypes.length) {
|
|
6806
6989
|
const requestAcceptHeader = request.headers["accept"];
|
|
@@ -6808,11 +6991,6 @@ const createNotAcceptableResponse = (
|
|
|
6808
6991
|
// Use a non-standard but semantic header name
|
|
6809
6992
|
headers["available-media-types"] = availableMediaTypes.join(", ");
|
|
6810
6993
|
|
|
6811
|
-
Object.assign(data, {
|
|
6812
|
-
requestAcceptHeader,
|
|
6813
|
-
availableMediaTypes,
|
|
6814
|
-
});
|
|
6815
|
-
|
|
6816
6994
|
unsupported.push({
|
|
6817
6995
|
type: "content-type",
|
|
6818
6996
|
message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
|
|
@@ -6825,11 +7003,6 @@ Available media types: ${availableMediaTypes.join(", ")}.`,
|
|
|
6825
7003
|
// Use a non-standard but semantic header name
|
|
6826
7004
|
headers["available-languages"] = availableLanguages.join(", ");
|
|
6827
7005
|
|
|
6828
|
-
Object.assign(data, {
|
|
6829
|
-
requestAcceptLanguageHeader,
|
|
6830
|
-
availableLanguages,
|
|
6831
|
-
});
|
|
6832
|
-
|
|
6833
7006
|
unsupported.push({
|
|
6834
7007
|
type: "language",
|
|
6835
7008
|
message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
|
|
@@ -6842,11 +7015,6 @@ Available languages: ${availableLanguages.join(", ")}.`,
|
|
|
6842
7015
|
// Use a non-standard but semantic header name
|
|
6843
7016
|
headers["available-versions"] = availableVersions.join(", ");
|
|
6844
7017
|
|
|
6845
|
-
Object.assign(data, {
|
|
6846
|
-
requestAcceptVersionHeader,
|
|
6847
|
-
availableLanguages,
|
|
6848
|
-
});
|
|
6849
|
-
|
|
6850
7018
|
unsupported.push({
|
|
6851
7019
|
type: "version",
|
|
6852
7020
|
message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
|
|
@@ -6859,11 +7027,6 @@ Available versions: ${availableVersions.join(", ")}.`,
|
|
|
6859
7027
|
// Use a non-standard but semantic header name
|
|
6860
7028
|
headers["available-encodings"] = availableEncodings.join(", ");
|
|
6861
7029
|
|
|
6862
|
-
Object.assign(data, {
|
|
6863
|
-
requestAcceptEncodingHeader,
|
|
6864
|
-
availableEncodings,
|
|
6865
|
-
});
|
|
6866
|
-
|
|
6867
7030
|
unsupported.push({
|
|
6868
7031
|
type: "encoding",
|
|
6869
7032
|
message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
|
|
@@ -6900,7 +7063,7 @@ const createMethodNotAllowedResponse = (
|
|
|
6900
7063
|
return {
|
|
6901
7064
|
status: 405,
|
|
6902
7065
|
statusText: "Method Not Allowed",
|
|
6903
|
-
|
|
7066
|
+
statusMessage: `The HTTP method "${request.method}" is not supported for this resource.
|
|
6904
7067
|
Allowed methods: ${allowedMethods.join(", ")}`,
|
|
6905
7068
|
headers: {
|
|
6906
7069
|
allow: allowedMethods.join(", "),
|
|
@@ -7021,199 +7184,131 @@ const letters = [
|
|
|
7021
7184
|
"y",
|
|
7022
7185
|
"z",
|
|
7023
7186
|
];
|
|
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
|
-
}
|
|
7187
|
+
|
|
7188
|
+
const createReason = (reasonString) => {
|
|
7112
7189
|
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
|
-
},
|
|
7190
|
+
toString: () => reasonString,
|
|
7139
7191
|
};
|
|
7140
7192
|
};
|
|
7141
7193
|
|
|
7142
|
-
const
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
const
|
|
7194
|
+
const STOP_REASON_INTERNAL_ERROR = createReason("Internal error");
|
|
7195
|
+
const STOP_REASON_PROCESS_SIGHUP = createReason("process SIGHUP");
|
|
7196
|
+
const STOP_REASON_PROCESS_SIGTERM = createReason("process SIGTERM");
|
|
7197
|
+
const STOP_REASON_PROCESS_SIGINT = createReason("process SIGINT");
|
|
7198
|
+
const STOP_REASON_PROCESS_BEFORE_EXIT = createReason(
|
|
7199
|
+
"process before exit",
|
|
7200
|
+
);
|
|
7201
|
+
const STOP_REASON_PROCESS_EXIT = createReason("process exit");
|
|
7202
|
+
const STOP_REASON_NOT_SPECIFIED = createReason("not specified");
|
|
7151
7203
|
|
|
7152
7204
|
const TIMING_NOOP = () => {
|
|
7153
7205
|
return { end: () => {} };
|
|
7154
7206
|
};
|
|
7155
7207
|
|
|
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
7208
|
/**
|
|
7166
|
-
*
|
|
7167
|
-
*
|
|
7168
|
-
* @param {Object} params
|
|
7209
|
+
* Start an http server (https and http2 optional) answering each request with
|
|
7210
|
+
* the first route producing a response.
|
|
7169
7211
|
*
|
|
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
|
-
*
|
|
7212
|
+
* @param {Object} [params={}]
|
|
7213
|
+
* @param {Array<Object>} [params.routes=[]] - Route descriptors, tried in order. Each has:
|
|
7214
|
+
* - `endpoint` {string} — Required. `"GET /users/:id"`: an http method (or `*` for any)
|
|
7215
|
+
* and a resource pattern. `:name` captures a segment and `*` a run of segments, both
|
|
7216
|
+
* land in `request.params`; `?page=:page` captures a search param. `"*"` alone
|
|
7217
|
+
* matches everything. An endpoint ending in `.websocket` marks a websocket route.
|
|
7218
|
+
* - `fetch` {Function} — Required. `(request, helpers) => response`, async or not.
|
|
7219
|
+
* Returns a `Response`, a plain `{ status, statusText, statusMessage, headers, body,
|
|
7220
|
+
* timing }` object (`status` defaults to 404, `statusMessage` feeds the body of 4xx/5xx
|
|
7221
|
+
* responses), a `WebSocketResponse`, or `null`/`undefined` to let the next route try.
|
|
7222
|
+
* `request` is described in docs/handling_requests.md. `helpers` holds `timing(name)`,
|
|
7223
|
+
* `injectResponseHeader(name, value)`, `contentNegotiation` (`{ mediaType, language,
|
|
7224
|
+
* version, encoding }` picked from the `available*` lists below), `responseCookies`
|
|
7225
|
+
* (`set`/`delete`), `hasPermissions`, `getAllPermissions`, `router`,
|
|
7226
|
+
* `canExposeSensitiveData`, plus whatever plugins add with "augmentRouteFetchSecondArg".
|
|
7227
|
+
* - `headers` {Object} — Header pattern the request must match
|
|
7228
|
+
* (`{ upgrade: "websocket" }` marks a websocket route).
|
|
7229
|
+
* - `availableMediaTypes`, `availableLanguages`, `availableVersions`, `availableEncodings`
|
|
7230
|
+
* {Array} — What the route can produce, in order of preference. Drives content
|
|
7231
|
+
* negotiation, the `vary` header and 406 responses. Media types are inferred from the
|
|
7232
|
+
* endpoint extension when omitted.
|
|
7233
|
+
* - `acceptedMediaTypes` {Array<string>} — Request body media types accepted by
|
|
7234
|
+
* POST/PATCH/PUT (415 otherwise).
|
|
7235
|
+
* - `permissionsRequired` {Array<string>} — Permissions (granted by "grantPermissions"
|
|
7236
|
+
* plugins) needed to access the route; `[]` opens it to everyone. Once any route
|
|
7237
|
+
* declares permissions, a route without them is hidden (404) from everyone.
|
|
7238
|
+
* - `permissionsToSee` {Array<string>} — Permissions needed to learn the route exists
|
|
7239
|
+
* (403 instead of 404 when access is denied); `[]` makes it visible to everyone.
|
|
7240
|
+
* - `description` {string}, `clientCodeExample` {string|Function}, `declarationSource`
|
|
7241
|
+
* {string} — Shown by the route inspector at `/.internal/route_inspector`.
|
|
7195
7242
|
*
|
|
7196
|
-
* @param {Array<Object>} [params.plugins=[]] - Server plugins
|
|
7197
|
-
*
|
|
7198
|
-
*
|
|
7243
|
+
* @param {Array<Object>} [params.plugins=[]] - Server plugins, see docs/plugins.md.
|
|
7244
|
+
* @param {number} [params.port=0] - `0` lets the OS pick a free port.
|
|
7245
|
+
* @param {number} [params.portHint] - With `port: 0`, try this port first, then the next ones.
|
|
7246
|
+
* @param {string} [params.hostname="localhost"] - Hostname or ip to listen to; `server.origin` is built from it.
|
|
7247
|
+
* @param {boolean} [params.acceptAnyIp=false] - Listen on every interface (`0.0.0.0` or `::`)
|
|
7248
|
+
* so that other machines on the network can reach the server; `server.origins.externalip`
|
|
7249
|
+
* then tells the address to use from there.
|
|
7250
|
+
* @param {boolean} [params.preferIpv6] - Favor ipv6 addresses in `server.origins`.
|
|
7251
|
+
* @param {Array<string>|true} [params.allowedHosts=[]] - Names the server may be requested
|
|
7252
|
+
* with (the `host` header), on top of localhost, `hostname`, the machine name and ips,
|
|
7253
|
+
* and every ip when `acceptAnyIp`. `".example.com"` covers the subdomains. Any other
|
|
7254
|
+
* host is answered 403: a page served elsewhere cannot read the responses by rebinding
|
|
7255
|
+
* its DNS name to this machine. `true` disables the check.
|
|
7256
|
+
* @param {Object|false} [params.https=false] - `{ certificate, privateKey }` (PEM strings) to serve https.
|
|
7257
|
+
* @param {boolean} [params.redirectHttpToHttps] - With https, answer http requests with a 301
|
|
7258
|
+
* to the https origin. Defaults to true unless `allowHttpRequestOnHttps` is set.
|
|
7259
|
+
* @param {boolean} [params.allowHttpRequestOnHttps=false] - With https, also serve plain http
|
|
7260
|
+
* requests on the same port (`request.origin` tells them apart).
|
|
7261
|
+
* @param {boolean} [params.http2=false] - Serve http2 (needs `https`, http/1.1 clients are still
|
|
7262
|
+
* accepted). Brings nothing on localhost; a page loading many modules from another machine
|
|
7263
|
+
* loads several times faster. Http2 has no reason phrase: `statusText` then only reaches the
|
|
7264
|
+
* logs and the body of 4xx/5xx responses.
|
|
7265
|
+
* @param {boolean} [params.http1Allowed=true] - With http2, still accept http/1.1 clients.
|
|
7266
|
+
* @param {string} [params.logLevel] - `"debug"`, `"info"` (default), `"warn"`, `"error"` or `"off"`.
|
|
7267
|
+
* @param {string} [params.routerLogLevel] - Same, for the router logs (which route matched).
|
|
7268
|
+
* @param {boolean} [params.startLog=true] - Log "server started at …" once listening.
|
|
7269
|
+
* @param {string} [params.serverName="server"] - Name used in the logs.
|
|
7270
|
+
* @param {AbortSignal} [params.signal] - Cancels the start (an AbortError is thrown). Once
|
|
7271
|
+
* listening the server is stopped with `stop`.
|
|
7272
|
+
* @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
|
|
7273
|
+
* inside a cluster worker where the primary process is in charge.
|
|
7274
|
+
* @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
|
|
7275
|
+
* @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
|
|
7276
|
+
* "handleError" plugins answered).
|
|
7277
|
+
* @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
|
|
7278
|
+
* the process alive.
|
|
7279
|
+
* @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
|
|
7280
|
+
* belongs to the machine it runs on. Development only, never in production. It unlocks:
|
|
7281
|
+
* - file paths in status texts (a 404 from `createFileSystemFetch` says which path);
|
|
7282
|
+
* - the declaration source of every route in the route inspector, where every route is
|
|
7283
|
+
* listed whatever its permissions;
|
|
7284
|
+
* - `GET /.internal/open_file/*`, opening a file of the machine in the editor;
|
|
7285
|
+
* - `GET /@jsenv/server/*`, serving this package's own client files;
|
|
7286
|
+
* - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
|
|
7287
|
+
* subscribes to in order to reload when the server restarts.
|
|
7288
|
+
* @param {boolean|Object} [params.serverTiming=false] - `true` or `{ minDuration }` to send
|
|
7289
|
+
* `server-timing` response headers: the time to start responding, the routing of each
|
|
7290
|
+
* plugin, what routes measure with `helpers.timing` and the `timing` a response hands
|
|
7291
|
+
* back. `minDuration` (ms) drops the entries that took less: 0 keeps everything (what a
|
|
7292
|
+
* test wants), a human reading devtools usually wants the sub-millisecond noise gone.
|
|
7293
|
+
* @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
|
|
7294
|
+
* still has no response after that many ms (0 disables).
|
|
7295
|
+
* @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
|
|
7296
|
+
* @param {number} [params.responseTimeout=600000] - Ms a route can take to start responding
|
|
7297
|
+
* before a 504 is sent instead (10 minutes).
|
|
7298
|
+
* @param {number} [params.requestBodyMaxSize=1048576] - Bytes `request.json()`, `text()`,
|
|
7299
|
+
* `buffer()` and `queryString()` accept before answering 413 (1 MiB). A route can pass
|
|
7300
|
+
* its own `{ maxSize }` to these readers; `request.body` is never limited.
|
|
7199
7301
|
*
|
|
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, … }`.
|
|
7302
|
+
* @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
|
|
7303
|
+
* webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
|
|
7304
|
+
* - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
|
|
7305
|
+
* - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
|
|
7306
|
+
* and defaults to `STOP_REASON_NOT_SPECIFIED`.
|
|
7307
|
+
* - `stoppedPromise`: resolves with the reason the server stopped for (one of the
|
|
7308
|
+
* `STOP_REASON_*` exports or what was given to `stop`).
|
|
7309
|
+
* - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
|
|
7310
|
+
* - `addEffect(callback)`: runs `callback` right away; the function it returns runs
|
|
7311
|
+
* when the server stops.
|
|
7217
7312
|
*/
|
|
7218
7313
|
const startServer = async ({
|
|
7219
7314
|
signal = new AbortController().signal,
|
|
@@ -7229,6 +7324,7 @@ const startServer = async ({
|
|
|
7229
7324
|
allowHttpRequestOnHttps = false,
|
|
7230
7325
|
acceptAnyIp = false,
|
|
7231
7326
|
preferIpv6,
|
|
7327
|
+
allowedHosts = [],
|
|
7232
7328
|
hostname = "localhost",
|
|
7233
7329
|
port = 0, // assign a random available port
|
|
7234
7330
|
portHint,
|
|
@@ -7244,17 +7340,7 @@ const startServer = async ({
|
|
|
7244
7340
|
keepProcessAlive = true,
|
|
7245
7341
|
routes = [],
|
|
7246
7342
|
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
7343
|
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
7344
|
serverTiming = false,
|
|
7259
7345
|
requestWaitingMs = 0,
|
|
7260
7346
|
requestWaitingCallback = ({ request, requestWaitingMs }) => {
|
|
@@ -7268,13 +7354,8 @@ const startServer = async ({
|
|
|
7268
7354
|
),
|
|
7269
7355
|
);
|
|
7270
7356
|
},
|
|
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
|
|
7357
|
+
responseTimeout = 60_000 * 10, // 10 minutes
|
|
7358
|
+
requestBodyMaxSize = 1024 * 1024,
|
|
7278
7359
|
...rest
|
|
7279
7360
|
} = {}) => {
|
|
7280
7361
|
// param validations
|
|
@@ -7299,6 +7380,11 @@ const startServer = async ({
|
|
|
7299
7380
|
if (http2 && !https) {
|
|
7300
7381
|
throw new Error(`http2 needs https`);
|
|
7301
7382
|
}
|
|
7383
|
+
if (allowedHosts !== true && !Array.isArray(allowedHosts)) {
|
|
7384
|
+
throw new TypeError(
|
|
7385
|
+
`allowedHosts must be an array of hosts or true, got ${allowedHosts}`,
|
|
7386
|
+
);
|
|
7387
|
+
}
|
|
7302
7388
|
}
|
|
7303
7389
|
const logger = createLogger({ logLevel });
|
|
7304
7390
|
// param warnings and normalization
|
|
@@ -7370,9 +7456,7 @@ const startServer = async ({
|
|
|
7370
7456
|
let nodeServer;
|
|
7371
7457
|
const startServerOperation = Abort.startOperation();
|
|
7372
7458
|
const stopCallbackSet = new Set();
|
|
7373
|
-
|
|
7374
|
-
local: "", // favors hostname when possible
|
|
7375
|
-
};
|
|
7459
|
+
let serverOrigins;
|
|
7376
7460
|
|
|
7377
7461
|
try {
|
|
7378
7462
|
startServerOperation.addAbortSignal(signal);
|
|
@@ -7397,63 +7481,15 @@ const startServer = async ({
|
|
|
7397
7481
|
nodeServer.unref();
|
|
7398
7482
|
}
|
|
7399
7483
|
|
|
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
|
-
}
|
|
7484
|
+
const resolved = await resolveServerOrigins({
|
|
7485
|
+
https,
|
|
7486
|
+
hostname,
|
|
7487
|
+
acceptAnyIp,
|
|
7488
|
+
preferIpv6,
|
|
7489
|
+
});
|
|
7490
|
+
hostname = resolved.hostname;
|
|
7491
|
+
serverOrigins = resolved.serverOrigins;
|
|
7492
|
+
const hostnameToListen = resolved.hostnameToListen;
|
|
7457
7493
|
|
|
7458
7494
|
port = await listen({
|
|
7459
7495
|
signal: startServerOperation.signal,
|
|
@@ -7488,6 +7524,12 @@ const startServer = async ({
|
|
|
7488
7524
|
// so we prefer https://locahost or https://local_hostname
|
|
7489
7525
|
// over the ip
|
|
7490
7526
|
const serverOrigin = serverOrigins.local;
|
|
7527
|
+
const isHostAllowed = createHostChecker({
|
|
7528
|
+
allowedHosts,
|
|
7529
|
+
hostname,
|
|
7530
|
+
serverOrigins,
|
|
7531
|
+
acceptAnyIp,
|
|
7532
|
+
});
|
|
7491
7533
|
|
|
7492
7534
|
// now the server is started (listening) it cannot be aborted anymore
|
|
7493
7535
|
// (otherwise an AbortError is thrown to the code calling "startServer")
|
|
@@ -7551,15 +7593,11 @@ const startServer = async ({
|
|
|
7551
7593
|
);
|
|
7552
7594
|
stopCallbackSet.add(removeConnectionErrorListener);
|
|
7553
7595
|
|
|
7554
|
-
const connectionsTracker = trackServerPendingConnections(nodeServer
|
|
7555
|
-
http2,
|
|
7556
|
-
});
|
|
7596
|
+
const connectionsTracker = trackServerPendingConnections(nodeServer);
|
|
7557
7597
|
// opened connection must be shutdown before the close event is emitted
|
|
7558
7598
|
stopCallbackSet.add(connectionsTracker.stop);
|
|
7559
7599
|
|
|
7560
|
-
const pendingRequestsTracker = trackServerPendingRequests(nodeServer
|
|
7561
|
-
http2,
|
|
7562
|
-
});
|
|
7600
|
+
const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
|
|
7563
7601
|
// ensure pending requests got a response from the server
|
|
7564
7602
|
stopCallbackSet.add((reason) => {
|
|
7565
7603
|
pendingRequestsTracker.stop({
|
|
@@ -7612,7 +7650,7 @@ const startServer = async ({
|
|
|
7612
7650
|
serverTiming && typeof serverTiming === "object"
|
|
7613
7651
|
? serverTiming.minDuration || 0
|
|
7614
7652
|
: 0;
|
|
7615
|
-
const getResponseProperties = async (request
|
|
7653
|
+
const getResponseProperties = async (request) => {
|
|
7616
7654
|
const timings = {};
|
|
7617
7655
|
const timing = serverTiming
|
|
7618
7656
|
? (name) => {
|
|
@@ -7633,9 +7671,7 @@ const startServer = async ({
|
|
|
7633
7671
|
request.logger.info(
|
|
7634
7672
|
request.headers["upgrade"] === "websocket"
|
|
7635
7673
|
? `GET ${request.url} ${websocketSuffixColorized}`
|
|
7636
|
-
: request.
|
|
7637
|
-
? `Push ${request.resource}`
|
|
7638
|
-
: `${request.method} ${request.url}`,
|
|
7674
|
+
: `${request.method} ${request.url}`,
|
|
7639
7675
|
);
|
|
7640
7676
|
let requestWaitingTimeout;
|
|
7641
7677
|
if (requestWaitingMs) {
|
|
@@ -7715,23 +7751,22 @@ const startServer = async ({
|
|
|
7715
7751
|
);
|
|
7716
7752
|
},
|
|
7717
7753
|
);
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
) {
|
|
7725
|
-
clearTimeout(request.body.timeout);
|
|
7726
|
-
}
|
|
7754
|
+
serverPluginsController.callHooks("inspectResponse", request, {
|
|
7755
|
+
response: responseProperties,
|
|
7756
|
+
warn: (message) => {
|
|
7757
|
+
request.logger.warn(message);
|
|
7758
|
+
},
|
|
7759
|
+
});
|
|
7727
7760
|
return responseProperties;
|
|
7728
7761
|
};
|
|
7729
7762
|
|
|
7730
7763
|
let timeout;
|
|
7764
|
+
let timedOut = false;
|
|
7731
7765
|
try {
|
|
7732
7766
|
request = applyRequestInternalRedirection(request);
|
|
7733
7767
|
const timeoutResponsePropertiesPromise = new Promise((resolve) => {
|
|
7734
7768
|
timeout = setTimeout(() => {
|
|
7769
|
+
timedOut = true;
|
|
7735
7770
|
resolve({
|
|
7736
7771
|
// the correct status code should be 500 because it's
|
|
7737
7772
|
// we don't really know what takes time
|
|
@@ -7747,7 +7782,7 @@ const startServer = async ({
|
|
|
7747
7782
|
const routerResponsePropertiesPromise = (async () => {
|
|
7748
7783
|
const fetchSecondArg = {
|
|
7749
7784
|
timing,
|
|
7750
|
-
|
|
7785
|
+
canExposeSensitiveData,
|
|
7751
7786
|
injectResponseHeader: (name, value) => {
|
|
7752
7787
|
if (!headersToInject) {
|
|
7753
7788
|
headersToInject = {};
|
|
@@ -7769,54 +7804,28 @@ const startServer = async ({
|
|
|
7769
7804
|
}
|
|
7770
7805
|
},
|
|
7771
7806
|
);
|
|
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;
|
|
7807
|
+
Object.assign(
|
|
7808
|
+
fetchSecondArg,
|
|
7809
|
+
createPermissionHelpers(serverPluginsController, request),
|
|
7810
|
+
);
|
|
7814
7811
|
const routerResponseProperties = await router.match(
|
|
7815
7812
|
request,
|
|
7816
7813
|
fetchSecondArg,
|
|
7817
7814
|
);
|
|
7818
7815
|
return routerResponseProperties;
|
|
7819
7816
|
})();
|
|
7817
|
+
// once the 504 is sent the route keeps running: what it throws
|
|
7818
|
+
// afterwards would have nobody to reject to
|
|
7819
|
+
routerResponsePropertiesPromise.catch((e) => {
|
|
7820
|
+
if (timedOut) {
|
|
7821
|
+
logger.error(
|
|
7822
|
+
createDetailedMessage(`error after the 504 timeout response`, {
|
|
7823
|
+
"request url": request.url,
|
|
7824
|
+
"error stack": e.stack,
|
|
7825
|
+
}),
|
|
7826
|
+
);
|
|
7827
|
+
}
|
|
7828
|
+
});
|
|
7820
7829
|
const responseProperties = await Promise.race([
|
|
7821
7830
|
timeoutResponsePropertiesPromise,
|
|
7822
7831
|
routerResponsePropertiesPromise,
|
|
@@ -7829,6 +7838,21 @@ const startServer = async ({
|
|
|
7829
7838
|
// let it propagate to the caller that should catch this
|
|
7830
7839
|
throw e;
|
|
7831
7840
|
}
|
|
7841
|
+
// an error carrying its own response (a request body too large for
|
|
7842
|
+
// instance) is not an internal error: it is answered as such, whatever
|
|
7843
|
+
// the plugins
|
|
7844
|
+
if (e && typeof e.asResponse === "function") {
|
|
7845
|
+
const responseProperties = composeTwoResponses(
|
|
7846
|
+
{
|
|
7847
|
+
status: 500,
|
|
7848
|
+
headers: {
|
|
7849
|
+
"cache-control": "no-store",
|
|
7850
|
+
},
|
|
7851
|
+
},
|
|
7852
|
+
await e.asResponse(),
|
|
7853
|
+
);
|
|
7854
|
+
return finalizeResponseProperties(responseProperties);
|
|
7855
|
+
}
|
|
7832
7856
|
// internal error, create 500 response
|
|
7833
7857
|
if (
|
|
7834
7858
|
// stopOnInternalError stops server only if requestToResponse generated
|
|
@@ -7866,20 +7890,28 @@ const startServer = async ({
|
|
|
7866
7890
|
return finalizeResponseProperties(responseProperties);
|
|
7867
7891
|
}
|
|
7868
7892
|
};
|
|
7893
|
+
// When a response is sent before the request body was read (a 413, a 404
|
|
7894
|
+
// on an upload...) node keeps draining what the client sends so that the
|
|
7895
|
+
// client gets to read the response, as nginx does with its lingering close.
|
|
7896
|
+
// A client that has not finished sending after that delay is cut off:
|
|
7897
|
+
// draining a body forever is what the 413 was meant to avoid.
|
|
7898
|
+
const lingerAfterEarlyResponse = (nodeRequest) => {
|
|
7899
|
+
if (nodeRequest.complete) {
|
|
7900
|
+
return;
|
|
7901
|
+
}
|
|
7902
|
+
const lingerTimeout = setTimeout(() => {
|
|
7903
|
+
nodeRequest.socket.destroy();
|
|
7904
|
+
}, REQUEST_BODY_LINGER_MS).unref();
|
|
7905
|
+
nodeRequest.once("close", () => {
|
|
7906
|
+
clearTimeout(lingerTimeout);
|
|
7907
|
+
});
|
|
7908
|
+
};
|
|
7869
7909
|
const sendResponse = async (
|
|
7870
7910
|
responseStream,
|
|
7871
7911
|
responseProperties,
|
|
7872
7912
|
{ signal, request },
|
|
7873
7913
|
) => {
|
|
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
7914
|
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
7915
|
await writeNodeResponse(responseStream, responseProperties, {
|
|
7884
7916
|
signal,
|
|
7885
7917
|
ignoreBody,
|
|
@@ -7910,6 +7942,15 @@ const startServer = async ({
|
|
|
7910
7942
|
|
|
7911
7943
|
{
|
|
7912
7944
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
7945
|
+
const requestHost = nodeRequest.authority || nodeRequest.headers.host;
|
|
7946
|
+
if (!isHostAllowed(requestHost)) {
|
|
7947
|
+
logger.warn(
|
|
7948
|
+
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
7949
|
+
);
|
|
7950
|
+
nodeResponse.writeHead(403, { "content-type": "text/plain" });
|
|
7951
|
+
nodeResponse.end("Host not allowed");
|
|
7952
|
+
return;
|
|
7953
|
+
}
|
|
7913
7954
|
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
7914
7955
|
nodeResponse.writeHead(301, {
|
|
7915
7956
|
location: `${serverOrigin}${nodeRequest.url}`,
|
|
@@ -7928,164 +7969,29 @@ const startServer = async ({
|
|
|
7928
7969
|
|
|
7929
7970
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
7930
7971
|
prepareHandleRequestOperations(nodeRequest, nodeResponse);
|
|
7931
|
-
|
|
7932
|
-
signal: stopAbortSignal,
|
|
7933
|
-
serverOrigin,
|
|
7934
|
-
requestBodyLifetime,
|
|
7935
|
-
logger,
|
|
7936
|
-
nagle,
|
|
7937
|
-
});
|
|
7938
|
-
|
|
7972
|
+
let request;
|
|
7939
7973
|
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
|
-
},
|
|
7974
|
+
request = fromNodeRequest(nodeRequest, {
|
|
7975
|
+
signal: stopAbortSignal,
|
|
7976
|
+
serverOrigin,
|
|
7977
|
+
logger,
|
|
7978
|
+
requestBodyMaxSize,
|
|
8088
7979
|
});
|
|
7980
|
+
} catch (e) {
|
|
7981
|
+
// the request cannot even be read: there is nothing to hand to the routes
|
|
7982
|
+
logger.error(
|
|
7983
|
+
createDetailedMessage(`error while reading request`, {
|
|
7984
|
+
"request url": nodeRequest.url,
|
|
7985
|
+
"error stack": e.stack,
|
|
7986
|
+
}),
|
|
7987
|
+
);
|
|
7988
|
+
nodeResponse.writeHead(500);
|
|
7989
|
+
nodeResponse.end();
|
|
7990
|
+
return;
|
|
7991
|
+
}
|
|
7992
|
+
|
|
7993
|
+
try {
|
|
7994
|
+
const responseProperties = await getResponseProperties(request);
|
|
8089
7995
|
const webSocketHandler = getWebSocketHandler(responseProperties);
|
|
8090
7996
|
if (webSocketHandler) {
|
|
8091
7997
|
throw new Error(
|
|
@@ -8099,6 +8005,7 @@ const startServer = async ({
|
|
|
8099
8005
|
signal: sendResponseOperation.signal,
|
|
8100
8006
|
request,
|
|
8101
8007
|
});
|
|
8008
|
+
lingerAfterEarlyResponse(nodeRequest);
|
|
8102
8009
|
} finally {
|
|
8103
8010
|
await sendResponseOperation.end();
|
|
8104
8011
|
}
|
|
@@ -8135,22 +8042,39 @@ const startServer = async ({
|
|
|
8135
8042
|
};
|
|
8136
8043
|
// https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
|
|
8137
8044
|
const upgradeEventHandler = async (nodeRequest, socket, head) => {
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
8045
|
+
const requestHost = nodeRequest.headers.host;
|
|
8046
|
+
if (!isHostAllowed(requestHost)) {
|
|
8047
|
+
logger.warn(
|
|
8048
|
+
`${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
|
|
8049
|
+
);
|
|
8050
|
+
socket.write(
|
|
8051
|
+
`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: 16\r\n\r\nHost not allowed`,
|
|
8052
|
+
);
|
|
8053
|
+
socket.destroy();
|
|
8054
|
+
return;
|
|
8055
|
+
}
|
|
8056
|
+
let request;
|
|
8057
|
+
try {
|
|
8058
|
+
request = fromNodeRequest(nodeRequest, {
|
|
8059
|
+
signal: stopAbortSignal,
|
|
8060
|
+
serverOrigin,
|
|
8061
|
+
logger,
|
|
8062
|
+
requestBodyMaxSize,
|
|
8063
|
+
});
|
|
8064
|
+
} catch (e) {
|
|
8065
|
+
logger.error(
|
|
8066
|
+
createDetailedMessage(`error while reading upgrade request`, {
|
|
8067
|
+
"request url": nodeRequest.url,
|
|
8068
|
+
"error stack": e.stack,
|
|
8069
|
+
}),
|
|
8070
|
+
);
|
|
8071
|
+
socket.write(`HTTP/1.1 500 Internal Server Error\r\n\r\n`);
|
|
8072
|
+
socket.destroy();
|
|
8073
|
+
return;
|
|
8074
|
+
}
|
|
8145
8075
|
const [receiveRequestOperation, sendResponseOperation] =
|
|
8146
8076
|
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
|
-
});
|
|
8077
|
+
const responseProperties = await getResponseProperties(request);
|
|
8154
8078
|
if (receiveRequestOperation.signal.aborted) {
|
|
8155
8079
|
return;
|
|
8156
8080
|
}
|
|
@@ -8202,7 +8126,7 @@ const startServer = async ({
|
|
|
8202
8126
|
}
|
|
8203
8127
|
return;
|
|
8204
8128
|
};
|
|
8205
|
-
// see
|
|
8129
|
+
// see server_polyglot.js, upgrade must be listened on https server when used
|
|
8206
8130
|
const facadeServer = nodeServer._tlsServer || nodeServer;
|
|
8207
8131
|
const removeUpgradeCallback = listenEvent(
|
|
8208
8132
|
facadeServer,
|
|
@@ -8219,9 +8143,9 @@ const startServer = async ({
|
|
|
8219
8143
|
}
|
|
8220
8144
|
|
|
8221
8145
|
if (startLog) {
|
|
8222
|
-
if (serverOrigins.
|
|
8146
|
+
if (serverOrigins.externalip) {
|
|
8223
8147
|
logger.info(
|
|
8224
|
-
`${serverName} started at ${serverOrigins.local} (${serverOrigins.
|
|
8148
|
+
`${serverName} started at ${serverOrigins.local} (${serverOrigins.externalip})`,
|
|
8225
8149
|
);
|
|
8226
8150
|
} else {
|
|
8227
8151
|
logger.info(`${serverName} started at ${serverOrigins.local}`);
|
|
@@ -8264,38 +8188,18 @@ const createNodeServer = async ({
|
|
|
8264
8188
|
http1Allowed,
|
|
8265
8189
|
});
|
|
8266
8190
|
}
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8191
|
+
return createSecureServer({
|
|
8192
|
+
certificate,
|
|
8193
|
+
privateKey,
|
|
8194
|
+
http2,
|
|
8195
|
+
http1Allowed,
|
|
8271
8196
|
});
|
|
8272
8197
|
}
|
|
8273
8198
|
const { createServer } = await import("node:http");
|
|
8274
8199
|
return createServer();
|
|
8275
8200
|
};
|
|
8276
8201
|
|
|
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
|
-
};
|
|
8202
|
+
const REQUEST_BODY_LINGER_MS = 5_000;
|
|
8299
8203
|
|
|
8300
8204
|
const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
8301
8205
|
SIGHUP: STOP_REASON_PROCESS_SIGHUP,
|
|
@@ -8306,7 +8210,30 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
|
8306
8210
|
};
|
|
8307
8211
|
|
|
8308
8212
|
const internalErrorHtmlFileUrl = import.meta.resolve("./client/error_handler/500.html");
|
|
8213
|
+
let internalErrorHtmlTemplate;
|
|
8214
|
+
const readInternalErrorHtmlTemplate = () => {
|
|
8215
|
+
if (internalErrorHtmlTemplate === undefined) {
|
|
8216
|
+
internalErrorHtmlTemplate = readFileSync(
|
|
8217
|
+
new URL(internalErrorHtmlFileUrl),
|
|
8218
|
+
"utf8",
|
|
8219
|
+
);
|
|
8220
|
+
}
|
|
8221
|
+
return internalErrorHtmlTemplate;
|
|
8222
|
+
};
|
|
8309
8223
|
|
|
8224
|
+
/**
|
|
8225
|
+
* Server plugin turning an error thrown while handling a request into a 500
|
|
8226
|
+
* response (html, text or json depending on what the request accepts).
|
|
8227
|
+
* Without a plugin handling errors, a route that throws makes the process
|
|
8228
|
+
* exit. Put it last: it catches every error, so plugins handling a subset of
|
|
8229
|
+
* them must come before. (An error exposing `asResponse()` never reaches it:
|
|
8230
|
+
* the server answers it directly.)
|
|
8231
|
+
*
|
|
8232
|
+
* @param {Object} [params]
|
|
8233
|
+
* @param {boolean} [params.sendErrorDetails=false] - Put the error stack (and
|
|
8234
|
+
* its own properties for json) in the response. Development only: a stack
|
|
8235
|
+
* reveals file paths and code.
|
|
8236
|
+
*/
|
|
8310
8237
|
const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
8311
8238
|
return {
|
|
8312
8239
|
name: "jsenv:error_handler",
|
|
@@ -8315,9 +8242,6 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8315
8242
|
serverInternalError === null ||
|
|
8316
8243
|
(typeof serverInternalError !== "object" &&
|
|
8317
8244
|
typeof serverInternalError !== "function");
|
|
8318
|
-
if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
|
|
8319
|
-
return serverInternalError.asResponse();
|
|
8320
|
-
}
|
|
8321
8245
|
const dataToSend = serverInternalErrorIsAPrimitive
|
|
8322
8246
|
? {
|
|
8323
8247
|
code: "VALUE_THROWED",
|
|
@@ -8336,18 +8260,14 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8336
8260
|
const availableContentTypes = {
|
|
8337
8261
|
"text/html": () => {
|
|
8338
8262
|
const renderHtmlForErrorWithoutDetails = () => {
|
|
8339
|
-
return `<p>Details not available: to enable them use
|
|
8263
|
+
return `<p>Details not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).</p>`;
|
|
8340
8264
|
};
|
|
8341
8265
|
const renderHtmlForErrorWithDetails = () => {
|
|
8342
8266
|
return errorToHTML(serverInternalError);
|
|
8343
8267
|
};
|
|
8344
8268
|
|
|
8345
|
-
const internalErrorHtmlTemplate = readFileSync(
|
|
8346
|
-
new URL(internalErrorHtmlFileUrl),
|
|
8347
|
-
"utf8",
|
|
8348
|
-
);
|
|
8349
8269
|
const internalErrorHtml = replacePlaceholdersInHtml(
|
|
8350
|
-
|
|
8270
|
+
readInternalErrorHtmlTemplate(),
|
|
8351
8271
|
{
|
|
8352
8272
|
errorMessage: serverInternalErrorIsAPrimitive
|
|
8353
8273
|
? `Code inside server has thrown a literal.`
|
|
@@ -8381,7 +8301,7 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8381
8301
|
internalErrorMessage += `\n${serverInternalError.stack}`;
|
|
8382
8302
|
}
|
|
8383
8303
|
} else {
|
|
8384
|
-
internalErrorMessage += `\nDetails not available: to enable them use
|
|
8304
|
+
internalErrorMessage += `\nDetails not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).`;
|
|
8385
8305
|
}
|
|
8386
8306
|
|
|
8387
8307
|
return {
|
|
@@ -8412,6 +8332,30 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
|
|
|
8412
8332
|
};
|
|
8413
8333
|
};
|
|
8414
8334
|
|
|
8335
|
+
/**
|
|
8336
|
+
* A response whose body is written over time (long polling, progress
|
|
8337
|
+
* reporting). Return it from a route `fetch`: the headers are sent right away
|
|
8338
|
+
* and the handler writes the body chunks whenever it wants.
|
|
8339
|
+
*
|
|
8340
|
+
* @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
|
|
8341
|
+
* Receives `write` and `end`. If it returns a function, that function runs
|
|
8342
|
+
* when the client disconnects before `end` was called (cleanup).
|
|
8343
|
+
* @param {Object} [init]
|
|
8344
|
+
* @param {number} [init.status=200]
|
|
8345
|
+
* @param {string} [init.statusText]
|
|
8346
|
+
* @param {Object} [init.headers] - `content-type` decides whether the first
|
|
8347
|
+
* (empty) chunk is text or binary; defaults to text/plain.
|
|
8348
|
+
*
|
|
8349
|
+
* @example
|
|
8350
|
+
* {
|
|
8351
|
+
* endpoint: "GET /progress",
|
|
8352
|
+
* fetch: () => new ProgressiveResponse(({ write, end }) => {
|
|
8353
|
+
* const interval = setInterval(() => write("."), 100);
|
|
8354
|
+
* setTimeout(() => { clearInterval(interval); end(); }, 1000);
|
|
8355
|
+
* return () => clearInterval(interval);
|
|
8356
|
+
* }),
|
|
8357
|
+
* }
|
|
8358
|
+
*/
|
|
8415
8359
|
class ProgressiveResponse {
|
|
8416
8360
|
constructor(responseBodyHandler, { status = 200, statusText, headers } = {}) {
|
|
8417
8361
|
const contentType = headers ? headers["content-type"] : "text/plain";
|
|
@@ -8463,6 +8407,32 @@ const jsenvAccessControlAllowedMethods = [
|
|
|
8463
8407
|
"OPTIONS",
|
|
8464
8408
|
];
|
|
8465
8409
|
|
|
8410
|
+
/**
|
|
8411
|
+
* Server plugin adding the CORS response headers to every response, including
|
|
8412
|
+
* errors (a browser treats a 500 without them as a CORS failure). Disabled
|
|
8413
|
+
* (returns no plugin) unless `accessControlAllowRequestOrigin` is true or
|
|
8414
|
+
* `accessControlAllowedOrigins` is non empty.
|
|
8415
|
+
*
|
|
8416
|
+
* @param {Object} [params]
|
|
8417
|
+
* @param {Array<string>} [params.accessControlAllowedOrigins=[]] - Origins allowed to
|
|
8418
|
+
* read responses. `*` stands for any run of characters except "/":
|
|
8419
|
+
* `"https://pr-*-my-app.fly.dev"` matches every preview deployment.
|
|
8420
|
+
* @param {Array<string>} [params.accessControlAllowedMethods] - Defaults to
|
|
8421
|
+
* GET, POST, PUT, DELETE, OPTIONS.
|
|
8422
|
+
* @param {Array<string>} [params.accessControlAllowedHeaders] - Defaults to `["x-requested-with"]`.
|
|
8423
|
+
* @param {boolean} [params.accessControlAllowRequestOrigin=false] - Reflect any request
|
|
8424
|
+
* origin, whatever `accessControlAllowedOrigins` says.
|
|
8425
|
+
* @param {boolean} [params.accessControlAllowRequestMethod=false] - Also allow the method a
|
|
8426
|
+
* preflight asks for (`access-control-request-method`).
|
|
8427
|
+
* @param {boolean} [params.accessControlAllowRequestHeaders=false] - Also allow the headers a
|
|
8428
|
+
* preflight asks for (`access-control-request-headers`).
|
|
8429
|
+
* @param {boolean} [params.accessControlAllowCredentials=false] - Send
|
|
8430
|
+
* `access-control-allow-credentials: true`.
|
|
8431
|
+
* @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
|
|
8432
|
+
* @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin` so the
|
|
8433
|
+
* allowed origin can read resource timing.
|
|
8434
|
+
* @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
|
|
8435
|
+
*/
|
|
8466
8436
|
const serverPluginCORS = ({
|
|
8467
8437
|
accessControlAllowedOrigins = [],
|
|
8468
8438
|
accessControlAllowedMethods = jsenvAccessControlAllowedMethods,
|
|
@@ -8576,12 +8546,7 @@ const generateAccessControlHeaders = ({
|
|
|
8576
8546
|
// If no origin matches we fall back to "*" (only when not using credentials).
|
|
8577
8547
|
let allowOrigin = null;
|
|
8578
8548
|
|
|
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;
|
|
8549
|
+
const requestOrigin = readRequestOrigin(headers);
|
|
8585
8550
|
|
|
8586
8551
|
if (requestOrigin) {
|
|
8587
8552
|
if (allowedOriginChecker.isAllowed(requestOrigin)) {
|
|
@@ -8642,6 +8607,18 @@ const generateAccessControlHeaders = ({
|
|
|
8642
8607
|
};
|
|
8643
8608
|
};
|
|
8644
8609
|
|
|
8610
|
+
// the referer is a fallback for clients not sending "origin"; it comes from
|
|
8611
|
+
// the network and may not be a url at all
|
|
8612
|
+
const readRequestOrigin = (headers) => {
|
|
8613
|
+
if ("origin" in headers && headers.origin !== "null") {
|
|
8614
|
+
return headers.origin;
|
|
8615
|
+
}
|
|
8616
|
+
if ("referer" in headers && URL.canParse(headers.referer)) {
|
|
8617
|
+
return new URL(headers.referer).origin;
|
|
8618
|
+
}
|
|
8619
|
+
return null;
|
|
8620
|
+
};
|
|
8621
|
+
|
|
8645
8622
|
const ETAG_FOR_EMPTY_CONTENT = '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"';
|
|
8646
8623
|
|
|
8647
8624
|
const bufferToEtag = (buffer) => {
|
|
@@ -8663,6 +8640,17 @@ const bufferToEtag = (buffer) => {
|
|
|
8663
8640
|
return `"${length.toString(16)}-${hashBase64StringSubset}"`;
|
|
8664
8641
|
};
|
|
8665
8642
|
|
|
8643
|
+
/**
|
|
8644
|
+
* Response listing a directory, as json (an array of names) or as an html
|
|
8645
|
+
* page of links depending on what the request accepts (json by default).
|
|
8646
|
+
*
|
|
8647
|
+
* @param {string|URL} url - The directory.
|
|
8648
|
+
* @param {Object} [params]
|
|
8649
|
+
* @param {Object} [params.headers={}] - Request headers, read for `accept`.
|
|
8650
|
+
* @param {string|URL} [params.rootDirectoryUrl] - Directory served at "/", so
|
|
8651
|
+
* that the html links are relative to the server.
|
|
8652
|
+
* @returns {{ status: number, headers: Object, body: string }}
|
|
8653
|
+
*/
|
|
8666
8654
|
const fetchDirectory = (
|
|
8667
8655
|
url,
|
|
8668
8656
|
{ headers = {}, rootDirectoryUrl } = {},
|
|
@@ -8681,7 +8669,7 @@ const fetchDirectory = (
|
|
|
8681
8669
|
status: 200,
|
|
8682
8670
|
headers: {
|
|
8683
8671
|
"content-type": "application/json",
|
|
8684
|
-
"content-length": directoryContentJson
|
|
8672
|
+
"content-length": Buffer.byteLength(directoryContentJson),
|
|
8685
8673
|
},
|
|
8686
8674
|
body: directoryContentJson,
|
|
8687
8675
|
};
|
|
@@ -8696,7 +8684,7 @@ const fetchDirectory = (
|
|
|
8696
8684
|
</head>
|
|
8697
8685
|
|
|
8698
8686
|
<body>
|
|
8699
|
-
<h1>Content of directory ${url}</h1>
|
|
8687
|
+
<h1>Content of directory ${escapeHtml(url)}</h1>
|
|
8700
8688
|
<ul>
|
|
8701
8689
|
${directoryContentArray.map((filename) => {
|
|
8702
8690
|
const fileUrlObject = new URL(filename, url);
|
|
@@ -8707,8 +8695,9 @@ const fetchDirectory = (
|
|
|
8707
8695
|
if (lstatSync(fileUrlObject).isDirectory()) {
|
|
8708
8696
|
fileUrlRelativeToServer += "/";
|
|
8709
8697
|
}
|
|
8698
|
+
const linkHtml = escapeHtml(fileUrlRelativeToServer);
|
|
8710
8699
|
return `<li>
|
|
8711
|
-
<a href="/${
|
|
8700
|
+
<a href="/${linkHtml}">${linkHtml}</a>
|
|
8712
8701
|
</li>`;
|
|
8713
8702
|
}).join(`
|
|
8714
8703
|
`)}
|
|
@@ -8733,24 +8722,30 @@ const fetchDirectory = (
|
|
|
8733
8722
|
return responseProducers[bestContentType || "application/json"]();
|
|
8734
8723
|
};
|
|
8735
8724
|
|
|
8736
|
-
|
|
8725
|
+
// The file path belongs to the server machine: it joins the status text only
|
|
8726
|
+
// when the server was started with canExposeSensitiveData.
|
|
8727
|
+
const convertFileSystemErrorToResponseProperties = (
|
|
8728
|
+
error,
|
|
8729
|
+
{ canExposeSensitiveData },
|
|
8730
|
+
) => {
|
|
8731
|
+
const location = canExposeSensitiveData ? ` at ${error.path}` : "";
|
|
8737
8732
|
// https://iojs.org/api/errors.html#errors_eacces_permission_denied
|
|
8738
8733
|
if (isErrorWithCode(error, "EACCES")) {
|
|
8739
8734
|
return {
|
|
8740
8735
|
status: 403,
|
|
8741
|
-
statusText: `EACCES: No permission to read file
|
|
8736
|
+
statusText: `EACCES: No permission to read file${location}`,
|
|
8742
8737
|
};
|
|
8743
8738
|
}
|
|
8744
8739
|
if (isErrorWithCode(error, "EPERM")) {
|
|
8745
8740
|
return {
|
|
8746
8741
|
status: 403,
|
|
8747
|
-
statusText: `EPERM: No permission to read file
|
|
8742
|
+
statusText: `EPERM: No permission to read file${location}`,
|
|
8748
8743
|
};
|
|
8749
8744
|
}
|
|
8750
8745
|
if (isErrorWithCode(error, "ENOENT")) {
|
|
8751
8746
|
return {
|
|
8752
8747
|
status: 404,
|
|
8753
|
-
statusText: `ENOENT: File not found
|
|
8748
|
+
statusText: `ENOENT: File not found${location}`,
|
|
8754
8749
|
};
|
|
8755
8750
|
}
|
|
8756
8751
|
// file access may be temporarily blocked
|
|
@@ -8758,7 +8753,7 @@ const convertFileSystemErrorToResponseProperties = (error) => {
|
|
|
8758
8753
|
if (isErrorWithCode(error, "EBUSY")) {
|
|
8759
8754
|
return {
|
|
8760
8755
|
status: 503,
|
|
8761
|
-
statusText: `EBUSY: File is busy
|
|
8756
|
+
statusText: `EBUSY: File is busy${location}`,
|
|
8762
8757
|
headers: {
|
|
8763
8758
|
"retry-after": 0.01, // retry in 10ms
|
|
8764
8759
|
},
|
|
@@ -8777,7 +8772,7 @@ const convertFileSystemErrorToResponseProperties = (error) => {
|
|
|
8777
8772
|
if (isErrorWithCode(error, "EISDIR")) {
|
|
8778
8773
|
return {
|
|
8779
8774
|
status: 500,
|
|
8780
|
-
statusText: `EISDIR: Unexpected directory operation
|
|
8775
|
+
statusText: `EISDIR: Unexpected directory operation${location}`,
|
|
8781
8776
|
};
|
|
8782
8777
|
}
|
|
8783
8778
|
return null;
|
|
@@ -8819,18 +8814,66 @@ const fileSystemPathToUrl = (value) => {
|
|
|
8819
8814
|
};
|
|
8820
8815
|
|
|
8821
8816
|
/*
|
|
8822
|
-
*
|
|
8823
|
-
*
|
|
8824
|
-
*
|
|
8817
|
+
* Turns a request for a file into response properties: content type, client
|
|
8818
|
+
* cache (etag or mtime), compression, directory listing or main file. The
|
|
8819
|
+
* response is a plain object so that it composes with what the router and
|
|
8820
|
+
* the plugins add.
|
|
8825
8821
|
*/
|
|
8826
8822
|
|
|
8827
8823
|
|
|
8824
|
+
/**
|
|
8825
|
+
* A route `fetch` serving the files of a directory. The part of the url
|
|
8826
|
+
* captured by the endpoint `*` (or the whole resource when there is none) is
|
|
8827
|
+
* resolved inside `directoryUrl`; a url escaping it is answered with 403.
|
|
8828
|
+
*
|
|
8829
|
+
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
8830
|
+
* @param {Object} [options] - See {@link fetchFileSystem}.
|
|
8831
|
+
* @returns {(request: Object, helpers: Object) => Promise<Object|null>}
|
|
8832
|
+
*
|
|
8833
|
+
* @example
|
|
8834
|
+
* {
|
|
8835
|
+
* endpoint: "GET /assets/*",
|
|
8836
|
+
* fetch: createFileSystemFetch(import.meta.resolve("./assets/"), { etagEnabled: true }),
|
|
8837
|
+
* }
|
|
8838
|
+
*/
|
|
8828
8839
|
const createFileSystemFetch = (directoryUrl, options) => {
|
|
8829
8840
|
return (request, helpers) => {
|
|
8830
8841
|
return fetchFileSystem(request, helpers, directoryUrl, options);
|
|
8831
8842
|
};
|
|
8832
8843
|
};
|
|
8833
8844
|
|
|
8845
|
+
/**
|
|
8846
|
+
* Serve a file of `directoryUrl` for `request`. Declines (`null`) any method
|
|
8847
|
+
* other than GET and HEAD. File paths appear in the status text only when the
|
|
8848
|
+
* server runs with `canExposeSensitiveData`.
|
|
8849
|
+
*
|
|
8850
|
+
* @param {Object} request - The request given to a route `fetch`.
|
|
8851
|
+
* @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
|
|
8852
|
+
* @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
|
|
8853
|
+
* @param {Object} [options]
|
|
8854
|
+
* @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
|
|
8855
|
+
* as a fallback, for extension-less urls that do not exist (client side routing).
|
|
8856
|
+
* @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
|
|
8857
|
+
* answer 304 to a matching `if-none-match`.
|
|
8858
|
+
* @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
|
|
8859
|
+
* file stats change) so that a file is hashed once.
|
|
8860
|
+
* @param {number} [options.etagMemoryMaxSize=1000] - Files remembered at most.
|
|
8861
|
+
* @param {boolean} [options.mtimeEnabled=false] - Send `last-modified` and answer 304 to
|
|
8862
|
+
* `if-modified-since` (second precision). Ignored when `etagEnabled` is set.
|
|
8863
|
+
* @param {boolean} [options.compressionEnabled=false] - Compress textual files with brotli,
|
|
8864
|
+
* gzip or deflate according to `accept-encoding`.
|
|
8865
|
+
* @param {number} [options.compressionSizeThreshold=1024] - Files smaller than that (bytes) are not compressed.
|
|
8866
|
+
* @param {string|((request: Object) => string)} [options.cacheControl] - The `cache-control`
|
|
8867
|
+
* response header. Defaults to 30 days immutable for versioned urls (see `isVersioned`) and
|
|
8868
|
+
* `private,max-age=0,must-revalidate` otherwise. `"no-store"` disables etag and mtime.
|
|
8869
|
+
* @param {(request: Object) => boolean} [options.isVersioned] - Tells if a url is versioned
|
|
8870
|
+
* (its content never changes). Defaults to "has a `v` search param".
|
|
8871
|
+
* @param {boolean} [options.canReadDirectory=false] - Answer a directory with its listing
|
|
8872
|
+
* (json or html, see {@link fetchDirectory}) instead of 403.
|
|
8873
|
+
* @param {() => string|URL|null} [options.ENOENTFallback] - Called when the file does not exist;
|
|
8874
|
+
* the file url it returns is served instead.
|
|
8875
|
+
* @returns {Promise<Object|null>} Response properties, `null` when the method is not GET/HEAD.
|
|
8876
|
+
*/
|
|
8834
8877
|
const fetchFileSystem = async (
|
|
8835
8878
|
request,
|
|
8836
8879
|
helpers,
|
|
@@ -8863,6 +8906,9 @@ const fetchFileSystem = async (
|
|
|
8863
8906
|
if (!directoryUrlString.endsWith("/")) {
|
|
8864
8907
|
directoryUrlString = `${directoryUrlString}/`;
|
|
8865
8908
|
}
|
|
8909
|
+
const canExposeSensitiveData = Boolean(
|
|
8910
|
+
helpers && helpers.canExposeSensitiveData,
|
|
8911
|
+
);
|
|
8866
8912
|
let resource;
|
|
8867
8913
|
if (request.params && "0" in request.params) {
|
|
8868
8914
|
resource = request.params["0"];
|
|
@@ -8913,15 +8959,14 @@ const fetchFileSystem = async (
|
|
|
8913
8959
|
const serveFile = async (fileUrl) => {
|
|
8914
8960
|
try {
|
|
8915
8961
|
const readStatTiming = helpers?.timing("file service>read file stat");
|
|
8916
|
-
const fileStat =
|
|
8962
|
+
const fileStat = await stat(new URL(fileUrl));
|
|
8917
8963
|
readStatTiming?.end();
|
|
8918
8964
|
|
|
8919
8965
|
if (fileStat.isDirectory()) {
|
|
8920
8966
|
if (canReadDirectory) {
|
|
8921
8967
|
return fetchDirectory(fileUrl, {
|
|
8922
8968
|
headers: request.headers,
|
|
8923
|
-
|
|
8924
|
-
rootDirectoryUrl: directoryUrl,
|
|
8969
|
+
rootDirectoryUrl: directoryUrlString,
|
|
8925
8970
|
});
|
|
8926
8971
|
}
|
|
8927
8972
|
if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
|
|
@@ -9017,7 +9062,9 @@ const fetchFileSystem = async (
|
|
|
9017
9062
|
...(cacheControl ? { "cache-control": cacheControl } : {}),
|
|
9018
9063
|
},
|
|
9019
9064
|
},
|
|
9020
|
-
convertFileSystemErrorToResponseProperties(e
|
|
9065
|
+
convertFileSystemErrorToResponseProperties(e, {
|
|
9066
|
+
canExposeSensitiveData,
|
|
9067
|
+
}) || {},
|
|
9021
9068
|
);
|
|
9022
9069
|
}
|
|
9023
9070
|
};
|
|
@@ -9119,20 +9166,13 @@ const computeEtag = async ({
|
|
|
9119
9166
|
return etagMemoryEntry.eTag;
|
|
9120
9167
|
}
|
|
9121
9168
|
}
|
|
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
|
-
});
|
|
9169
|
+
const fileContentAsBuffer = await readFile(new URL(fileUrl));
|
|
9131
9170
|
const eTag = bufferToEtag(fileContentAsBuffer);
|
|
9132
9171
|
if (etagMemory) {
|
|
9133
9172
|
if (ETAG_MEMORY_MAP.size >= etagMemoryMaxSize) {
|
|
9134
|
-
|
|
9135
|
-
ETAG_MEMORY_MAP.
|
|
9173
|
+
// a Map iterates in insertion order: the first key is the oldest entry
|
|
9174
|
+
const oldestKey = ETAG_MEMORY_MAP.keys().next().value;
|
|
9175
|
+
ETAG_MEMORY_MAP.delete(oldestKey);
|
|
9136
9176
|
}
|
|
9137
9177
|
ETAG_MEMORY_MAP.set(fileUrl, { fileStat, eTag });
|
|
9138
9178
|
}
|
|
@@ -9224,10 +9264,23 @@ const fileUrlToReadableStream = (fileUrl) => {
|
|
|
9224
9264
|
});
|
|
9225
9265
|
};
|
|
9226
9266
|
|
|
9267
|
+
// Brotli's default quality (11) is meant for compressing assets once, ahead
|
|
9268
|
+
// of time: it is an order of magnitude slower than gzip. For compression at
|
|
9269
|
+
// request time, quality 4 costs about as much cpu as gzip while still
|
|
9270
|
+
// compressing better.
|
|
9271
|
+
const BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION = 4;
|
|
9272
|
+
|
|
9227
9273
|
const availableCompressionFormats = {
|
|
9228
9274
|
br: async (fileReadableStream) => {
|
|
9229
|
-
const { createBrotliCompress } = await import("node:zlib");
|
|
9230
|
-
return fileReadableStream.pipe(
|
|
9275
|
+
const { constants, createBrotliCompress } = await import("node:zlib");
|
|
9276
|
+
return fileReadableStream.pipe(
|
|
9277
|
+
createBrotliCompress({
|
|
9278
|
+
params: {
|
|
9279
|
+
[constants.BROTLI_PARAM_QUALITY]:
|
|
9280
|
+
BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION,
|
|
9281
|
+
},
|
|
9282
|
+
}),
|
|
9283
|
+
);
|
|
9231
9284
|
},
|
|
9232
9285
|
deflate: async (fileReadableStream) => {
|
|
9233
9286
|
const { createDeflate } = await import("node:zlib");
|
|
@@ -9286,6 +9339,11 @@ const defaultIsVersioned = (request) => {
|
|
|
9286
9339
|
return new URL(request.url).searchParams.has("v");
|
|
9287
9340
|
};
|
|
9288
9341
|
|
|
9342
|
+
/**
|
|
9343
|
+
* Server plugin logging a warning when a response does not honor what the
|
|
9344
|
+
* request accepts (`accept`, `accept-language`, `accept-encoding`). A
|
|
9345
|
+
* development aid: it changes nothing in the response.
|
|
9346
|
+
*/
|
|
9289
9347
|
const serverPluginResponseAcceptanceCheck = () => {
|
|
9290
9348
|
return {
|
|
9291
9349
|
name: "jsenv:response_acceptance_check",
|
|
@@ -9327,9 +9385,9 @@ ${requestAcceptLanguageHeader}`);
|
|
|
9327
9385
|
const requestAcceptEncodingHeader = request.headers["accept-encoding"];
|
|
9328
9386
|
const responseContentEncodingHeader = response.headers["content-encoding"];
|
|
9329
9387
|
if (
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
!pickContentEncoding(request, [
|
|
9388
|
+
requestAcceptEncodingHeader &&
|
|
9389
|
+
responseContentEncodingHeader &&
|
|
9390
|
+
!pickContentEncoding(request, [responseContentEncodingHeader])
|
|
9333
9391
|
) {
|
|
9334
9392
|
warn(`response encoding is not in the request accepted encoding.
|
|
9335
9393
|
--- response content-encoding header ---
|
|
@@ -9339,6 +9397,14 @@ ${requestAcceptEncodingHeader}`);
|
|
|
9339
9397
|
}
|
|
9340
9398
|
};
|
|
9341
9399
|
|
|
9400
|
+
/**
|
|
9401
|
+
* Server plugin rewriting request resources before routing, from url
|
|
9402
|
+
* patterns to their replacement: `{ "/old/*": "/new/*", "/index": "/" }`
|
|
9403
|
+
* (patterns as in @jsenv/url-meta). The search params of the request are
|
|
9404
|
+
* kept unless the replacement has its own.
|
|
9405
|
+
*
|
|
9406
|
+
* @param {Object<string, string>} resourceAliases
|
|
9407
|
+
*/
|
|
9342
9408
|
const serverPluginRequestAliases = (resourceAliases) => {
|
|
9343
9409
|
const aliases = {};
|
|
9344
9410
|
Object.keys(resourceAliases).forEach((key) => {
|