@jsenv/server 17.6.5 → 17.6.7

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.
@@ -1150,6 +1150,62 @@ const stringifyHeaderProperty = ({ name, value }) => {
1150
1150
  return `${name}=${value}`;
1151
1151
  };
1152
1152
 
1153
+ /*
1154
+ * A request target is the text between the method and the http version on the
1155
+ * request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
1156
+ * It is a path, not a url reference, and the two must never be confused:
1157
+ * "//wp-includes/x" is an origin-form target whose first path segment is
1158
+ * empty, but `new URL("//wp-includes/x", origin)` reads it as a
1159
+ * protocol-relative reference and turns "wp-includes" into the host. So the
1160
+ * path is assigned onto the origin instead, and what a client sends can never
1161
+ * replace that origin.
1162
+ *
1163
+ * The one form that legitimately names an origin is absolute-form
1164
+ * ("GET http://host/path", sent by proxies and open-proxy scanners); it is
1165
+ * read as such so the origin it names goes through the allowedHosts check
1166
+ * like any other host, instead of reaching the routes unchecked.
1167
+ */
1168
+
1169
+ const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
1170
+
1171
+ /**
1172
+ * @param {string} target the raw request target
1173
+ * @returns {{ origin: string|null, host: string|null, resource: string }|null}
1174
+ * null when the target cannot be read; the server answers 400
1175
+ */
1176
+ const readRequestTarget = (target) => {
1177
+ if (!ABSOLUTE_FORM_REGEX.test(target)) {
1178
+ // origin-form ("/path?query"), asterisk-form ("*") or authority-form
1179
+ return { origin: null, host: null, resource: target };
1180
+ }
1181
+ if (!URL.canParse(target)) {
1182
+ return null;
1183
+ }
1184
+ const urlObject = new URL(target);
1185
+ if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
1186
+ return null;
1187
+ }
1188
+ return {
1189
+ origin: urlObject.origin,
1190
+ host: urlObject.host,
1191
+ resource: `${urlObject.pathname}${urlObject.search}`,
1192
+ };
1193
+ };
1194
+
1195
+ // the search of baseUrl is dropped: the resource carries its own
1196
+ const resourceToUrlObject = (resource, baseUrl) => {
1197
+ const urlObject = new URL(baseUrl);
1198
+ const searchSeparatorIndex = resource.indexOf("?");
1199
+ if (searchSeparatorIndex === -1) {
1200
+ urlObject.pathname = resource;
1201
+ urlObject.search = "";
1202
+ } else {
1203
+ urlObject.pathname = resource.slice(0, searchSeparatorIndex);
1204
+ urlObject.search = resource.slice(searchSeparatorIndex);
1205
+ }
1206
+ return urlObject;
1207
+ };
1208
+
1153
1209
  // https://wicg.github.io/observable/#core-infrastructure
1154
1210
 
1155
1211
  if ("observable" in Symbol === false) {
@@ -1329,8 +1385,13 @@ const fromNodeRequest = (
1329
1385
  nodeRequest.pause();
1330
1386
  const body = observableFromNodeStream(nodeRequest);
1331
1387
 
1388
+ // an absolute-form target names the origin the request is for; the host
1389
+ // header is ignored in that case (RFC 9112 section 3.3)
1390
+ const requestTarget = readRequestTarget(nodeRequest.url);
1332
1391
  let requestOrigin;
1333
- if (nodeRequest.upgrade) {
1392
+ if (requestTarget.origin) {
1393
+ requestOrigin = requestTarget.origin;
1394
+ } else if (nodeRequest.upgrade) {
1334
1395
  requestOrigin = serverOrigin;
1335
1396
  } else if (nodeRequest.authority) {
1336
1397
  requestOrigin = nodeRequest.connection.encrypted
@@ -1463,7 +1524,7 @@ const fromNodeRequest = (
1463
1524
  http2: Boolean(nodeRequest.stream),
1464
1525
  origin: requestOrigin,
1465
1526
  ...getPropertiesFromResource({
1466
- resource: nodeRequest.url,
1527
+ resource: requestTarget.resource,
1467
1528
  baseUrl: requestOrigin,
1468
1529
  }),
1469
1530
  method: nodeRequest.method,
@@ -1690,13 +1751,12 @@ const applyRedirectionToRequest = (
1690
1751
  };
1691
1752
  };
1692
1753
  const getPropertiesFromResource = ({ resource, baseUrl }) => {
1693
- const urlObject = new URL(resource, baseUrl);
1694
- let pathname = urlObject.pathname;
1754
+ const urlObject = resourceToUrlObject(resource, baseUrl);
1695
1755
 
1696
1756
  return {
1697
1757
  url: String(urlObject),
1698
1758
  searchParams: urlObject.searchParams,
1699
- pathname,
1759
+ pathname: urlObject.pathname,
1700
1760
  resource,
1701
1761
  };
1702
1762
  };
@@ -5833,8 +5893,17 @@ const createResourcePattern = (pattern) => {
5833
5893
  }
5834
5894
 
5835
5895
  const patternEndsWithSlash = pathnamePatternString.endsWith("/");
5896
+ // a pattern constraining nothing: every resource matches it, so whoever uses
5897
+ // it cannot name the resource that matched
5898
+ const matchesEveryResource =
5899
+ !searchPattern &&
5900
+ !hashPattern &&
5901
+ (pathnamePatternString === "*" ||
5902
+ pathnamePatternString === "/" ||
5903
+ pathnamePatternString === "/*");
5836
5904
 
5837
5905
  return {
5906
+ matchesEveryResource,
5838
5907
  match: (resource) => {
5839
5908
  const [pathname, search, hash] = resourceToParts(resource);
5840
5909
  let decodedPathname = decodeURIComponent(pathname);
@@ -6477,9 +6546,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6477
6546
  }
6478
6547
  if (!route.matchMethod(request.method)) {
6479
6548
  // a 405 asserts the resource exists with other methods: a route
6480
- // matching any resource ("GET *") cannot assert that, so it does
6481
- // not turn an unknown resource into a 405
6482
- if (!route.isFallback && route.resource !== "*") {
6549
+ // matching every resource ("GET *", "GET /") names no resource, so
6550
+ // it cannot make that assertion and an unknown address stays a 404
6551
+ if (!route.isFallback && !route.matchesEveryResource) {
6483
6552
  wouldHaveMatched.methodSet.add(route.method);
6484
6553
  }
6485
6554
  continue;
@@ -6827,6 +6896,8 @@ const createRoute = ({
6827
6896
  : (requestResource) => {
6828
6897
  return resourcePattern.match(requestResource);
6829
6898
  },
6899
+ matchesEveryResource:
6900
+ resource === "*" || resourcePattern.matchesEveryResource,
6830
6901
  matchHeaders:
6831
6902
  headers === undefined
6832
6903
  ? () => true
@@ -7309,11 +7380,14 @@ const TIMING_NOOP = () => {
7309
7380
  * - `GET /@jsenv/server/*`, serving this package's own client files;
7310
7381
  * - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
7311
7382
  * subscribes to in order to reload when the server restarts.
7312
- * @param {boolean|Object} [params.serverTiming=false] - `true` or `{ minDuration }` to send
7313
- * `server-timing` response headers: the time to start responding, the routing of each
7314
- * plugin, what routes measure with `helpers.timing` and the `timing` a response hands
7315
- * back. `minDuration` (ms) drops the entries that took less: 0 keeps everything (what a
7316
- * test wants), a human reading devtools usually wants the sub-millisecond noise gone.
7383
+ * @param {boolean|Function|Object} [params.serverTiming=false] - `true`, `(request) =>
7384
+ * boolean` or `{ enabled, minDuration }` to send `server-timing` response headers: the
7385
+ * time to start responding, the routing of each plugin, what routes measure with
7386
+ * `helpers.timing` and the `timing` a response hands back. `enabled` can be a function
7387
+ * too: it is asked once per request, so a server can measure for a client it recognizes
7388
+ * (a header, a cookie, an origin) and stay silent for everyone else. `minDuration` (ms)
7389
+ * drops the entries that took less: 0 keeps everything (what a test wants), a human
7390
+ * reading devtools usually wants the sub-millisecond noise gone.
7317
7391
  * @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
7318
7392
  * still has no response after that many ms (0 disables).
7319
7393
  * @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
@@ -7670,13 +7744,16 @@ const startServer = async ({
7670
7744
  sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
7671
7745
  return [receiveRequestOperation, sendResponseOperation];
7672
7746
  };
7673
- const serverTimingMinDuration =
7674
- serverTiming && typeof serverTiming === "object"
7675
- ? serverTiming.minDuration || 0
7676
- : 0;
7747
+ const { serverTimingEnabled, serverTimingMinDuration } =
7748
+ resolveServerTiming(serverTiming);
7677
7749
  const getResponseProperties = async (request) => {
7750
+ // Asked once per request, on what the client sent: an api can then measure
7751
+ // for everyone and disclose to a token, a cookie, an origin — instead of
7752
+ // choosing between measuring nothing and telling every client what it does
7753
+ // internally.
7754
+ const timingEnabled = serverTimingEnabled(request);
7678
7755
  const timings = {};
7679
- const timing = serverTiming
7756
+ const timing = timingEnabled
7680
7757
  ? (name) => {
7681
7758
  const start = performance.now();
7682
7759
  timings[name] = null;
@@ -7711,7 +7788,7 @@ const startServer = async ({
7711
7788
 
7712
7789
  let headersToInject;
7713
7790
  const finalizeResponseProperties = (responseProperties) => {
7714
- if (serverTiming) {
7791
+ if (timingEnabled) {
7715
7792
  startRespondingTiming.end();
7716
7793
  // A response can hand back measures of its own (a `timing` property —
7717
7794
  // durations keyed by description, null for a plain marker): they join
@@ -7966,7 +8043,14 @@ const startServer = async ({
7966
8043
 
7967
8044
  {
7968
8045
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
7969
- const requestHost = nodeRequest.authority || nodeRequest.headers.host;
8046
+ const requestTarget = readRequestTarget(nodeRequest.url);
8047
+ if (!requestTarget) {
8048
+ nodeResponse.writeHead(400, "Request url is not supported");
8049
+ nodeResponse.end();
8050
+ return;
8051
+ }
8052
+ const requestHost =
8053
+ requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
7970
8054
  if (!isHostAllowed(requestHost)) {
7971
8055
  logger.warn(
7972
8056
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -7977,19 +8061,11 @@ const startServer = async ({
7977
8061
  }
7978
8062
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
7979
8063
  nodeResponse.writeHead(301, {
7980
- location: `${serverOrigin}${nodeRequest.url}`,
8064
+ location: `${serverOrigin}${requestTarget.resource}`,
7981
8065
  });
7982
8066
  nodeResponse.end();
7983
8067
  return;
7984
8068
  }
7985
- try {
7986
- // eslint-disable-next-line no-new
7987
- new URL(nodeRequest.url, "http://example.com");
7988
- } catch {
7989
- nodeResponse.writeHead(400, "Request url is not supported");
7990
- nodeResponse.end();
7991
- return;
7992
- }
7993
8069
 
7994
8070
  const [receiveRequestOperation, sendResponseOperation] =
7995
8071
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
@@ -8066,7 +8142,13 @@ const startServer = async ({
8066
8142
  };
8067
8143
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
8068
8144
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
8069
- const requestHost = nodeRequest.headers.host;
8145
+ const requestTarget = readRequestTarget(nodeRequest.url);
8146
+ if (!requestTarget) {
8147
+ socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
8148
+ socket.destroy();
8149
+ return;
8150
+ }
8151
+ const requestHost = requestTarget.host || nodeRequest.headers.host;
8070
8152
  if (!isHostAllowed(requestHost)) {
8071
8153
  logger.warn(
8072
8154
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -8233,6 +8315,36 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
8233
8315
  exit: STOP_REASON_PROCESS_EXIT,
8234
8316
  };
8235
8317
 
8318
+ const SERVER_TIMING_DISABLED = () => false;
8319
+ const SERVER_TIMING_ENABLED = () => true;
8320
+
8321
+ const resolveServerTiming = (serverTiming) => {
8322
+ if (typeof serverTiming === "function") {
8323
+ return {
8324
+ serverTimingEnabled: serverTiming,
8325
+ serverTimingMinDuration: 0,
8326
+ };
8327
+ }
8328
+ if (serverTiming && typeof serverTiming === "object") {
8329
+ const { enabled = true, minDuration = 0 } = serverTiming;
8330
+ return {
8331
+ serverTimingEnabled:
8332
+ typeof enabled === "function"
8333
+ ? enabled
8334
+ : enabled
8335
+ ? SERVER_TIMING_ENABLED
8336
+ : SERVER_TIMING_DISABLED,
8337
+ serverTimingMinDuration: minDuration,
8338
+ };
8339
+ }
8340
+ return {
8341
+ serverTimingEnabled: serverTiming
8342
+ ? SERVER_TIMING_ENABLED
8343
+ : SERVER_TIMING_DISABLED,
8344
+ serverTimingMinDuration: 0,
8345
+ };
8346
+ };
8347
+
8236
8348
  const internalErrorHtmlFileUrl = import.meta.resolve("./client/error_handler/500.html");
8237
8349
  let internalErrorHtmlTemplate;
8238
8350
  const readInternalErrorHtmlTemplate = () => {
@@ -8421,6 +8533,9 @@ class ProgressiveResponse {
8421
8533
  * so here we just need to add the CORS headers to the response
8422
8534
  */
8423
8535
 
8536
+ const TIMING_ALLOW_ORIGIN_DISABLED = () => false;
8537
+ const TIMING_ALLOW_ORIGIN_ENABLED = () => true;
8538
+
8424
8539
  const jsenvAccessControlAllowedHeaders = ["x-requested-with"];
8425
8540
 
8426
8541
  const jsenvAccessControlAllowedMethods = [
@@ -8453,8 +8568,10 @@ const jsenvAccessControlAllowedMethods = [
8453
8568
  * @param {boolean} [params.accessControlAllowCredentials=false] - Send
8454
8569
  * `access-control-allow-credentials: true`.
8455
8570
  * @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
8456
- * @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin` so the
8457
- * allowed origin can read resource timing.
8571
+ * @param {boolean|Function} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
8572
+ * so the allowed origin can read resource timing (the `server-timing` header included).
8573
+ * A `(request) => boolean` is asked once per request, so it can be the very test that
8574
+ * decides `startServer({ serverTiming })`.
8458
8575
  * @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
8459
8576
  */
8460
8577
  const serverPluginCORS = ({
@@ -8482,6 +8599,12 @@ const serverPluginCORS = ({
8482
8599
  const allowedOriginChecker = createAllowedOriginChecker(
8483
8600
  accessControlAllowedOrigins,
8484
8601
  );
8602
+ const timingAllowOriginEnabled =
8603
+ typeof timingAllowOrigin === "function"
8604
+ ? timingAllowOrigin
8605
+ : timingAllowOrigin
8606
+ ? TIMING_ALLOW_ORIGIN_ENABLED
8607
+ : TIMING_ALLOW_ORIGIN_DISABLED;
8485
8608
 
8486
8609
  return {
8487
8610
  name: "jsenv:cors",
@@ -8496,7 +8619,7 @@ const serverPluginCORS = ({
8496
8619
  accessControlAllowRequestHeaders,
8497
8620
  accessControlAllowCredentials,
8498
8621
  accessControlMaxAge,
8499
- timingAllowOrigin,
8622
+ timingAllowOriginEnabled,
8500
8623
  });
8501
8624
  return {
8502
8625
  headers: accessControlHeaders,
@@ -8550,7 +8673,7 @@ const originPatternToRegExp = (originPattern) => {
8550
8673
  // https://www.w3.org/TR/cors/
8551
8674
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
8552
8675
  const generateAccessControlHeaders = ({
8553
- request: { headers },
8676
+ request,
8554
8677
  allowedOriginChecker,
8555
8678
  accessControlAllowRequestOrigin,
8556
8679
  accessControlAllowedMethods,
@@ -8561,8 +8684,9 @@ const generateAccessControlHeaders = ({
8561
8684
  // by default OPTIONS request can be cache for a long time, it's not going to change soon ?
8562
8685
  // we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
8563
8686
  accessControlMaxAge = 600,
8564
- timingAllowOrigin,
8687
+ timingAllowOriginEnabled,
8565
8688
  } = {}) => {
8689
+ const { headers } = request;
8566
8690
  const vary = [];
8567
8691
 
8568
8692
  // Access-Control-Allow-Origin must be a single value (not a list).
@@ -8626,7 +8750,9 @@ const generateAccessControlHeaders = ({
8626
8750
  ? { "access-control-allow-credentials": true }
8627
8751
  : {}),
8628
8752
  "access-control-max-age": accessControlMaxAge,
8629
- ...(timingAllowOrigin ? { "timing-allow-origin": allowOrigin } : {}),
8753
+ ...(timingAllowOriginEnabled(request)
8754
+ ? { "timing-allow-origin": allowOrigin }
8755
+ : {}),
8630
8756
  ...(vary.length ? { vary: vary.join(", ") } : {}),
8631
8757
  };
8632
8758
  };
@@ -8937,8 +9063,11 @@ const fetchFileSystem = async (
8937
9063
  if (request.params && "0" in request.params) {
8938
9064
  resource = request.params["0"];
8939
9065
  } else {
8940
- resource = request.resource.slice(1);
9066
+ resource = request.resource;
8941
9067
  }
9068
+ // the resource is resolved against the served directory, so it must stay a
9069
+ // relative reference: a leading slash would resolve to the filesystem root
9070
+ resource = resource.replace(/^\/+/, "");
8942
9071
  const filesystemUrl = new URL(resource, directoryUrlString);
8943
9072
  const urlString = asUrlString(filesystemUrl);
8944
9073
  if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.6.5",
3
+ "version": "17.6.7",
4
4
  "type": "module",
5
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
6
6
  "repository": {
@@ -20,6 +20,10 @@ import {
20
20
  } from "../internal/colorize_response_status.js";
21
21
  import { headersFromObject } from "../internal/headers_from_object.js";
22
22
  import { parseSingleHeaderWithAttributes } from "../internal/multiple_header.js";
23
+ import {
24
+ readRequestTarget,
25
+ resourceToUrlObject,
26
+ } from "../internal/request_target.js";
23
27
  import { observableFromNodeStream } from "./observable_from_node_stream.js";
24
28
 
25
29
  export const fromNodeRequest = (
@@ -66,8 +70,13 @@ export const fromNodeRequest = (
66
70
  nodeRequest.pause();
67
71
  const body = observableFromNodeStream(nodeRequest);
68
72
 
73
+ // an absolute-form target names the origin the request is for; the host
74
+ // header is ignored in that case (RFC 9112 section 3.3)
75
+ const requestTarget = readRequestTarget(nodeRequest.url);
69
76
  let requestOrigin;
70
- if (nodeRequest.upgrade) {
77
+ if (requestTarget.origin) {
78
+ requestOrigin = requestTarget.origin;
79
+ } else if (nodeRequest.upgrade) {
71
80
  requestOrigin = serverOrigin;
72
81
  } else if (nodeRequest.authority) {
73
82
  requestOrigin = nodeRequest.connection.encrypted
@@ -200,7 +209,7 @@ export const fromNodeRequest = (
200
209
  http2: Boolean(nodeRequest.stream),
201
210
  origin: requestOrigin,
202
211
  ...getPropertiesFromResource({
203
- resource: nodeRequest.url,
212
+ resource: requestTarget.resource,
204
213
  baseUrl: requestOrigin,
205
214
  }),
206
215
  method: nodeRequest.method,
@@ -440,13 +449,12 @@ export const applyRedirectionToRequest = (
440
449
  };
441
450
  };
442
451
  const getPropertiesFromResource = ({ resource, baseUrl }) => {
443
- const urlObject = new URL(resource, baseUrl);
444
- let pathname = urlObject.pathname;
452
+ const urlObject = resourceToUrlObject(resource, baseUrl);
445
453
 
446
454
  return {
447
455
  url: String(urlObject),
448
456
  searchParams: urlObject.searchParams,
449
- pathname,
457
+ pathname: urlObject.pathname,
450
458
  resource,
451
459
  };
452
460
  };
@@ -0,0 +1,55 @@
1
+ /*
2
+ * A request target is the text between the method and the http version on the
3
+ * request line (RFC 9112 section 3.2), or the ":path" pseudo header in http2.
4
+ * It is a path, not a url reference, and the two must never be confused:
5
+ * "//wp-includes/x" is an origin-form target whose first path segment is
6
+ * empty, but `new URL("//wp-includes/x", origin)` reads it as a
7
+ * protocol-relative reference and turns "wp-includes" into the host. So the
8
+ * path is assigned onto the origin instead, and what a client sends can never
9
+ * replace that origin.
10
+ *
11
+ * The one form that legitimately names an origin is absolute-form
12
+ * ("GET http://host/path", sent by proxies and open-proxy scanners); it is
13
+ * read as such so the origin it names goes through the allowedHosts check
14
+ * like any other host, instead of reaching the routes unchecked.
15
+ */
16
+
17
+ const ABSOLUTE_FORM_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i;
18
+
19
+ /**
20
+ * @param {string} target the raw request target
21
+ * @returns {{ origin: string|null, host: string|null, resource: string }|null}
22
+ * null when the target cannot be read; the server answers 400
23
+ */
24
+ export const readRequestTarget = (target) => {
25
+ if (!ABSOLUTE_FORM_REGEX.test(target)) {
26
+ // origin-form ("/path?query"), asterisk-form ("*") or authority-form
27
+ return { origin: null, host: null, resource: target };
28
+ }
29
+ if (!URL.canParse(target)) {
30
+ return null;
31
+ }
32
+ const urlObject = new URL(target);
33
+ if (urlObject.protocol !== "http:" && urlObject.protocol !== "https:") {
34
+ return null;
35
+ }
36
+ return {
37
+ origin: urlObject.origin,
38
+ host: urlObject.host,
39
+ resource: `${urlObject.pathname}${urlObject.search}`,
40
+ };
41
+ };
42
+
43
+ // the search of baseUrl is dropped: the resource carries its own
44
+ export const resourceToUrlObject = (resource, baseUrl) => {
45
+ const urlObject = new URL(baseUrl);
46
+ const searchSeparatorIndex = resource.indexOf("?");
47
+ if (searchSeparatorIndex === -1) {
48
+ urlObject.pathname = resource;
49
+ urlObject.search = "";
50
+ } else {
51
+ urlObject.pathname = resource.slice(0, searchSeparatorIndex);
52
+ urlObject.search = resource.slice(searchSeparatorIndex);
53
+ }
54
+ return urlObject;
55
+ };
@@ -3,6 +3,9 @@
3
3
  * so here we just need to add the CORS headers to the response
4
4
  */
5
5
 
6
+ const TIMING_ALLOW_ORIGIN_DISABLED = () => false;
7
+ const TIMING_ALLOW_ORIGIN_ENABLED = () => true;
8
+
6
9
  export const jsenvAccessControlAllowedHeaders = ["x-requested-with"];
7
10
 
8
11
  export const jsenvAccessControlAllowedMethods = [
@@ -35,8 +38,10 @@ export const jsenvAccessControlAllowedMethods = [
35
38
  * @param {boolean} [params.accessControlAllowCredentials=false] - Send
36
39
  * `access-control-allow-credentials: true`.
37
40
  * @param {number} [params.accessControlMaxAge=600] - Seconds a browser may cache the preflight.
38
- * @param {boolean} [params.timingAllowOrigin=false] - Send `timing-allow-origin` so the
39
- * allowed origin can read resource timing.
41
+ * @param {boolean|Function} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
42
+ * so the allowed origin can read resource timing (the `server-timing` header included).
43
+ * A `(request) => boolean` is asked once per request, so it can be the very test that
44
+ * decides `startServer({ serverTiming })`.
40
45
  * @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
41
46
  */
42
47
  export const serverPluginCORS = ({
@@ -64,6 +69,12 @@ export const serverPluginCORS = ({
64
69
  const allowedOriginChecker = createAllowedOriginChecker(
65
70
  accessControlAllowedOrigins,
66
71
  );
72
+ const timingAllowOriginEnabled =
73
+ typeof timingAllowOrigin === "function"
74
+ ? timingAllowOrigin
75
+ : timingAllowOrigin
76
+ ? TIMING_ALLOW_ORIGIN_ENABLED
77
+ : TIMING_ALLOW_ORIGIN_DISABLED;
67
78
 
68
79
  return {
69
80
  name: "jsenv:cors",
@@ -78,7 +89,7 @@ export const serverPluginCORS = ({
78
89
  accessControlAllowRequestHeaders,
79
90
  accessControlAllowCredentials,
80
91
  accessControlMaxAge,
81
- timingAllowOrigin,
92
+ timingAllowOriginEnabled,
82
93
  });
83
94
  return {
84
95
  headers: accessControlHeaders,
@@ -132,7 +143,7 @@ const originPatternToRegExp = (originPattern) => {
132
143
  // https://www.w3.org/TR/cors/
133
144
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
134
145
  const generateAccessControlHeaders = ({
135
- request: { headers },
146
+ request,
136
147
  allowedOriginChecker,
137
148
  accessControlAllowRequestOrigin,
138
149
  accessControlAllowedMethods,
@@ -143,8 +154,9 @@ const generateAccessControlHeaders = ({
143
154
  // by default OPTIONS request can be cache for a long time, it's not going to change soon ?
144
155
  // we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
145
156
  accessControlMaxAge = 600,
146
- timingAllowOrigin,
157
+ timingAllowOriginEnabled,
147
158
  } = {}) => {
159
+ const { headers } = request;
148
160
  const vary = [];
149
161
 
150
162
  // Access-Control-Allow-Origin must be a single value (not a list).
@@ -208,7 +220,9 @@ const generateAccessControlHeaders = ({
208
220
  ? { "access-control-allow-credentials": true }
209
221
  : {}),
210
222
  "access-control-max-age": accessControlMaxAge,
211
- ...(timingAllowOrigin ? { "timing-allow-origin": allowOrigin } : {}),
223
+ ...(timingAllowOriginEnabled(request)
224
+ ? { "timing-allow-origin": allowOrigin }
225
+ : {}),
212
226
  ...(vary.length ? { vary: vary.join(", ") } : {}),
213
227
  };
214
228
  };
@@ -112,8 +112,11 @@ export const fetchFileSystem = async (
112
112
  if (request.params && "0" in request.params) {
113
113
  resource = request.params["0"];
114
114
  } else {
115
- resource = request.resource.slice(1);
115
+ resource = request.resource;
116
116
  }
117
+ // the resource is resolved against the served directory, so it must stay a
118
+ // relative reference: a leading slash would resolve to the filesystem root
119
+ resource = resource.replace(/^\/+/, "");
117
120
  const filesystemUrl = new URL(resource, directoryUrlString);
118
121
  const urlString = asUrlString(filesystemUrl);
119
122
  if (!urlIsOrIsInsideOf(urlString, directoryUrlString)) {
@@ -356,9 +356,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
356
356
  }
357
357
  if (!route.matchMethod(request.method)) {
358
358
  // a 405 asserts the resource exists with other methods: a route
359
- // matching any resource ("GET *") cannot assert that, so it does
360
- // not turn an unknown resource into a 405
361
- if (!route.isFallback && route.resource !== "*") {
359
+ // matching every resource ("GET *", "GET /") names no resource, so
360
+ // it cannot make that assertion and an unknown address stays a 404
361
+ if (!route.isFallback && !route.matchesEveryResource) {
362
362
  wouldHaveMatched.methodSet.add(route.method);
363
363
  }
364
364
  continue;
@@ -709,6 +709,8 @@ const createRoute = ({
709
709
  : (requestResource) => {
710
710
  return resourcePattern.match(requestResource);
711
711
  },
712
+ matchesEveryResource:
713
+ resource === "*" || resourcePattern.matchesEveryResource,
712
714
  matchHeaders:
713
715
  headers === undefined
714
716
  ? () => true
@@ -18,6 +18,7 @@ import { listen, stopListening } from "./internal/listen.js";
18
18
  import { listenEvent } from "./internal/listen_event.js";
19
19
  import { listenRequest } from "./internal/listen_request.js";
20
20
  import { listenServerConnectionError } from "./internal/listen_server_connection_error.js";
21
+ import { readRequestTarget } from "./internal/request_target.js";
21
22
  import { composeTwoResponses } from "./internal/response_composition.js";
22
23
  import { createSecureServer } from "./internal/secure_server.js";
23
24
  import { resolveServerOrigins } from "./internal/server_origins.js";
@@ -129,11 +130,14 @@ const TIMING_NOOP = () => {
129
130
  * - `GET /@jsenv/server/*`, serving this package's own client files;
130
131
  * - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
131
132
  * subscribes to in order to reload when the server restarts.
132
- * @param {boolean|Object} [params.serverTiming=false] - `true` or `{ minDuration }` to send
133
- * `server-timing` response headers: the time to start responding, the routing of each
134
- * plugin, what routes measure with `helpers.timing` and the `timing` a response hands
135
- * back. `minDuration` (ms) drops the entries that took less: 0 keeps everything (what a
136
- * test wants), a human reading devtools usually wants the sub-millisecond noise gone.
133
+ * @param {boolean|Function|Object} [params.serverTiming=false] - `true`, `(request) =>
134
+ * boolean` or `{ enabled, minDuration }` to send `server-timing` response headers: the
135
+ * time to start responding, the routing of each plugin, what routes measure with
136
+ * `helpers.timing` and the `timing` a response hands back. `enabled` can be a function
137
+ * too: it is asked once per request, so a server can measure for a client it recognizes
138
+ * (a header, a cookie, an origin) and stay silent for everyone else. `minDuration` (ms)
139
+ * drops the entries that took less: 0 keeps everything (what a test wants), a human
140
+ * reading devtools usually wants the sub-millisecond noise gone.
137
141
  * @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
138
142
  * still has no response after that many ms (0 disables).
139
143
  * @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
@@ -490,13 +494,16 @@ export const startServer = async ({
490
494
  sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
491
495
  return [receiveRequestOperation, sendResponseOperation];
492
496
  };
493
- const serverTimingMinDuration =
494
- serverTiming && typeof serverTiming === "object"
495
- ? serverTiming.minDuration || 0
496
- : 0;
497
+ const { serverTimingEnabled, serverTimingMinDuration } =
498
+ resolveServerTiming(serverTiming);
497
499
  const getResponseProperties = async (request) => {
500
+ // Asked once per request, on what the client sent: an api can then measure
501
+ // for everyone and disclose to a token, a cookie, an origin — instead of
502
+ // choosing between measuring nothing and telling every client what it does
503
+ // internally.
504
+ const timingEnabled = serverTimingEnabled(request);
498
505
  const timings = {};
499
- const timing = serverTiming
506
+ const timing = timingEnabled
500
507
  ? (name) => {
501
508
  const start = performance.now();
502
509
  timings[name] = null;
@@ -531,7 +538,7 @@ export const startServer = async ({
531
538
 
532
539
  let headersToInject;
533
540
  const finalizeResponseProperties = (responseProperties) => {
534
- if (serverTiming) {
541
+ if (timingEnabled) {
535
542
  startRespondingTiming.end();
536
543
  // A response can hand back measures of its own (a `timing` property —
537
544
  // durations keyed by description, null for a plain marker): they join
@@ -786,7 +793,14 @@ export const startServer = async ({
786
793
 
787
794
  request: {
788
795
  const requestEventHandler = async (nodeRequest, nodeResponse) => {
789
- const requestHost = nodeRequest.authority || nodeRequest.headers.host;
796
+ const requestTarget = readRequestTarget(nodeRequest.url);
797
+ if (!requestTarget) {
798
+ nodeResponse.writeHead(400, "Request url is not supported");
799
+ nodeResponse.end();
800
+ return;
801
+ }
802
+ const requestHost =
803
+ requestTarget.host || nodeRequest.authority || nodeRequest.headers.host;
790
804
  if (!isHostAllowed(requestHost)) {
791
805
  logger.warn(
792
806
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -797,19 +811,11 @@ export const startServer = async ({
797
811
  }
798
812
  if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
799
813
  nodeResponse.writeHead(301, {
800
- location: `${serverOrigin}${nodeRequest.url}`,
814
+ location: `${serverOrigin}${requestTarget.resource}`,
801
815
  });
802
816
  nodeResponse.end();
803
817
  return;
804
818
  }
805
- try {
806
- // eslint-disable-next-line no-new
807
- new URL(nodeRequest.url, "http://example.com");
808
- } catch {
809
- nodeResponse.writeHead(400, "Request url is not supported");
810
- nodeResponse.end();
811
- return;
812
- }
813
819
 
814
820
  const [receiveRequestOperation, sendResponseOperation] =
815
821
  prepareHandleRequestOperations(nodeRequest, nodeResponse);
@@ -891,7 +897,13 @@ export const startServer = async ({
891
897
  };
892
898
  // https://github.com/websockets/ws/blob/b92745a9d6760e6b4b2394bfac78cbcd258a8c8d/lib/websocket-server.js#L491
893
899
  const upgradeEventHandler = async (nodeRequest, socket, head) => {
894
- const requestHost = nodeRequest.headers.host;
900
+ const requestTarget = readRequestTarget(nodeRequest.url);
901
+ if (!requestTarget) {
902
+ socket.write(`HTTP/1.1 400 Request url is not supported\r\n\r\n`);
903
+ socket.destroy();
904
+ return;
905
+ }
906
+ const requestHost = requestTarget.host || nodeRequest.headers.host;
895
907
  if (!isHostAllowed(requestHost)) {
896
908
  logger.warn(
897
909
  `${nodeRequest.method} ${nodeRequest.url} refused: host "${requestHost}" is not allowed (see the allowedHosts option)`,
@@ -1057,3 +1069,33 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
1057
1069
  beforeExit: STOP_REASON_PROCESS_BEFORE_EXIT,
1058
1070
  exit: STOP_REASON_PROCESS_EXIT,
1059
1071
  };
1072
+
1073
+ const SERVER_TIMING_DISABLED = () => false;
1074
+ const SERVER_TIMING_ENABLED = () => true;
1075
+
1076
+ const resolveServerTiming = (serverTiming) => {
1077
+ if (typeof serverTiming === "function") {
1078
+ return {
1079
+ serverTimingEnabled: serverTiming,
1080
+ serverTimingMinDuration: 0,
1081
+ };
1082
+ }
1083
+ if (serverTiming && typeof serverTiming === "object") {
1084
+ const { enabled = true, minDuration = 0 } = serverTiming;
1085
+ return {
1086
+ serverTimingEnabled:
1087
+ typeof enabled === "function"
1088
+ ? enabled
1089
+ : enabled
1090
+ ? SERVER_TIMING_ENABLED
1091
+ : SERVER_TIMING_DISABLED,
1092
+ serverTimingMinDuration: minDuration,
1093
+ };
1094
+ }
1095
+ return {
1096
+ serverTimingEnabled: serverTiming
1097
+ ? SERVER_TIMING_ENABLED
1098
+ : SERVER_TIMING_DISABLED,
1099
+ serverTimingMinDuration: 0,
1100
+ };
1101
+ };