@jsenv/server 17.6.1 → 17.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +47 -48
  2. package/dist/jsenv_server.js +1332 -1269
  3. package/package.json +2 -2
  4. package/src/content_negotiation/pick_content_encoding.js +11 -1
  5. package/src/content_negotiation/pick_content_language.js +11 -1
  6. package/src/content_negotiation/pick_content_type.js +11 -1
  7. package/src/content_negotiation/pick_content_version.js +1 -1
  8. package/src/interfacing_with_node/from_node_request.js +154 -178
  9. package/src/interfacing_with_node/observable_from_file_handle.js +1 -18
  10. package/src/interfacing_with_node/observable_from_node_stream.js +29 -65
  11. package/src/interfacing_with_node/write_node_response.js +59 -103
  12. package/src/internal/allowed_hosts.js +85 -0
  13. package/src/internal/escape_html.js +11 -0
  14. package/src/internal/{headersFromObject.js → headers_from_object.js} +2 -2
  15. package/src/internal/listen.js +32 -5
  16. package/src/internal/{listenRequest.js → listen_request.js} +1 -1
  17. package/src/internal/{listenServerConnectionError.js → listen_server_connection_error.js} +1 -1
  18. package/src/internal/{multiple-header.js → multiple_header.js} +32 -10
  19. package/src/internal/reason_phrase.js +17 -0
  20. package/src/internal/response_composition.js +11 -2
  21. package/src/internal/secure_server.js +22 -0
  22. package/src/internal/server_origins.js +78 -0
  23. package/src/internal/{server-polyglot.js → server_polyglot.js} +13 -38
  24. package/src/internal/track_server_pending_connections.js +50 -0
  25. package/src/internal/track_server_pending_requests.js +47 -0
  26. package/src/plugins/cors/server_plugin_cors.js +39 -6
  27. package/src/plugins/default_body_4xx_5xx/server_plugin_default_body_4xx_5xx.js +37 -29
  28. package/src/plugins/error_handler/server_plugin_error_handler.js +26 -10
  29. package/src/plugins/filesystem/fetch_directory.js +16 -3
  30. package/src/plugins/filesystem/fetch_file.js +80 -21
  31. package/src/plugins/filesystem/filesystem_error_to_response.js +12 -6
  32. package/src/plugins/request_aliases/server_plugin_request_aliases.js +8 -0
  33. package/src/plugins/response_acceptance_check/server_plugin_response_acceptance_check.js +11 -6
  34. package/src/plugins/route_inspector/server_plugin_route_inspector.js +11 -5
  35. package/src/plugins/server_plugins_controller.js +27 -13
  36. package/src/progressive_response.js +24 -0
  37. package/src/router/permissions.js +52 -0
  38. package/src/router/router.js +14 -79
  39. package/src/server_events.js +3 -1
  40. package/src/server_timing/timing_header.js +1 -1
  41. package/src/start_server.js +266 -416
  42. package/src/internal/headersFromString.js +0 -21
  43. package/src/internal/headersToObject.js +0 -7
  44. package/src/internal/headersToString.js +0 -16
  45. package/src/internal/listenClientError.js +0 -27
  46. package/src/internal/trackServerPendingConnections.js +0 -145
  47. package/src/internal/trackServerPendingRequests.js +0 -68
  48. package/src/request_body_handling.js +0 -22
  49. /package/src/internal/{colorizeResponseStatus.js → colorize_response_status.js} +0 -0
  50. /package/src/internal/{listenEvent.js → listen_event.js} +0 -0
  51. /package/src/internal/{normalizeHeaderName.js → normalize_header_name.js} +0 -0
  52. /package/src/internal/{normalizeHeaderValue.js → normalize_header_value.js} +0 -0
@@ -1,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 { Readable, Stream, Writable } from "node:stream";
6
- import http from "node:http";
4
+ import http, { STATUS_CODES } from "node:http";
7
5
  import { Http2ServerResponse } from "node:http2";
8
- import { createReadStream, readFileSync, existsSync, readdirSync, lstatSync, statSync, readFile } from "node:fs";
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 { lookup } from "node:dns";
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, "&amp;")
572
573
  .replace(/</g, "&lt;")
@@ -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 attributes = string.split(";");
1026
- for (const attr of attributes) {
1027
- let [name, value] = attr.split("=");
1028
- name = name.trim();
1029
- value = value.trim();
1030
- if (validateAttribute({ name, value })) {
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 [propertyName, propertyValueString] = propertySource
1041
- .trim()
1042
- .split("=");
1043
- const propertyValue = parseHeaderPropertyValue(propertyValueString);
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
- nodeStream,
1222
- { readableLifetime } = {},
1223
- ) => {
1224
- const observable = createObservable(
1225
- ({ next, error, complete, addTeardown }) => {
1226
- const errorEventCallback = (e) => {
1227
- error(e);
1228
- };
1229
- const dataEventCallback = (data) => {
1230
- next(data);
1231
- };
1232
- const closeEventCallback = () => {
1233
- complete();
1234
- };
1235
- const endEventCallback = () => {
1236
- complete();
1237
- };
1238
- nodeStream.once("error", errorEventCallback);
1239
- nodeStream.on("data", dataEventCallback);
1240
- nodeStream.once("end", endEventCallback);
1241
- nodeStream.once("close", closeEventCallback); // not sure it's required
1242
- addTeardown(() => {
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
- observable.timeout = timeout;
1272
- }
1273
-
1274
- return observable;
1267
+ if (nodeStream.isPaused()) {
1268
+ nodeStream.resume();
1269
+ } else if (nodeStream.complete) {
1270
+ complete();
1271
+ }
1272
+ });
1275
1273
  };
1276
1274
 
1277
- const onceReadableStreamUsedOrClosed = (readableStream, callback) => {
1278
- const dataOrCloseCallback = () => {
1279
- readableStream.removeListener("data", dataOrCloseCallback);
1280
- readableStream.removeListener("close", dataOrCloseCallback);
1281
- callback();
1282
- };
1283
- readableStream.on("data", dataOrCloseCallback);
1284
- readableStream.once("close", dataOrCloseCallback);
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, requestBodyLifetime, logger, nagle },
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
- if (!nagle) {
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
- const buffer = async () => {
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
- // const contentType = headers["content-type"];
1356
- // if (!CONTENT_TYPE.isBinary(contentType)) {
1357
- // console.warn(
1358
- // `buffer() called on a request with content-type: "${contentType}". A binary content-type was expected.`,
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(); // was paused in line #53
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, { as: "string" });
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, { as: "string" });
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, { as: "string" });
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 childArray = [];
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
- type: isFaviconNotFound
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
- value: message,
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 (requestLogger.isChild) {
1553
- // keep buffering until root request write logs for everyone
1536
+ if (logArray.length === 0) {
1554
1537
  return;
1555
1538
  }
1556
- const prefixLines = (string, prefix) => {
1557
- return string.replace(/^(?!\s*$)/gm, prefix);
1558
- };
1559
- const writeLog = (
1560
- { type, value },
1561
- { someLogIsError, someLogIsWarn, depth },
1562
- ) => {
1563
- if (depth > 0) {
1564
- value = prefixLines(value, " ".repeat(depth));
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
- const writeLogs = (loggerToWrite, depth) => {
1576
- const logArray = loggerToWrite.logArray;
1577
- if (logArray.length === 0) {
1578
- return;
1579
- }
1580
- let someLogIsError = false;
1581
- let someLogIsWarn = false;
1582
- for (const log of loggerToWrite.logArray) {
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 readBody = (body, { as }) => {
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
- body.subscribe({
1627
- error: reject,
1628
- next: (buffer) => {
1629
- bufferArray.push(buffer);
1630
- },
1631
- complete: () => {
1632
- const bodyAsBuffer = Buffer.concat(bufferArray);
1633
- if (as === "buffer") {
1634
- resolve(bodyAsBuffer);
1635
- return;
1636
- }
1637
- if (as === "string") {
1638
- const bodyAsString = bodyAsBuffer.toString();
1639
- resolve(bodyAsString);
1640
- return;
1641
- }
1642
- if (as === "json") {
1643
- const bodyAsString = bodyAsBuffer.toString();
1644
- const bodyAsJSON = JSON.parse(bodyAsString);
1645
- resolve(bodyAsJSON);
1646
- return;
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 = decodeURIComponent(pair.slice(eqIndex + 1).trim());
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
- const createPushRequest = (
1709
- request,
1710
- { signal, pathname, method, logger },
1711
- ) => {
1712
- const pushRequest = Object.freeze({
1713
- ...request,
1714
- logger,
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 getHeadersInheritedByPushRequest = (request) => {
1732
- const headersInherited = { ...request.headers };
1733
- // mtime sent by the client in request headers concerns the main request
1734
- // Time remains valid for request to other resources so we keep it
1735
- // in child requests
1736
- // delete childHeaders["if-modified-since"]
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
- const fileReadableStream =
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, bodyEncoding },
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 responseIsServerHttp2Stream =
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
- if (statusText === undefined) {
2064
- statusText = statusTextFromStatus(status);
2065
- } else {
2066
- statusText = statusText.replace(/\n/g, "").replaceAll("✅", "");
2067
- }
2068
- if (responseIsServerHttp2Stream) {
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
- Object.keys(headers).forEach((name) => {
2121
- if (name === "connection" && ignoreConnectionHeader) return;
2122
- const nodeHeaderName = name in mapping ? mapping[name] : name;
2123
- nodeHeaders[nodeHeaderName] = headers[name];
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
- const listen = async ({
2344
- signal = new AbortController().signal,
2345
- server,
2346
- port,
2347
- portHint,
2348
- hostname,
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
- const listeningOperation = Abort.startOperation();
2351
-
2352
- try {
2353
- listeningOperation.addAbortSignal(signal);
2276
+ if (allowedHosts === true) {
2277
+ return () => true;
2278
+ }
2354
2279
 
2355
- if (portHint) {
2356
- listeningOperation.throwIfAborted();
2357
- port = await findFreePort(portHint, {
2358
- signal: listeningOperation.signal,
2359
- hostname,
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
- listeningOperation.throwIfAborted();
2363
- port = await startListening({ server, port, hostname });
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
- server.on("error", reject);
2436
- server.on("listening", () => {
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
- server.on("error", reject);
2448
- server.on("close", resolve);
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
- https://stackoverflow.com/a/42019773/2634179
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
- removeOnceReadableListener = socket.once("readable", tryToRead);
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
- // const trackHttp2ServerPendingSessions = () => {}
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(async (pendingConnection) => {
2722
- await destroyConnection(pendingConnection, reason);
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
- // export const trackServerPendingStreams = (nodeServer) => {
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
- nodeResponse.writeHead(status, String(reason));
2860
- }
2861
-
2862
- // http2
2863
- if (nodeResponse.close) {
2864
- return new Promise((resolve, reject) => {
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
- } else {
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(`send "${event.type}" event to ${clientArray.size} client(s)`);
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
+ "&": "&amp;",
3756
+ "<": "&lt;",
3757
+ ">": "&gt;",
3758
+ '"': "&quot;",
3759
+ "'": "&#39;",
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 < 500) {
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 htmlTemplate = readFileSync(
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
- const inspectorHtml = readFileSync(
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 jsenvServerPluginsController = await createPluginsController({
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 jsenvServerPluginsController;
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, redirect = "manual" } = {},
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 = fetchReturnValue.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 = fetchReturnValue.statusText;
6397
+ statusText =
6398
+ fetchReturnValue.statusText === undefined
6399
+ ? undefined
6400
+ : asReasonPhrase(fetchReturnValue.statusText);
6169
6401
  statusMessage = fetchReturnValue.statusMessage;
6170
6402
  headers = fetchReturnValue.headers || {};
6171
6403
  body = fetchReturnValue.body;
@@ -6176,30 +6408,6 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6176
6408
  );
6177
6409
  }
6178
6410
 
6179
- if (
6180
- redirect === "follow" &&
6181
- isRedirectStatus(status) &&
6182
- headers["location"]
6183
- ) {
6184
- const redirectUrl = new URL(headers["location"], request.url);
6185
- if (redirectUrl.origin === new URL(request.url).origin) {
6186
- const redirectRequest = {
6187
- ...request,
6188
- url: redirectUrl.href,
6189
- resource:
6190
- redirectUrl.pathname + redirectUrl.search + redirectUrl.hash,
6191
- // GET for 301/302/303, preserve method for 307/308
6192
- method: status === 307 || status === 308 ? request.method : "GET",
6193
- params: {},
6194
- };
6195
- const redirectedResponse = await matchRoutes(redirectRequest);
6196
- onRouteMatch(route);
6197
- onResponseHeaders(request, route, redirectedResponse.headers || {});
6198
- return redirectedResponse;
6199
- }
6200
- // redirect to other origins are left to the client to handle
6201
- }
6202
-
6203
6411
  onRouteMatch(route);
6204
6412
  onResponseHeaders(request, route, headers);
6205
6413
  return { status, statusText, statusMessage, headers, body, timing };
@@ -6461,7 +6669,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6461
6669
  const permsSet = await fetchSecondArg.getAllPermissions();
6462
6670
  normallyCanSee =
6463
6671
  permissionsToSee.length === 0 ||
6464
- permissionsSatisfy$1(permsSet, permissionsToSee);
6672
+ permissionsSatisfy(permsSet, permissionsToSee);
6465
6673
  }
6466
6674
  if (!normallyCanSee && !canExposeSensitiveData) {
6467
6675
  continue;
@@ -6519,25 +6727,13 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6519
6727
  return router;
6520
6728
  };
6521
6729
 
6522
- /**
6523
- * Adds a route to the router.
6524
- *
6525
- * @param {Object} params - Route configuration object
6526
- * @param {string} params.endpoint - String in format "METHOD /resource/path" (e.g. "GET /users/:id")
6527
- * @param {Object} [params.headers] - Optional headers pattern to match
6528
- * @param {Array<string>} [params.availableMediaTypes=[]] - Content types this route can produce
6529
- * @param {Array<string>} [params.availableLanguages=[]] - Languages this route can respond with
6530
- * @param {Array<string>} [params.availableEncodings=[]] - Encodings this route supports
6531
- * @param {Array<string>} [params.acceptedMediaTypes=[]] - Content types this route accepts (for POST/PATCH/PUT)
6532
- * @param {Function} params.fetch - Function to generate response for matching requests
6533
- * @throws {TypeError} If endpoint is not a string
6534
- * @returns {void}
6535
- */
6730
+ // Turns a route descriptor (documented on startServer's `routes` param) into
6731
+ // a route: matchers for the method, the resource and the headers, plus what
6732
+ // the inspector shows. Throws on a malformed descriptor.
6536
6733
  const createRoute = ({
6537
6734
  endpoint,
6538
6735
  description,
6539
6736
  headers,
6540
- service,
6541
6737
  serverPlugin,
6542
6738
  permissionsRequired,
6543
6739
  permissionsToSee,
@@ -6549,7 +6745,6 @@ const createRoute = ({
6549
6745
  fetch: routeFetchMethod, // rename because there is global.fetch and we want to be explicit
6550
6746
  clientCodeExample,
6551
6747
  isFallback,
6552
- subroutes,
6553
6748
  declarationSource,
6554
6749
  }) => {
6555
6750
  if (!endpoint || typeof endpoint !== "string") {
@@ -6589,7 +6784,6 @@ const createRoute = ({
6589
6784
  method,
6590
6785
  resource,
6591
6786
  description,
6592
- service,
6593
6787
  serverPlugin,
6594
6788
  permissionsRequired,
6595
6789
  permissionsToSee,
@@ -6672,14 +6866,10 @@ const createRoute = ({
6672
6866
  resourcePattern,
6673
6867
  isForWebSocket,
6674
6868
  isFallback,
6675
- subroutes,
6676
6869
  };
6677
6870
  return route;
6678
6871
  };
6679
6872
 
6680
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
6681
- const isRedirectStatus = (status) => REDIRECT_STATUSES.has(status);
6682
-
6683
6873
  // Returns a denied response if the request does not satisfy route access/visible config.
6684
6874
  // Returns null if access is granted.
6685
6875
  const checkRouteAccess = async (
@@ -6709,22 +6899,13 @@ const checkRouteAccess = async (
6709
6899
  const permsSet = await fetchSecondArg.getAllPermissions();
6710
6900
  if (
6711
6901
  permissionsToSee.length === 0 ||
6712
- permissionsSatisfy$1(permsSet, permissionsToSee)
6902
+ permissionsSatisfy(permsSet, permissionsToSee)
6713
6903
  ) {
6714
6904
  return createForbiddenResponse();
6715
6905
  }
6716
6906
  return createRouteNotFoundResponse(request);
6717
6907
  };
6718
6908
 
6719
- const permissionsSatisfy$1 = (permissionsSet, permissionsRequired) => {
6720
- for (const p of permissionsRequired) {
6721
- if (!permissionsSet.has(p)) {
6722
- return false;
6723
- }
6724
- }
6725
- return true;
6726
- };
6727
-
6728
6909
  const createForbiddenResponse = () => {
6729
6910
  return {
6730
6911
  status: 403,
@@ -6800,7 +6981,6 @@ const createNotAcceptableResponse = (
6800
6981
  ) => {
6801
6982
  const unsupported = [];
6802
6983
  const headers = {};
6803
- const data = {};
6804
6984
 
6805
6985
  if (availableMediaTypes.length) {
6806
6986
  const requestAcceptHeader = request.headers["accept"];
@@ -6808,11 +6988,6 @@ const createNotAcceptableResponse = (
6808
6988
  // Use a non-standard but semantic header name
6809
6989
  headers["available-media-types"] = availableMediaTypes.join(", ");
6810
6990
 
6811
- Object.assign(data, {
6812
- requestAcceptHeader,
6813
- availableMediaTypes,
6814
- });
6815
-
6816
6991
  unsupported.push({
6817
6992
  type: "content-type",
6818
6993
  message: `The server cannot produce a response in any of the media types accepted by the request: "${requestAcceptHeader}".
@@ -6825,11 +7000,6 @@ Available media types: ${availableMediaTypes.join(", ")}.`,
6825
7000
  // Use a non-standard but semantic header name
6826
7001
  headers["available-languages"] = availableLanguages.join(", ");
6827
7002
 
6828
- Object.assign(data, {
6829
- requestAcceptLanguageHeader,
6830
- availableLanguages,
6831
- });
6832
-
6833
7003
  unsupported.push({
6834
7004
  type: "language",
6835
7005
  message: `The server cannot produce a response in any of the languages accepted by the request: "${requestAcceptLanguageHeader}".
@@ -6842,11 +7012,6 @@ Available languages: ${availableLanguages.join(", ")}.`,
6842
7012
  // Use a non-standard but semantic header name
6843
7013
  headers["available-versions"] = availableVersions.join(", ");
6844
7014
 
6845
- Object.assign(data, {
6846
- requestAcceptVersionHeader,
6847
- availableLanguages,
6848
- });
6849
-
6850
7015
  unsupported.push({
6851
7016
  type: "version",
6852
7017
  message: `The server cannot produce a response in any of the versions accepted by the request: "${requestAcceptVersionHeader}".
@@ -6859,11 +7024,6 @@ Available versions: ${availableVersions.join(", ")}.`,
6859
7024
  // Use a non-standard but semantic header name
6860
7025
  headers["available-encodings"] = availableEncodings.join(", ");
6861
7026
 
6862
- Object.assign(data, {
6863
- requestAcceptEncodingHeader,
6864
- availableEncodings,
6865
- });
6866
-
6867
7027
  unsupported.push({
6868
7028
  type: "encoding",
6869
7029
  message: `The server cannot encode the response in any of the encodings accepted by the request: "${requestAcceptEncodingHeader}".
@@ -6900,7 +7060,7 @@ const createMethodNotAllowedResponse = (
6900
7060
  return {
6901
7061
  status: 405,
6902
7062
  statusText: "Method Not Allowed",
6903
- statusmessage: `The HTTP method "${request.method}" is not supported for this resource.
7063
+ statusMessage: `The HTTP method "${request.method}" is not supported for this resource.
6904
7064
  Allowed methods: ${allowedMethods.join(", ")}`,
6905
7065
  headers: {
6906
7066
  allow: allowedMethods.join(", "),
@@ -7021,199 +7181,131 @@ const letters = [
7021
7181
  "y",
7022
7182
  "z",
7023
7183
  ];
7024
-
7025
- const createReason = (reasonString) => {
7026
- return {
7027
- toString: () => reasonString,
7028
- };
7029
- };
7030
-
7031
- const STOP_REASON_INTERNAL_ERROR = createReason("Internal error");
7032
- const STOP_REASON_PROCESS_SIGHUP = createReason("process SIGHUP");
7033
- const STOP_REASON_PROCESS_SIGTERM = createReason("process SIGTERM");
7034
- const STOP_REASON_PROCESS_SIGINT = createReason("process SIGINT");
7035
- const STOP_REASON_PROCESS_BEFORE_EXIT = createReason(
7036
- "process before exit",
7037
- );
7038
- const STOP_REASON_PROCESS_EXIT = createReason("process exit");
7039
- const STOP_REASON_NOT_SPECIFIED = createReason("not specified");
7040
-
7041
- const applyDnsResolution = async (
7042
- hostname,
7043
- { verbatim = false } = {},
7044
- ) => {
7045
- const dnsResolution = await new Promise((resolve, reject) => {
7046
- lookup(hostname, { verbatim }, (error, address, family) => {
7047
- if (error) {
7048
- reject(error);
7049
- } else {
7050
- resolve({ address, family });
7051
- }
7052
- });
7053
- });
7054
- return dnsResolution;
7055
- };
7056
-
7057
- const parseHostname = (hostname) => {
7058
- if (hostname === "0.0.0.0") {
7059
- return {
7060
- type: "ip",
7061
- label: "unspecified",
7062
- version: 4,
7063
- };
7064
- }
7065
- if (
7066
- hostname === "::" ||
7067
- hostname === "0000:0000:0000:0000:0000:0000:0000:0000"
7068
- ) {
7069
- return {
7070
- type: "ip",
7071
- label: "unspecified",
7072
- version: 6,
7073
- };
7074
- }
7075
- if (hostname === "127.0.0.1") {
7076
- return {
7077
- type: "ip",
7078
- label: "loopback",
7079
- version: 4,
7080
- };
7081
- }
7082
- if (
7083
- hostname === "::1" ||
7084
- hostname === "0000:0000:0000:0000:0000:0000:0000:0001"
7085
- ) {
7086
- return {
7087
- type: "ip",
7088
- label: "loopback",
7089
- version: 6,
7090
- };
7091
- }
7092
- const ipVersion = isIP(hostname);
7093
- if (ipVersion === 0) {
7094
- return {
7095
- type: "hostname",
7096
- };
7097
- }
7098
- return {
7099
- type: "ip",
7100
- version: ipVersion,
7101
- };
7102
- };
7103
-
7104
- const createIpGetters = () => {
7105
- const networkAddresses = [];
7106
- const networkInterfaceMap = networkInterfaces();
7107
- for (const key of Object.keys(networkInterfaceMap)) {
7108
- for (const networkAddress of networkInterfaceMap[key]) {
7109
- networkAddresses.push(networkAddress);
7110
- }
7111
- }
7184
+
7185
+ const createReason = (reasonString) => {
7112
7186
  return {
7113
- getFirstInternalIp: ({ preferIpv6 }) => {
7114
- const isPref = preferIpv6 ? isIpV6 : isIpV4;
7115
- let firstInternalIp;
7116
- for (const networkAddress of networkAddresses) {
7117
- if (networkAddress.internal) {
7118
- firstInternalIp = networkAddress.address;
7119
- if (isPref(networkAddress)) {
7120
- break;
7121
- }
7122
- }
7123
- }
7124
- return firstInternalIp;
7125
- },
7126
- getFirstExternalIp: ({ preferIpv6 }) => {
7127
- const isPref = preferIpv6 ? isIpV6 : isIpV4;
7128
- let firstExternalIp;
7129
- for (const networkAddress of networkAddresses) {
7130
- if (!networkAddress.internal) {
7131
- firstExternalIp = networkAddress.address;
7132
- if (isPref(networkAddress)) {
7133
- break;
7134
- }
7135
- }
7136
- }
7137
- return firstExternalIp;
7138
- },
7187
+ toString: () => reasonString,
7139
7188
  };
7140
7189
  };
7141
7190
 
7142
- const isIpV4 = (networkAddress) => {
7143
- // node 18.5
7144
- if (typeof networkAddress.family === "number") {
7145
- return networkAddress.family === 4;
7146
- }
7147
- return networkAddress.family === "IPv4";
7148
- };
7149
-
7150
- const isIpV6 = (networkAddress) => !isIpV4(networkAddress);
7191
+ const STOP_REASON_INTERNAL_ERROR = createReason("Internal error");
7192
+ const STOP_REASON_PROCESS_SIGHUP = createReason("process SIGHUP");
7193
+ const STOP_REASON_PROCESS_SIGTERM = createReason("process SIGTERM");
7194
+ const STOP_REASON_PROCESS_SIGINT = createReason("process SIGINT");
7195
+ const STOP_REASON_PROCESS_BEFORE_EXIT = createReason(
7196
+ "process before exit",
7197
+ );
7198
+ const STOP_REASON_PROCESS_EXIT = createReason("process exit");
7199
+ const STOP_REASON_NOT_SPECIFIED = createReason("not specified");
7151
7200
 
7152
7201
  const TIMING_NOOP = () => {
7153
7202
  return { end: () => {} };
7154
7203
  };
7155
7204
 
7156
- const permissionsSatisfy = (permissionsSet, permissionsRequired) => {
7157
- for (const p of permissionsRequired) {
7158
- if (!permissionsSet.has(p)) {
7159
- return false;
7160
- }
7161
- }
7162
- return true;
7163
- };
7164
-
7165
7205
  /**
7166
- * Starts an HTTP (or HTTPS/HTTP2) server.
7167
- *
7168
- * @param {Object} params
7206
+ * Start an http server (https and http2 optional) answering each request with
7207
+ * the first route producing a response.
7169
7208
  *
7170
- * @param {Array<Object>} [params.routes=[]] - Route definitions for the server.
7171
- * Each route is an object with the following properties:
7172
- * - `endpoint` {string} — Required. A string like `"GET /users/:id"` combining the HTTP method
7173
- * and the resource path. Use `"*"` as the method to match all methods, and URL patterns
7174
- * such as `:param` for named segments or `*` for wildcards.
7175
- * - `fetch` {Function} — Required. `(request, helpers) => response`. Must return a `Response`,
7176
- * a response-like object `{ status, headers, body }`, or `null`/`undefined` to skip the route.
7177
- * - `description` {string} — Optional human-readable description shown in the route inspector.
7178
- * - `permissionsRequired` {Array<string>} — Optional. Permissions the client must hold to
7179
- * access the route. An empty array means anyone can access. When omitted the route is hidden
7180
- * by default (404 for everyone).
7181
- * - `permissionsToSee` {Array<string>} — Optional. Permissions needed to know the route exists
7182
- * (403 instead of 404 when access is denied). An empty array means the route is visible to
7183
- * everyone even when access is denied.
7184
- * - `availableMediaTypes` {Array<string>} — Content-types this route can produce (drives
7185
- * `Accept` content negotiation).
7186
- * - `availableLanguages` {Array<string>} — Languages this route can respond with.
7187
- * - `availableEncodings` {Array<string>} — Encodings this route supports.
7188
- * - `acceptedMediaTypes` {Array<string>} — Content-types accepted for request bodies
7189
- * (POST/PATCH/PUT).
7190
- * - `clientCodeExample` {string|Function} — Optional code snippet displayed in the route
7191
- * inspector as a usage example.
7192
- * - `declarationSource` {string} — Optional file URL of where the route is declared, shown
7193
- * in the route inspector when `canExposeSensitiveData` is enabled.
7194
- * - `headers` {Object} — Optional header pattern that must match for the route to be selected.
7209
+ * @param {Object} [params={}]
7210
+ * @param {Array<Object>} [params.routes=[]] - Route descriptors, tried in order. Each has:
7211
+ * - `endpoint` {string} — Required. `"GET /users/:id"`: an http method (or `*` for any)
7212
+ * and a resource pattern. `:name` captures a segment and `*` a run of segments, both
7213
+ * land in `request.params`; `?page=:page` captures a search param. `"*"` alone
7214
+ * matches everything. An endpoint ending in `.websocket` marks a websocket route.
7215
+ * - `fetch` {Function} — Required. `(request, helpers) => response`, async or not.
7216
+ * Returns a `Response`, a plain `{ status, statusText, statusMessage, headers, body,
7217
+ * timing }` object (`status` defaults to 404, `statusMessage` feeds the body of 4xx/5xx
7218
+ * responses), a `WebSocketResponse`, or `null`/`undefined` to let the next route try.
7219
+ * `request` is described in docs/handling_requests.md. `helpers` holds `timing(name)`,
7220
+ * `injectResponseHeader(name, value)`, `contentNegotiation` (`{ mediaType, language,
7221
+ * version, encoding }` picked from the `available*` lists below), `responseCookies`
7222
+ * (`set`/`delete`), `hasPermissions`, `getAllPermissions`, `router`,
7223
+ * `canExposeSensitiveData`, plus whatever plugins add with "augmentRouteFetchSecondArg".
7224
+ * - `headers` {Object} — Header pattern the request must match
7225
+ * (`{ upgrade: "websocket" }` marks a websocket route).
7226
+ * - `availableMediaTypes`, `availableLanguages`, `availableVersions`, `availableEncodings`
7227
+ * {Array} — What the route can produce, in order of preference. Drives content
7228
+ * negotiation, the `vary` header and 406 responses. Media types are inferred from the
7229
+ * endpoint extension when omitted.
7230
+ * - `acceptedMediaTypes` {Array<string>} — Request body media types accepted by
7231
+ * POST/PATCH/PUT (415 otherwise).
7232
+ * - `permissionsRequired` {Array<string>} — Permissions (granted by "grantPermissions"
7233
+ * plugins) needed to access the route; `[]` opens it to everyone. Once any route
7234
+ * declares permissions, a route without them is hidden (404) from everyone.
7235
+ * - `permissionsToSee` {Array<string>} — Permissions needed to learn the route exists
7236
+ * (403 instead of 404 when access is denied); `[]` makes it visible to everyone.
7237
+ * - `description` {string}, `clientCodeExample` {string|Function}, `declarationSource`
7238
+ * {string} — Shown by the route inspector at `/.internal/route_inspector`.
7195
7239
  *
7196
- * @param {Array<Object>} [params.plugins=[]] - Server plugins that extend behaviour (hooks such
7197
- * as `grantPermissions`, `redirectRequest`, `handleError`, etc.). See plugin documentation for
7198
- * the exact shape; plugins are not described here in detail.
7240
+ * @param {Array<Object>} [params.plugins=[]] - Server plugins, see docs/plugins.md.
7241
+ * @param {number} [params.port=0] - `0` lets the OS pick a free port.
7242
+ * @param {number} [params.portHint] - With `port: 0`, try this port first, then the next ones.
7243
+ * @param {string} [params.hostname="localhost"] - Hostname or ip to listen to; `server.origin` is built from it.
7244
+ * @param {boolean} [params.acceptAnyIp=false] - Listen on every interface (`0.0.0.0` or `::`)
7245
+ * so that other machines on the network can reach the server; `server.origins.externalip`
7246
+ * then tells the address to use from there.
7247
+ * @param {boolean} [params.preferIpv6] - Favor ipv6 addresses in `server.origins`.
7248
+ * @param {Array<string>|true} [params.allowedHosts=[]] - Names the server may be requested
7249
+ * with (the `host` header), on top of localhost, `hostname`, the machine name and ips,
7250
+ * and every ip when `acceptAnyIp`. `".example.com"` covers the subdomains. Any other
7251
+ * host is answered 403: a page served elsewhere cannot read the responses by rebinding
7252
+ * its DNS name to this machine. `true` disables the check.
7253
+ * @param {Object|false} [params.https=false] - `{ certificate, privateKey }` (PEM strings) to serve https.
7254
+ * @param {boolean} [params.redirectHttpToHttps] - With https, answer http requests with a 301
7255
+ * to the https origin. Defaults to true unless `allowHttpRequestOnHttps` is set.
7256
+ * @param {boolean} [params.allowHttpRequestOnHttps=false] - With https, also serve plain http
7257
+ * requests on the same port (`request.origin` tells them apart).
7258
+ * @param {boolean} [params.http2=false] - Serve http2 (needs `https`, http/1.1 clients are still
7259
+ * accepted). Brings nothing on localhost; a page loading many modules from another machine
7260
+ * loads several times faster. Http2 has no reason phrase: `statusText` then only reaches the
7261
+ * logs and the body of 4xx/5xx responses.
7262
+ * @param {boolean} [params.http1Allowed=true] - With http2, still accept http/1.1 clients.
7263
+ * @param {string} [params.logLevel] - `"debug"`, `"info"` (default), `"warn"`, `"error"` or `"off"`.
7264
+ * @param {string} [params.routerLogLevel] - Same, for the router logs (which route matched).
7265
+ * @param {boolean} [params.startLog=true] - Log "server started at …" once listening.
7266
+ * @param {string} [params.serverName="server"] - Name used in the logs.
7267
+ * @param {AbortSignal} [params.signal] - Cancels the start (an AbortError is thrown). Once
7268
+ * listening the server is stopped with `stop`.
7269
+ * @param {boolean} [params.stopOnSIGINT] - Stop on SIGINT (ctrl+c). Defaults to true, except
7270
+ * inside a cluster worker where the primary process is in charge.
7271
+ * @param {boolean} [params.stopOnExit=true] - Stop on SIGHUP, SIGTERM, beforeExit and exit.
7272
+ * @param {boolean} [params.stopOnInternalError=false] - Stop when a route throws (after the
7273
+ * "handleError" plugins answered).
7274
+ * @param {boolean} [params.keepProcessAlive=true] - When false the server alone does not keep
7275
+ * the process alive.
7276
+ * @param {boolean} [params.canExposeSensitiveData=false] - Lets the server hand out what
7277
+ * belongs to the machine it runs on. Development only, never in production. It unlocks:
7278
+ * - file paths in status texts (a 404 from `createFileSystemFetch` says which path);
7279
+ * - the declaration source of every route in the route inspector, where every route is
7280
+ * listed whatever its permissions;
7281
+ * - `GET /.internal/open_file/*`, opening a file of the machine in the editor;
7282
+ * - `GET /@jsenv/server/*`, serving this package's own client files;
7283
+ * - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
7284
+ * subscribes to in order to reload when the server restarts.
7285
+ * @param {boolean|Object} [params.serverTiming=false] - `true` or `{ minDuration }` to send
7286
+ * `server-timing` response headers: the time to start responding, the routing of each
7287
+ * plugin, what routes measure with `helpers.timing` and the `timing` a response hands
7288
+ * back. `minDuration` (ms) drops the entries that took less: 0 keeps everything (what a
7289
+ * test wants), a human reading devtools usually wants the sub-millisecond noise gone.
7290
+ * @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
7291
+ * still has no response after that many ms (0 disables).
7292
+ * @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
7293
+ * @param {number} [params.responseTimeout=600000] - Ms a route can take to start responding
7294
+ * before a 504 is sent instead (10 minutes).
7295
+ * @param {number} [params.requestBodyMaxSize=1048576] - Bytes `request.json()`, `text()`,
7296
+ * `buffer()` and `queryString()` accept before answering 413 (1 MiB). A route can pass
7297
+ * its own `{ maxSize }` to these readers; `request.body` is never limited.
7199
7298
  *
7200
- * @param {number} [params.port=0] - Port to listen on. Defaults to `0` which lets the OS assign
7201
- * a random available port — useful in tests to avoid port conflicts. A fixed port such as
7202
- * `3000` can be passed for a predictable address.
7203
- *
7204
- * @param {boolean} [params.acceptAnyIp=false] - When `true` the server binds to all network
7205
- * interfaces (`0.0.0.0` / `::`), making it reachable from other machines on the network.
7206
- * When `false` (default) it only listens on the configured `hostname`.
7207
- *
7208
- * @param {boolean} [params.canExposeSensitiveData=false] - Unlocks developer-facing features that
7209
- * should never be enabled in production:
7210
- * - Declaration source links in the route inspector (shows where each route is defined).
7211
- * - The `/.internal/open_file` endpoint that can open files on the server machine.
7212
- * - Auto-reload client notification when the server restarts.
7213
- * - All routes are visible in the route inspector regardless of permission configuration.
7214
- * Set to `true` during local development for a better DX.
7215
- *
7216
- * @returns {Promise<Object>} Resolves to the running server object with `{ origin, port, stop, … }`.
7299
+ * @returns {Promise<Object>} The server: `{ origin, origins, port, hostname, nodeServer,
7300
+ * webSocketOrigin, stop, stoppedPromise, getStatus, addEffect }`.
7301
+ * - `origins`: `{ local, localip, externalip }`, `origin` being `origins.local`.
7302
+ * - `stop(reason)`: resolves once every connection is closed; `reason` can be anything
7303
+ * and defaults to `STOP_REASON_NOT_SPECIFIED`.
7304
+ * - `stoppedPromise`: resolves with the reason the server stopped for (one of the
7305
+ * `STOP_REASON_*` exports or what was given to `stop`).
7306
+ * - `getStatus()`: `"starting"`, `"opened"`, `"stopping"` or `"stopped"`.
7307
+ * - `addEffect(callback)`: runs `callback` right away; the function it returns runs
7308
+ * when the server stops.
7217
7309
  */
7218
7310
  const startServer = async ({
7219
7311
  signal = new AbortController().signal,
@@ -7229,6 +7321,7 @@ const startServer = async ({
7229
7321
  allowHttpRequestOnHttps = false,
7230
7322
  acceptAnyIp = false,
7231
7323
  preferIpv6,
7324
+ allowedHosts = [],
7232
7325
  hostname = "localhost",
7233
7326
  port = 0, // assign a random available port
7234
7327
  portHint,
@@ -7244,17 +7337,7 @@ const startServer = async ({
7244
7337
  keepProcessAlive = true,
7245
7338
  routes = [],
7246
7339
  plugins = [],
7247
- // When enabled, the server gives more power:
7248
- // - each route show the source where it was declared
7249
- // - server can be requested to open a file on the machine
7250
- // - client can subscribe to server to detect when it restarts
7251
- // This param should be enabled ONLY during development on your machine
7252
7340
  canExposeSensitiveData = false,
7253
- nagle = true,
7254
- // false | true | { minDuration }: server-timing response headers.
7255
- // minDuration (ms) drops entries that took less — 0 keeps everything, which
7256
- // is what a test wants; a human reading devtools usually wants the noise
7257
- // gone (the jsenv dev server passes 0.5 for that).
7258
7341
  serverTiming = false,
7259
7342
  requestWaitingMs = 0,
7260
7343
  requestWaitingCallback = ({ request, requestWaitingMs }) => {
@@ -7268,13 +7351,8 @@ const startServer = async ({
7268
7351
  ),
7269
7352
  );
7270
7353
  },
7271
- // timeAllocated to start responding to a request
7272
- // after this delay the server will respond with 504
7273
- responseTimeout = 60_000 * 10, // 10s
7274
- // time allocated to server code to start reading the request body
7275
- // after this delay the underlying stream is destroyed, attempting to read it would throw
7276
- // if used the stream stays opened, it's only if the stream is not read at all that it gets destroyed
7277
- requestBodyLifetime = 60_000 * 2, // 2s
7354
+ responseTimeout = 60_000 * 10, // 10 minutes
7355
+ requestBodyMaxSize = 1024 * 1024,
7278
7356
  ...rest
7279
7357
  } = {}) => {
7280
7358
  // param validations
@@ -7299,6 +7377,11 @@ const startServer = async ({
7299
7377
  if (http2 && !https) {
7300
7378
  throw new Error(`http2 needs https`);
7301
7379
  }
7380
+ if (allowedHosts !== true && !Array.isArray(allowedHosts)) {
7381
+ throw new TypeError(
7382
+ `allowedHosts must be an array of hosts or true, got ${allowedHosts}`,
7383
+ );
7384
+ }
7302
7385
  }
7303
7386
  const logger = createLogger({ logLevel });
7304
7387
  // param warnings and normalization
@@ -7370,9 +7453,7 @@ const startServer = async ({
7370
7453
  let nodeServer;
7371
7454
  const startServerOperation = Abort.startOperation();
7372
7455
  const stopCallbackSet = new Set();
7373
- const serverOrigins = {
7374
- local: "", // favors hostname when possible
7375
- };
7456
+ let serverOrigins;
7376
7457
 
7377
7458
  try {
7378
7459
  startServerOperation.addAbortSignal(signal);
@@ -7397,63 +7478,15 @@ const startServer = async ({
7397
7478
  nodeServer.unref();
7398
7479
  }
7399
7480
 
7400
- const createOrigin = (hostname) => {
7401
- const protocol = https ? "https" : "http";
7402
- if (isIP(hostname) === 6) {
7403
- return `${protocol}://[${hostname}]`;
7404
- }
7405
- return `${protocol}://${hostname}`;
7406
- };
7407
-
7408
- const ipGetters = createIpGetters();
7409
- let hostnameToListen;
7410
- if (acceptAnyIp) {
7411
- const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
7412
- serverOrigins.local = createOrigin(firstInternalIp);
7413
- serverOrigins.localip = createOrigin(firstInternalIp);
7414
- const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
7415
- serverOrigins.externalip = createOrigin(firstExternalIp);
7416
- hostnameToListen = preferIpv6 ? "::" : "0.0.0.0";
7417
- } else {
7418
- hostnameToListen = hostname;
7419
- }
7420
- const hostnameInfo = parseHostname(hostname);
7421
- if (hostnameInfo.type === "ip") {
7422
- if (acceptAnyIp) {
7423
- throw new Error(
7424
- `hostname cannot be an ip when acceptAnyIp is enabled, got ${hostname}`,
7425
- );
7426
- }
7427
-
7428
- preferIpv6 = hostnameInfo.version === 6;
7429
- const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
7430
- serverOrigins.local = createOrigin(firstInternalIp);
7431
- serverOrigins.localip = createOrigin(firstInternalIp);
7432
- if (hostnameInfo.label === "unspecified") {
7433
- const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 });
7434
- serverOrigins.externalip = createOrigin(firstExternalIp);
7435
- } else if (hostnameInfo.label === "loopback") {
7436
- // nothing
7437
- } else {
7438
- serverOrigins.local = createOrigin(hostname);
7439
- }
7440
- } else {
7441
- const hostnameDnsResolution = await applyDnsResolution(hostname, {
7442
- verbatim: true,
7443
- });
7444
- if (hostnameDnsResolution) {
7445
- const hostnameIp = hostnameDnsResolution.address;
7446
- serverOrigins.localip = createOrigin(hostnameIp);
7447
- serverOrigins.local = createOrigin(hostname);
7448
- } else {
7449
- const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 });
7450
- // fallback to internal ip because there is no ip
7451
- // associated to this hostname on operating system (in hosts file)
7452
- hostname = firstInternalIp;
7453
- hostnameToListen = firstInternalIp;
7454
- serverOrigins.local = createOrigin(firstInternalIp);
7455
- }
7456
- }
7481
+ const resolved = await resolveServerOrigins({
7482
+ https,
7483
+ hostname,
7484
+ acceptAnyIp,
7485
+ preferIpv6,
7486
+ });
7487
+ hostname = resolved.hostname;
7488
+ serverOrigins = resolved.serverOrigins;
7489
+ const hostnameToListen = resolved.hostnameToListen;
7457
7490
 
7458
7491
  port = await listen({
7459
7492
  signal: startServerOperation.signal,
@@ -7488,6 +7521,12 @@ const startServer = async ({
7488
7521
  // so we prefer https://locahost or https://local_hostname
7489
7522
  // over the ip
7490
7523
  const serverOrigin = serverOrigins.local;
7524
+ const isHostAllowed = createHostChecker({
7525
+ allowedHosts,
7526
+ hostname,
7527
+ serverOrigins,
7528
+ acceptAnyIp,
7529
+ });
7491
7530
 
7492
7531
  // now the server is started (listening) it cannot be aborted anymore
7493
7532
  // (otherwise an AbortError is thrown to the code calling "startServer")
@@ -7551,15 +7590,11 @@ const startServer = async ({
7551
7590
  );
7552
7591
  stopCallbackSet.add(removeConnectionErrorListener);
7553
7592
 
7554
- const connectionsTracker = trackServerPendingConnections(nodeServer, {
7555
- http2,
7556
- });
7593
+ const connectionsTracker = trackServerPendingConnections(nodeServer);
7557
7594
  // opened connection must be shutdown before the close event is emitted
7558
7595
  stopCallbackSet.add(connectionsTracker.stop);
7559
7596
 
7560
- const pendingRequestsTracker = trackServerPendingRequests(nodeServer, {
7561
- http2,
7562
- });
7597
+ const pendingRequestsTracker = trackServerPendingRequests(nodeServer);
7563
7598
  // ensure pending requests got a response from the server
7564
7599
  stopCallbackSet.add((reason) => {
7565
7600
  pendingRequestsTracker.stop({
@@ -7612,7 +7647,7 @@ const startServer = async ({
7612
7647
  serverTiming && typeof serverTiming === "object"
7613
7648
  ? serverTiming.minDuration || 0
7614
7649
  : 0;
7615
- const getResponseProperties = async (request, { pushResponse }) => {
7650
+ const getResponseProperties = async (request) => {
7616
7651
  const timings = {};
7617
7652
  const timing = serverTiming
7618
7653
  ? (name) => {
@@ -7633,9 +7668,7 @@ const startServer = async ({
7633
7668
  request.logger.info(
7634
7669
  request.headers["upgrade"] === "websocket"
7635
7670
  ? `GET ${request.url} ${websocketSuffixColorized}`
7636
- : request.parent
7637
- ? `Push ${request.resource}`
7638
- : `${request.method} ${request.url}`,
7671
+ : `${request.method} ${request.url}`,
7639
7672
  );
7640
7673
  let requestWaitingTimeout;
7641
7674
  if (requestWaitingMs) {
@@ -7715,23 +7748,22 @@ const startServer = async ({
7715
7748
  );
7716
7749
  },
7717
7750
  );
7718
- // the node request readable stream is never closed because
7719
- // the response headers contains "connection: keep-alive"
7720
- // In this scenario we want to disable READABLE_STREAM_TIMEOUT warning
7721
- if (
7722
- responseProperties.headers.connection === "keep-alive" &&
7723
- request.body
7724
- ) {
7725
- clearTimeout(request.body.timeout);
7726
- }
7751
+ serverPluginsController.callHooks("inspectResponse", request, {
7752
+ response: responseProperties,
7753
+ warn: (message) => {
7754
+ request.logger.warn(message);
7755
+ },
7756
+ });
7727
7757
  return responseProperties;
7728
7758
  };
7729
7759
 
7730
7760
  let timeout;
7761
+ let timedOut = false;
7731
7762
  try {
7732
7763
  request = applyRequestInternalRedirection(request);
7733
7764
  const timeoutResponsePropertiesPromise = new Promise((resolve) => {
7734
7765
  timeout = setTimeout(() => {
7766
+ timedOut = true;
7735
7767
  resolve({
7736
7768
  // the correct status code should be 500 because it's
7737
7769
  // we don't really know what takes time
@@ -7747,7 +7779,7 @@ const startServer = async ({
7747
7779
  const routerResponsePropertiesPromise = (async () => {
7748
7780
  const fetchSecondArg = {
7749
7781
  timing,
7750
- pushResponse,
7782
+ canExposeSensitiveData,
7751
7783
  injectResponseHeader: (name, value) => {
7752
7784
  if (!headersToInject) {
7753
7785
  headersToInject = {};
@@ -7769,54 +7801,28 @@ const startServer = async ({
7769
7801
  }
7770
7802
  },
7771
7803
  );
7772
- // Build permission helpers, lazily per request.
7773
- // A single shared iterator ensures each plugin is called at most once.
7774
- // hasPermissions stops early once the rule is satisfied;
7775
- // getAllPermissions drains all remaining plugins.
7776
- const permissionsSet = new Set();
7777
- const nextPermissionsHook =
7778
- serverPluginsController.createAsyncHookIterator(
7779
- "grantPermissions",
7780
- request,
7781
- );
7782
- const drainPermissionsUntil = async (permissionsRequired) => {
7783
- for (;;) {
7784
- if (
7785
- permissionsRequired !== undefined &&
7786
- permissionsSatisfy(permissionsSet, permissionsRequired)
7787
- ) {
7788
- return true;
7789
- }
7790
- const { done, value } = await nextPermissionsHook();
7791
- if (done) {
7792
- break;
7793
- }
7794
- if (Array.isArray(value)) {
7795
- for (const p of value) {
7796
- permissionsSet.add(p);
7797
- }
7798
- }
7799
- }
7800
- return false;
7801
- };
7802
- const getAllPermissions = async () => {
7803
- await drainPermissionsUntil(undefined);
7804
- return permissionsSet;
7805
- };
7806
- const hasPermissions = async (permissionsRequired) => {
7807
- if (permissionsRequired.length === 0) {
7808
- return true;
7809
- }
7810
- return drainPermissionsUntil(permissionsRequired);
7811
- };
7812
- fetchSecondArg.getAllPermissions = getAllPermissions;
7813
- fetchSecondArg.hasPermissions = hasPermissions;
7804
+ Object.assign(
7805
+ fetchSecondArg,
7806
+ createPermissionHelpers(serverPluginsController, request),
7807
+ );
7814
7808
  const routerResponseProperties = await router.match(
7815
7809
  request,
7816
7810
  fetchSecondArg,
7817
7811
  );
7818
7812
  return routerResponseProperties;
7819
7813
  })();
7814
+ // once the 504 is sent the route keeps running: what it throws
7815
+ // afterwards would have nobody to reject to
7816
+ routerResponsePropertiesPromise.catch((e) => {
7817
+ if (timedOut) {
7818
+ logger.error(
7819
+ createDetailedMessage(`error after the 504 timeout response`, {
7820
+ "request url": request.url,
7821
+ "error stack": e.stack,
7822
+ }),
7823
+ );
7824
+ }
7825
+ });
7820
7826
  const responseProperties = await Promise.race([
7821
7827
  timeoutResponsePropertiesPromise,
7822
7828
  routerResponsePropertiesPromise,
@@ -7829,6 +7835,21 @@ const startServer = async ({
7829
7835
  // let it propagate to the caller that should catch this
7830
7836
  throw e;
7831
7837
  }
7838
+ // an error carrying its own response (a request body too large for
7839
+ // instance) is not an internal error: it is answered as such, whatever
7840
+ // the plugins
7841
+ if (e && typeof e.asResponse === "function") {
7842
+ const responseProperties = composeTwoResponses(
7843
+ {
7844
+ status: 500,
7845
+ headers: {
7846
+ "cache-control": "no-store",
7847
+ },
7848
+ },
7849
+ await e.asResponse(),
7850
+ );
7851
+ return finalizeResponseProperties(responseProperties);
7852
+ }
7832
7853
  // internal error, create 500 response
7833
7854
  if (
7834
7855
  // stopOnInternalError stops server only if requestToResponse generated
@@ -7866,20 +7887,28 @@ const startServer = async ({
7866
7887
  return finalizeResponseProperties(responseProperties);
7867
7888
  }
7868
7889
  };
7890
+ // When a response is sent before the request body was read (a 413, a 404
7891
+ // on an upload...) node keeps draining what the client sends so that the
7892
+ // client gets to read the response, as nginx does with its lingering close.
7893
+ // A client that has not finished sending after that delay is cut off:
7894
+ // draining a body forever is what the 413 was meant to avoid.
7895
+ const lingerAfterEarlyResponse = (nodeRequest) => {
7896
+ if (nodeRequest.complete) {
7897
+ return;
7898
+ }
7899
+ const lingerTimeout = setTimeout(() => {
7900
+ nodeRequest.socket.destroy();
7901
+ }, REQUEST_BODY_LINGER_MS).unref();
7902
+ nodeRequest.once("close", () => {
7903
+ clearTimeout(lingerTimeout);
7904
+ });
7905
+ };
7869
7906
  const sendResponse = async (
7870
7907
  responseStream,
7871
7908
  responseProperties,
7872
7909
  { signal, request },
7873
7910
  ) => {
7874
- // When "pushResponse" is called and the parent response has no body
7875
- // the parent response is immediatly ended. It means child responses (pushed streams)
7876
- // won't get a chance to be pushed.
7877
- // To let a chance to pushed streams we wait a little before sending the response
7878
7911
  const ignoreBody = request.method === "HEAD";
7879
- const bodyIsEmpty = !responseProperties.body || ignoreBody;
7880
- if (bodyIsEmpty && request.logger.hasPushChild) {
7881
- await new Promise((resolve) => setTimeout(resolve));
7882
- }
7883
7912
  await writeNodeResponse(responseStream, responseProperties, {
7884
7913
  signal,
7885
7914
  ignoreBody,
@@ -7910,6 +7939,15 @@ const startServer = async ({
7910
7939
 
7911
7940
  {
7912
7941
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
7942
+ const requestHost = nodeRequest.authority || nodeRequest.headers.host;
7943
+ if (!isHostAllowed(requestHost)) {
7944
+ logger.warn(
7945
+ `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
7946
+ );
7947
+ nodeResponse.writeHead(403, { "content-type": "text/plain" });
7948
+ nodeResponse.end("Host not allowed");
7949
+ return;
7950
+ }
7913
7951
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
7914
7952
  nodeResponse.writeHead(301, {
7915
7953
  location: `${serverOrigin}${nodeRequest.url}`,
@@ -7928,164 +7966,29 @@ const startServer = async ({
7928
7966
 
7929
7967
  const [receiveRequestOperation, sendResponseOperation] =
7930
7968
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
7931
- const request = fromNodeRequest(nodeRequest, {
7932
- signal: stopAbortSignal,
7933
- serverOrigin,
7934
- requestBodyLifetime,
7935
- logger,
7936
- nagle,
7937
- });
7938
-
7969
+ let request;
7939
7970
  try {
7940
- const responseProperties = await getResponseProperties(request, {
7941
- pushResponse: async ({ path, method }) => {
7942
- const pushRequestLogger = request.logger.forPush();
7943
- if (typeof path !== "string" || path[0] !== "/") {
7944
- pushRequestLogger.warn(
7945
- `response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
7946
- );
7947
- return;
7948
- }
7949
- if (!request.http2) {
7950
- pushRequestLogger.warn(
7951
- `response push ignored because request is not http2`,
7952
- );
7953
- return;
7954
- }
7955
- const canPushStream = testCanPushStream(nodeResponse.stream);
7956
- if (!canPushStream.can) {
7957
- pushRequestLogger.debug(
7958
- `response push ignored because ${canPushStream.reason}`,
7959
- );
7960
- return;
7961
- }
7962
-
7963
- let preventedByPlugin = null;
7964
- const prevent = () => {
7965
- preventedByPlugin = serverPluginsController.getCurrentPlugin();
7966
- };
7967
- serverPluginsController.callHooksUntil(
7968
- "onResponsePush",
7969
- { path, method },
7970
- { request, prevent },
7971
- () => preventedByPlugin,
7972
- );
7973
- if (preventedByPlugin) {
7974
- pushRequestLogger.debug(
7975
- `response push prevented by "${preventedByPlugin.name}" plugin`,
7976
- );
7977
- return;
7978
- }
7979
-
7980
- const http2Stream = nodeResponse.stream;
7981
-
7982
- // being able to push a stream is nice to have
7983
- // so when it fails it's not critical
7984
- const onPushStreamError = (e) => {
7985
- pushRequestLogger.error(
7986
- createDetailedMessage(
7987
- `An error occured while pushing a stream to the response for ${request.resource}`,
7988
- {
7989
- "error stack": e.stack,
7990
- },
7991
- ),
7992
- );
7993
- };
7994
-
7995
- // not aborted, let's try to push a stream into that response
7996
- // https://nodejs.org/docs/latest-v16.x/api/http2.html#http2streampushstreamheaders-options-callback
7997
- let pushStream;
7998
- try {
7999
- pushStream = await new Promise((resolve, reject) => {
8000
- http2Stream.pushStream(
8001
- {
8002
- ":path": path,
8003
- ...(method ? { ":method": method } : {}),
8004
- },
8005
- async (
8006
- error,
8007
- pushStream,
8008
- // headers
8009
- ) => {
8010
- if (error) {
8011
- reject(error);
8012
- }
8013
- resolve(pushStream);
8014
- },
8015
- );
8016
- });
8017
- } catch (e) {
8018
- onPushStreamError(e);
8019
- return;
8020
- }
8021
-
8022
- const abortController = new AbortController();
8023
- // It's possible to get NGHTTP2_REFUSED_STREAM errors here
8024
- // https://github.com/nodejs/node/issues/20824
8025
- const pushErrorCallback = (error) => {
8026
- onPushStreamError(error);
8027
- abortController.abort();
8028
- };
8029
- pushStream.on("error", pushErrorCallback);
8030
- sendResponseOperation.addEndCallback(() => {
8031
- pushStream.removeListener("error", onPushStreamError);
8032
- });
8033
-
8034
- await sendResponseOperation.withSignal(async (signal) => {
8035
- const pushResponseOperation = Abort.startOperation();
8036
- pushResponseOperation.addAbortSignal(signal);
8037
- pushResponseOperation.addAbortSignal(abortController.signal);
8038
-
8039
- const pushRequest = createPushRequest(request, {
8040
- signal: pushResponseOperation.signal,
8041
- pathname: path,
8042
- method,
8043
- logger: pushRequestLogger,
8044
- });
8045
-
8046
- try {
8047
- const responseProperties = await getResponseProperties(
8048
- pushRequest,
8049
- {
8050
- pushResponse: () => {
8051
- pushRequest.logger.warn(
8052
- `response push ignored because nested push is not supported`,
8053
- );
8054
- },
8055
- },
8056
- );
8057
- if (!abortController.signal.aborted) {
8058
- if (pushStream.destroyed) {
8059
- abortController.abort();
8060
- } else if (!http2Stream.pushAllowed) {
8061
- abortController.abort();
8062
- } else if (responseProperties.requestAborted) {
8063
- } else {
8064
- const responseLength =
8065
- responseProperties.headers["content-length"] || 0;
8066
- const { effectiveRecvDataLength, remoteWindowSize } =
8067
- http2Stream.session.state;
8068
- if (
8069
- effectiveRecvDataLength + responseLength >
8070
- remoteWindowSize
8071
- ) {
8072
- pushRequest.logger.debug(
8073
- `Aborting stream to prevent exceeding remoteWindowSize`,
8074
- );
8075
- abortController.abort();
8076
- }
8077
- }
8078
- }
8079
- await sendResponse(pushStream, responseProperties, {
8080
- signal: pushResponseOperation.signal,
8081
- request: pushRequest,
8082
- });
8083
- } finally {
8084
- await pushResponseOperation.end();
8085
- }
8086
- });
8087
- },
7971
+ request = fromNodeRequest(nodeRequest, {
7972
+ signal: stopAbortSignal,
7973
+ serverOrigin,
7974
+ logger,
7975
+ requestBodyMaxSize,
8088
7976
  });
7977
+ } catch (e) {
7978
+ // the request cannot even be read: there is nothing to hand to the routes
7979
+ logger.error(
7980
+ createDetailedMessage(`error while reading request`, {
7981
+ "request url": nodeRequest.url,
7982
+ "error stack": e.stack,
7983
+ }),
7984
+ );
7985
+ nodeResponse.writeHead(500);
7986
+ nodeResponse.end();
7987
+ return;
7988
+ }
7989
+
7990
+ try {
7991
+ const responseProperties = await getResponseProperties(request);
8089
7992
  const webSocketHandler = getWebSocketHandler(responseProperties);
8090
7993
  if (webSocketHandler) {
8091
7994
  throw new Error(
@@ -8099,6 +8002,7 @@ const startServer = async ({
8099
8002
  signal: sendResponseOperation.signal,
8100
8003
  request,
8101
8004
  });
8005
+ lingerAfterEarlyResponse(nodeRequest);
8102
8006
  } finally {
8103
8007
  await sendResponseOperation.end();
8104
8008
  }
@@ -8135,22 +8039,39 @@ const startServer = async ({
8135
8039
  };
8136
8040
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
8137
8041
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
8138
- let request = fromNodeRequest(nodeRequest, {
8139
- signal: stopAbortSignal,
8140
- serverOrigin,
8141
- requestBodyLifetime,
8142
- logger,
8143
- nagle,
8144
- });
8042
+ const requestHost = nodeRequest.headers.host;
8043
+ if (!isHostAllowed(requestHost)) {
8044
+ logger.warn(
8045
+ `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
8046
+ );
8047
+ socket.write(
8048
+ `HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\ncontent-length: 16\r\n\r\nHost not allowed`,
8049
+ );
8050
+ socket.destroy();
8051
+ return;
8052
+ }
8053
+ let request;
8054
+ try {
8055
+ request = fromNodeRequest(nodeRequest, {
8056
+ signal: stopAbortSignal,
8057
+ serverOrigin,
8058
+ logger,
8059
+ requestBodyMaxSize,
8060
+ });
8061
+ } catch (e) {
8062
+ logger.error(
8063
+ createDetailedMessage(`error while reading upgrade request`, {
8064
+ "request url": nodeRequest.url,
8065
+ "error stack": e.stack,
8066
+ }),
8067
+ );
8068
+ socket.write(`HTTP/1.1 500 Internal Server Error\r\n\r\n`);
8069
+ socket.destroy();
8070
+ return;
8071
+ }
8145
8072
  const [receiveRequestOperation, sendResponseOperation] =
8146
8073
  prepareHandleRequestOperations(nodeRequest, socket);
8147
- const responseProperties = await getResponseProperties(request, {
8148
- pushResponse: () => {
8149
- request.logger.warn(
8150
- `pushResponse ignored because it's not supported in websocket`,
8151
- );
8152
- },
8153
- });
8074
+ const responseProperties = await getResponseProperties(request);
8154
8075
  if (receiveRequestOperation.signal.aborted) {
8155
8076
  return;
8156
8077
  }
@@ -8202,7 +8123,7 @@ const startServer = async ({
8202
8123
  }
8203
8124
  return;
8204
8125
  };
8205
- // see server-polyglot.js, upgrade must be listened on https server when used
8126
+ // see server_polyglot.js, upgrade must be listened on https server when used
8206
8127
  const facadeServer = nodeServer._tlsServer || nodeServer;
8207
8128
  const removeUpgradeCallback = listenEvent(
8208
8129
  facadeServer,
@@ -8219,9 +8140,9 @@ const startServer = async ({
8219
8140
  }
8220
8141
 
8221
8142
  if (startLog) {
8222
- if (serverOrigins.network) {
8143
+ if (serverOrigins.externalip) {
8223
8144
  logger.info(
8224
- `${serverName} started at ${serverOrigins.local} (${serverOrigins.network})`,
8145
+ `${serverName} started at ${serverOrigins.local} (${serverOrigins.externalip})`,
8225
8146
  );
8226
8147
  } else {
8227
8148
  logger.info(`${serverName} started at ${serverOrigins.local}`);
@@ -8264,38 +8185,18 @@ const createNodeServer = async ({
8264
8185
  http1Allowed,
8265
8186
  });
8266
8187
  }
8267
- const { createServer } = await import("node:https");
8268
- return createServer({
8269
- cert: certificate,
8270
- key: privateKey,
8188
+ return createSecureServer({
8189
+ certificate,
8190
+ privateKey,
8191
+ http2,
8192
+ http1Allowed,
8271
8193
  });
8272
8194
  }
8273
8195
  const { createServer } = await import("node:http");
8274
8196
  return createServer();
8275
8197
  };
8276
8198
 
8277
- const testCanPushStream = (http2Stream) => {
8278
- if (!http2Stream.pushAllowed) {
8279
- return {
8280
- can: false,
8281
- reason: `stream.pushAllowed is false`,
8282
- };
8283
- }
8284
-
8285
- // See https://nodejs.org/dist/latest-v16.x/docs/api/http2.html#http2sessionstate
8286
- // And https://github.com/google/node-h2-auto-push/blob/67a36c04cbbd6da7b066a4e8d361c593d38853a4/src/index.ts#L100-L106
8287
- const { remoteWindowSize } = http2Stream.session.state;
8288
- if (remoteWindowSize === 0) {
8289
- return {
8290
- can: false,
8291
- reason: `no more remoteWindowSize`,
8292
- };
8293
- }
8294
-
8295
- return {
8296
- can: true,
8297
- };
8298
- };
8199
+ const REQUEST_BODY_LINGER_MS = 5_000;
8299
8200
 
8300
8201
  const PROCESS_TEARDOWN_EVENTS_MAP = {
8301
8202
  SIGHUP: STOP_REASON_PROCESS_SIGHUP,
@@ -8306,7 +8207,30 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
8306
8207
  };
8307
8208
 
8308
8209
  const internalErrorHtmlFileUrl = import.meta.resolve("./client/error_handler/500.html");
8210
+ let internalErrorHtmlTemplate;
8211
+ const readInternalErrorHtmlTemplate = () => {
8212
+ if (internalErrorHtmlTemplate === undefined) {
8213
+ internalErrorHtmlTemplate = readFileSync(
8214
+ new URL(internalErrorHtmlFileUrl),
8215
+ "utf8",
8216
+ );
8217
+ }
8218
+ return internalErrorHtmlTemplate;
8219
+ };
8309
8220
 
8221
+ /**
8222
+ * Server plugin turning an error thrown while handling a request into a 500
8223
+ * response (html, text or json depending on what the request accepts).
8224
+ * Without a plugin handling errors, a route that throws makes the process
8225
+ * exit. Put it last: it catches every error, so plugins handling a subset of
8226
+ * them must come before. (An error exposing `asResponse()` never reaches it:
8227
+ * the server answers it directly.)
8228
+ *
8229
+ * @param {Object} [params]
8230
+ * @param {boolean} [params.sendErrorDetails=false] - Put the error stack (and
8231
+ * its own properties for json) in the response. Development only: a stack
8232
+ * reveals file paths and code.
8233
+ */
8310
8234
  const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8311
8235
  return {
8312
8236
  name: "jsenv:error_handler",
@@ -8315,9 +8239,6 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8315
8239
  serverInternalError === null ||
8316
8240
  (typeof serverInternalError !== "object" &&
8317
8241
  typeof serverInternalError !== "function");
8318
- if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
8319
- return serverInternalError.asResponse();
8320
- }
8321
8242
  const dataToSend = serverInternalErrorIsAPrimitive
8322
8243
  ? {
8323
8244
  code: "VALUE_THROWED",
@@ -8336,18 +8257,14 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8336
8257
  const availableContentTypes = {
8337
8258
  "text/html": () => {
8338
8259
  const renderHtmlForErrorWithoutDetails = () => {
8339
- return `<p>Details not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).</p>`;
8260
+ return `<p>Details not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).</p>`;
8340
8261
  };
8341
8262
  const renderHtmlForErrorWithDetails = () => {
8342
8263
  return errorToHTML(serverInternalError);
8343
8264
  };
8344
8265
 
8345
- const internalErrorHtmlTemplate = readFileSync(
8346
- new URL(internalErrorHtmlFileUrl),
8347
- "utf8",
8348
- );
8349
8266
  const internalErrorHtml = replacePlaceholdersInHtml(
8350
- internalErrorHtmlTemplate,
8267
+ readInternalErrorHtmlTemplate(),
8351
8268
  {
8352
8269
  errorMessage: serverInternalErrorIsAPrimitive
8353
8270
  ? `Code inside server has thrown a literal.`
@@ -8381,7 +8298,7 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8381
8298
  internalErrorMessage += `\n${serverInternalError.stack}`;
8382
8299
  }
8383
8300
  } else {
8384
- internalErrorMessage += `\nDetails not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).`;
8301
+ internalErrorMessage += `\nDetails not available: to enable them use serverPluginErrorHandler({ sendErrorDetails: true }).`;
8385
8302
  }
8386
8303
 
8387
8304
  return {
@@ -8412,6 +8329,30 @@ const serverPluginErrorHandler = ({ sendErrorDetails = false } = {}) => {
8412
8329
  };
8413
8330
  };
8414
8331
 
8332
+ /**
8333
+ * A response whose body is written over time (long polling, progress
8334
+ * reporting). Return it from a route `fetch`: the headers are sent right away
8335
+ * and the handler writes the body chunks whenever it wants.
8336
+ *
8337
+ * @param {(body: { write: (chunk: string|Uint8Array) => void, end: () => void }) => void | (() => void)} responseBodyHandler
8338
+ * Receives `write` and `end`. If it returns a function, that function runs
8339
+ * when the client disconnects before `end` was called (cleanup).
8340
+ * @param {Object} [init]
8341
+ * @param {number} [init.status=200]
8342
+ * @param {string} [init.statusText]
8343
+ * @param {Object} [init.headers] - `content-type` decides whether the first
8344
+ * (empty) chunk is text or binary; defaults to text/plain.
8345
+ *
8346
+ * @example
8347
+ * {
8348
+ * endpoint: "GET /progress",
8349
+ * fetch: () => new ProgressiveResponse(({ write, end }) => {
8350
+ * const interval = setInterval(() => write("."), 100);
8351
+ * setTimeout(() => { clearInterval(interval); end(); }, 1000);
8352
+ * return () => clearInterval(interval);
8353
+ * }),
8354
+ * }
8355
+ */
8415
8356
  class ProgressiveResponse {
8416
8357
  constructor(responseBodyHandler, { status = 200, statusText, headers } = {}) {
8417
8358
  const contentType = headers ? headers["content-type"] : "text/plain";
@@ -8463,6 +8404,32 @@ const jsenvAccessControlAllowedMethods = [
8463
8404
  "OPTIONS",
8464
8405
  ];
8465
8406
 
8407
+ /**
8408
+ * Server plugin adding the CORS response headers to every response, including
8409
+ * errors (a browser treats a 500 without them as a CORS failure). Disabled
8410
+ * (returns no plugin) unless `accessControlAllowRequestOrigin` is true or
8411
+ * `accessControlAllowedOrigins` is non empty.
8412
+ *
8413
+ * @param {Object} [params]
8414
+ * @param {Array<string>} [params.accessControlAllowedOrigins=[]] - Origins allowed to
8415
+ * read responses. `*` stands for any run of characters except "/":
8416
+ * `"https://pr-*-my-app.fly.dev"` matches every preview deployment.
8417
+ * @param {Array<string>} [params.accessControlAllowedMethods] - Defaults to
8418
+ * GET, POST, PUT, DELETE, OPTIONS.
8419
+ * @param {Array<string>} [params.accessControlAllowedHeaders] - Defaults to `["x-requested-with"]`.
8420
+ * @param {boolean} [params.accessControlAllowRequestOrigin=false] - Reflect any request
8421
+ * origin, whatever `accessControlAllowedOrigins` says.
8422
+ * @param {boolean} [params.accessControlAllowRequestMethod=false] - Also allow the method a
8423
+ * preflight asks for (`access-control-request-method`).
8424
+ * @param {boolean} [params.accessControlAllowRequestHeaders=false] - Also allow the headers a
8425
+ * preflight asks for (`access-control-request-headers`).
8426
+ * @param {boolean} [params.accessControlAllowCredentials=false] - Send
8427
+ * `access-control-allow-credentials: true`.
8428
+ * @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
8429
+ * @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin` so the
8430
+ * allowed origin can read resource timing.
8431
+ * @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
8432
+ */
8466
8433
  const serverPluginCORS = ({
8467
8434
  accessControlAllowedOrigins = [],
8468
8435
  accessControlAllowedMethods = jsenvAccessControlAllowedMethods,
@@ -8576,12 +8543,7 @@ const generateAccessControlHeaders = ({
8576
8543
  // If no origin matches we fall back to "*" (only when not using credentials).
8577
8544
  let allowOrigin = null;
8578
8545
 
8579
- const requestOrigin =
8580
- "origin" in headers && headers.origin !== "null"
8581
- ? headers.origin
8582
- : "referer" in headers
8583
- ? new URL(headers.referer).origin
8584
- : null;
8546
+ const requestOrigin = readRequestOrigin(headers);
8585
8547
 
8586
8548
  if (requestOrigin) {
8587
8549
  if (allowedOriginChecker.isAllowed(requestOrigin)) {
@@ -8642,6 +8604,18 @@ const generateAccessControlHeaders = ({
8642
8604
  };
8643
8605
  };
8644
8606
 
8607
+ // the referer is a fallback for clients not sending "origin"; it comes from
8608
+ // the network and may not be a url at all
8609
+ const readRequestOrigin = (headers) => {
8610
+ if ("origin" in headers && headers.origin !== "null") {
8611
+ return headers.origin;
8612
+ }
8613
+ if ("referer" in headers && URL.canParse(headers.referer)) {
8614
+ return new URL(headers.referer).origin;
8615
+ }
8616
+ return null;
8617
+ };
8618
+
8645
8619
  const ETAG_FOR_EMPTY_CONTENT = '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"';
8646
8620
 
8647
8621
  const bufferToEtag = (buffer) => {
@@ -8663,6 +8637,17 @@ const bufferToEtag = (buffer) => {
8663
8637
  return `"${length.toString(16)}-${hashBase64StringSubset}"`;
8664
8638
  };
8665
8639
 
8640
+ /**
8641
+ * Response listing a directory, as json (an array of names) or as an html
8642
+ * page of links depending on what the request accepts (json by default).
8643
+ *
8644
+ * @param {string|URL} url - The directory.
8645
+ * @param {Object} [params]
8646
+ * @param {Object} [params.headers={}] - Request headers, read for `accept`.
8647
+ * @param {string|URL} [params.rootDirectoryUrl] - Directory served at "/", so
8648
+ * that the html links are relative to the server.
8649
+ * @returns {{ status: number, headers: Object, body: string }}
8650
+ */
8666
8651
  const fetchDirectory = (
8667
8652
  url,
8668
8653
  { headers = {}, rootDirectoryUrl } = {},
@@ -8681,7 +8666,7 @@ const fetchDirectory = (
8681
8666
  status: 200,
8682
8667
  headers: {
8683
8668
  "content-type": "application/json",
8684
- "content-length": directoryContentJson.length,
8669
+ "content-length": Buffer.byteLength(directoryContentJson),
8685
8670
  },
8686
8671
  body: directoryContentJson,
8687
8672
  };
@@ -8696,7 +8681,7 @@ const fetchDirectory = (
8696
8681
  </head>
8697
8682
 
8698
8683
  <body>
8699
- <h1>Content of directory ${url}</h1>
8684
+ <h1>Content of directory ${escapeHtml(url)}</h1>
8700
8685
  <ul>
8701
8686
  ${directoryContentArray.map((filename) => {
8702
8687
  const fileUrlObject = new URL(filename, url);
@@ -8707,8 +8692,9 @@ const fetchDirectory = (
8707
8692
  if (lstatSync(fileUrlObject).isDirectory()) {
8708
8693
  fileUrlRelativeToServer += "/";
8709
8694
  }
8695
+ const linkHtml = escapeHtml(fileUrlRelativeToServer);
8710
8696
  return `<li>
8711
- <a href="/${fileUrlRelativeToServer}">${fileUrlRelativeToServer}</a>
8697
+ <a href="/${linkHtml}">${linkHtml}</a>
8712
8698
  </li>`;
8713
8699
  }).join(`
8714
8700
  `)}
@@ -8733,24 +8719,30 @@ const fetchDirectory = (
8733
8719
  return responseProducers[bestContentType || "application/json"]();
8734
8720
  };
8735
8721
 
8736
- const convertFileSystemErrorToResponseProperties = (error) => {
8722
+ // The file path belongs to the server machine: it joins the status text only
8723
+ // when the server was started with canExposeSensitiveData.
8724
+ const convertFileSystemErrorToResponseProperties = (
8725
+ error,
8726
+ { canExposeSensitiveData },
8727
+ ) => {
8728
+ const location = canExposeSensitiveData ? ` at ${error.path}` : "";
8737
8729
  // https://iojs.org/api/errors.html#errors_eacces_permission_denied
8738
8730
  if (isErrorWithCode(error, "EACCES")) {
8739
8731
  return {
8740
8732
  status: 403,
8741
- statusText: `EACCES: No permission to read file at ${error.path}`,
8733
+ statusText: `EACCES: No permission to read file${location}`,
8742
8734
  };
8743
8735
  }
8744
8736
  if (isErrorWithCode(error, "EPERM")) {
8745
8737
  return {
8746
8738
  status: 403,
8747
- statusText: `EPERM: No permission to read file at ${error.path}`,
8739
+ statusText: `EPERM: No permission to read file${location}`,
8748
8740
  };
8749
8741
  }
8750
8742
  if (isErrorWithCode(error, "ENOENT")) {
8751
8743
  return {
8752
8744
  status: 404,
8753
- statusText: `ENOENT: File not found at ${error.path}`,
8745
+ statusText: `ENOENT: File not found${location}`,
8754
8746
  };
8755
8747
  }
8756
8748
  // file access may be temporarily blocked
@@ -8758,7 +8750,7 @@ const convertFileSystemErrorToResponseProperties = (error) => {
8758
8750
  if (isErrorWithCode(error, "EBUSY")) {
8759
8751
  return {
8760
8752
  status: 503,
8761
- statusText: `EBUSY: File is busy ${error.path}`,
8753
+ statusText: `EBUSY: File is busy${location}`,
8762
8754
  headers: {
8763
8755
  "retry-after": 0.01, // retry in 10ms
8764
8756
  },
@@ -8777,7 +8769,7 @@ const convertFileSystemErrorToResponseProperties = (error) => {
8777
8769
  if (isErrorWithCode(error, "EISDIR")) {
8778
8770
  return {
8779
8771
  status: 500,
8780
- statusText: `EISDIR: Unexpected directory operation at ${error.path}`,
8772
+ statusText: `EISDIR: Unexpected directory operation${location}`,
8781
8773
  };
8782
8774
  }
8783
8775
  return null;
@@ -8819,18 +8811,66 @@ const fileSystemPathToUrl = (value) => {
8819
8811
  };
8820
8812
 
8821
8813
  /*
8822
- * This function returns response properties in a plain object like
8823
- * { status: 200, body: "Hello world" }.
8824
- * It is meant to be used inside "requestToResponse"
8814
+ * Turns a request for a file into response properties: content type, client
8815
+ * cache (etag or mtime), compression, directory listing or main file. The
8816
+ * response is a plain object so that it composes with what the router and
8817
+ * the plugins add.
8825
8818
  */
8826
8819
 
8827
8820
 
8821
+ /**
8822
+ * A route `fetch` serving the files of a directory. The part of the url
8823
+ * captured by the endpoint `*` (or the whole resource when there is none) is
8824
+ * resolved inside `directoryUrl`; a url escaping it is answered with 403.
8825
+ *
8826
+ * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
8827
+ * @param {Object} [options] - See {@link fetchFileSystem}.
8828
+ * @returns {(request: Object, helpers: Object) => Promise<Object|null>}
8829
+ *
8830
+ * @example
8831
+ * {
8832
+ * endpoint: "GET /assets/*",
8833
+ * fetch: createFileSystemFetch(import.meta.resolve("./assets/"), { etagEnabled: true }),
8834
+ * }
8835
+ */
8828
8836
  const createFileSystemFetch = (directoryUrl, options) => {
8829
8837
  return (request, helpers) => {
8830
8838
  return fetchFileSystem(request, helpers, directoryUrl, options);
8831
8839
  };
8832
8840
  };
8833
8841
 
8842
+ /**
8843
+ * Serve a file of `directoryUrl` for `request`. Declines (`null`) any method
8844
+ * other than GET and HEAD. File paths appear in the status text only when the
8845
+ * server runs with `canExposeSensitiveData`.
8846
+ *
8847
+ * @param {Object} request - The request given to a route `fetch`.
8848
+ * @param {Object} [helpers] - The helpers given to a route `fetch` (used for `timing` and `canExposeSensitiveData`).
8849
+ * @param {string|URL} directoryUrl - `file://` url (or filesystem path) of the directory to serve.
8850
+ * @param {Object} [options]
8851
+ * @param {string} [options.mainFileRelativeUrl] - File served for the directory itself and,
8852
+ * as a fallback, for extension-less urls that do not exist (client side routing).
8853
+ * @param {boolean} [options.etagEnabled=false] - Send an `etag` (hash of the content) and
8854
+ * answer 304 to a matching `if-none-match`.
8855
+ * @param {boolean} [options.etagMemory=true] - Remember etags per file (invalidated when the
8856
+ * file stats change) so that a file is hashed once.
8857
+ * @param {number} [options.etagMemoryMaxSize=1000] - Files remembered at most.
8858
+ * @param {boolean} [options.mtimeEnabled=false] - Send `last-modified` and answer 304 to
8859
+ * `if-modified-since` (second precision). Ignored when `etagEnabled` is set.
8860
+ * @param {boolean} [options.compressionEnabled=false] - Compress textual files with brotli,
8861
+ * gzip or deflate according to `accept-encoding`.
8862
+ * @param {number} [options.compressionSizeThreshold=1024] - Files smaller than that (bytes) are not compressed.
8863
+ * @param {string|((request: Object) => string)} [options.cacheControl] - The `cache-control`
8864
+ * response header. Defaults to 30 days immutable for versioned urls (see `isVersioned`) and
8865
+ * `private,max-age=0,must-revalidate` otherwise. `"no-store"` disables etag and mtime.
8866
+ * @param {(request: Object) => boolean} [options.isVersioned] - Tells if a url is versioned
8867
+ * (its content never changes). Defaults to "has a `v` search param".
8868
+ * @param {boolean} [options.canReadDirectory=false] - Answer a directory with its listing
8869
+ * (json or html, see {@link fetchDirectory}) instead of 403.
8870
+ * @param {() => string|URL|null} [options.ENOENTFallback] - Called when the file does not exist;
8871
+ * the file url it returns is served instead.
8872
+ * @returns {Promise<Object|null>} Response properties, `null` when the method is not GET/HEAD.
8873
+ */
8834
8874
  const fetchFileSystem = async (
8835
8875
  request,
8836
8876
  helpers,
@@ -8863,6 +8903,9 @@ const fetchFileSystem = async (
8863
8903
  if (!directoryUrlString.endsWith("/")) {
8864
8904
  directoryUrlString = `${directoryUrlString}/`;
8865
8905
  }
8906
+ const canExposeSensitiveData = Boolean(
8907
+ helpers && helpers.canExposeSensitiveData,
8908
+ );
8866
8909
  let resource;
8867
8910
  if (request.params && "0" in request.params) {
8868
8911
  resource = request.params["0"];
@@ -8913,15 +8956,14 @@ const fetchFileSystem = async (
8913
8956
  const serveFile = async (fileUrl) => {
8914
8957
  try {
8915
8958
  const readStatTiming = helpers?.timing("file service>read file stat");
8916
- const fileStat = statSync(new URL(fileUrl));
8959
+ const fileStat = await stat(new URL(fileUrl));
8917
8960
  readStatTiming?.end();
8918
8961
 
8919
8962
  if (fileStat.isDirectory()) {
8920
8963
  if (canReadDirectory) {
8921
8964
  return fetchDirectory(fileUrl, {
8922
8965
  headers: request.headers,
8923
- canReadDirectory,
8924
- rootDirectoryUrl: directoryUrl,
8966
+ rootDirectoryUrl: directoryUrlString,
8925
8967
  });
8926
8968
  }
8927
8969
  if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
@@ -9017,7 +9059,9 @@ const fetchFileSystem = async (
9017
9059
  ...(cacheControl ? { "cache-control": cacheControl } : {}),
9018
9060
  },
9019
9061
  },
9020
- convertFileSystemErrorToResponseProperties(e) || {},
9062
+ convertFileSystemErrorToResponseProperties(e, {
9063
+ canExposeSensitiveData,
9064
+ }) || {},
9021
9065
  );
9022
9066
  }
9023
9067
  };
@@ -9119,20 +9163,13 @@ const computeEtag = async ({
9119
9163
  return etagMemoryEntry.eTag;
9120
9164
  }
9121
9165
  }
9122
- const fileContentAsBuffer = await new Promise((resolve, reject) => {
9123
- readFile(new URL(fileUrl), (error, buffer) => {
9124
- if (error) {
9125
- reject(error);
9126
- } else {
9127
- resolve(buffer);
9128
- }
9129
- });
9130
- });
9166
+ const fileContentAsBuffer = await readFile(new URL(fileUrl));
9131
9167
  const eTag = bufferToEtag(fileContentAsBuffer);
9132
9168
  if (etagMemory) {
9133
9169
  if (ETAG_MEMORY_MAP.size >= etagMemoryMaxSize) {
9134
- const firstKey = Array.from(ETAG_MEMORY_MAP.keys())[0];
9135
- ETAG_MEMORY_MAP.delete(firstKey);
9170
+ // a Map iterates in insertion order: the first key is the oldest entry
9171
+ const oldestKey = ETAG_MEMORY_MAP.keys().next().value;
9172
+ ETAG_MEMORY_MAP.delete(oldestKey);
9136
9173
  }
9137
9174
  ETAG_MEMORY_MAP.set(fileUrl, { fileStat, eTag });
9138
9175
  }
@@ -9224,10 +9261,23 @@ const fileUrlToReadableStream = (fileUrl) => {
9224
9261
  });
9225
9262
  };
9226
9263
 
9264
+ // Brotli's default quality (11) is meant for compressing assets once, ahead
9265
+ // of time: it is an order of magnitude slower than gzip. For compression at
9266
+ // request time, quality 4 costs about as much cpu as gzip while still
9267
+ // compressing better.
9268
+ const BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION = 4;
9269
+
9227
9270
  const availableCompressionFormats = {
9228
9271
  br: async (fileReadableStream) => {
9229
- const { createBrotliCompress } = await import("node:zlib");
9230
- return fileReadableStream.pipe(createBrotliCompress());
9272
+ const { constants, createBrotliCompress } = await import("node:zlib");
9273
+ return fileReadableStream.pipe(
9274
+ createBrotliCompress({
9275
+ params: {
9276
+ [constants.BROTLI_PARAM_QUALITY]:
9277
+ BROTLI_QUALITY_FOR_ON_THE_FLY_COMPRESSION,
9278
+ },
9279
+ }),
9280
+ );
9231
9281
  },
9232
9282
  deflate: async (fileReadableStream) => {
9233
9283
  const { createDeflate } = await import("node:zlib");
@@ -9286,6 +9336,11 @@ const defaultIsVersioned = (request) => {
9286
9336
  return new URL(request.url).searchParams.has("v");
9287
9337
  };
9288
9338
 
9339
+ /**
9340
+ * Server plugin logging a warning when a response does not honor what the
9341
+ * request accepts (`accept`, `accept-language`, `accept-encoding`). A
9342
+ * development aid: it changes nothing in the response.
9343
+ */
9289
9344
  const serverPluginResponseAcceptanceCheck = () => {
9290
9345
  return {
9291
9346
  name: "jsenv:response_acceptance_check",
@@ -9327,9 +9382,9 @@ ${requestAcceptLanguageHeader}`);
9327
9382
  const requestAcceptEncodingHeader = request.headers["accept-encoding"];
9328
9383
  const responseContentEncodingHeader = response.headers["content-encoding"];
9329
9384
  if (
9330
- requestAcceptLanguageHeader &&
9331
- responseContentLanguageHeader &&
9332
- !pickContentEncoding(request, [responseContentLanguageHeader])
9385
+ requestAcceptEncodingHeader &&
9386
+ responseContentEncodingHeader &&
9387
+ !pickContentEncoding(request, [responseContentEncodingHeader])
9333
9388
  ) {
9334
9389
  warn(`response encoding is not in the request accepted encoding.
9335
9390
  --- response content-encoding header ---
@@ -9339,6 +9394,14 @@ ${requestAcceptEncodingHeader}`);
9339
9394
  }
9340
9395
  };
9341
9396
 
9397
+ /**
9398
+ * Server plugin rewriting request resources before routing, from url
9399
+ * patterns to their replacement: `{ "/old/*": "/new/*", "/index": "/" }`
9400
+ * (patterns as in @jsenv/url-meta). The search params of the request are
9401
+ * kept unless the replacement has its own.
9402
+ *
9403
+ * @param {Object<string, string>} resourceAliases
9404
+ */
9342
9405
  const serverPluginRequestAliases = (resourceAliases) => {
9343
9406
  const aliases = {};
9344
9407
  Object.keys(resourceAliases).forEach((key) => {