@jsenv/server 17.4.1 → 17.6.0

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.
@@ -2564,6 +2564,9 @@ const RESPONSE_KEYS_COMPOSITION = {
2564
2564
  headers: composeTwoHeaders,
2565
2565
  body: (prevBody, body) => body,
2566
2566
  bodyEncoding: (prevEncoding, encoding) => encoding,
2567
+ // measures a response hands back, merged into the server-timing header when
2568
+ // the server has serverTiming enabled (see finalizeResponseProperties)
2569
+ timing: (prevTiming, timing) => ({ ...prevTiming, ...timing }),
2567
2570
  };
2568
2571
 
2569
2572
  /**
@@ -3373,8 +3376,9 @@ const createEventHistory = (limit) => {
3373
3376
 
3374
3377
  const add = (data) => {
3375
3378
  events.push(data);
3376
-
3377
- if (events.length >= limit) {
3379
+ // Strictly above, not "as many as": shifting at `>= limit` keeps one less
3380
+ // than asked for.
3381
+ if (events.length > limit) {
3378
3382
  events.shift();
3379
3383
  }
3380
3384
  };
@@ -6147,6 +6151,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6147
6151
  let statusMessage;
6148
6152
  let headers;
6149
6153
  let body;
6154
+ // measures the route hands back, merged into the server-timing header
6155
+ // when serverTiming is enabled (see finalizeResponseProperties)
6156
+ let timing;
6150
6157
 
6151
6158
  if (fetchReturnValue instanceof Response) {
6152
6159
  status = fetchReturnValue.status;
@@ -6162,6 +6169,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6162
6169
  statusMessage = fetchReturnValue.statusMessage;
6163
6170
  headers = fetchReturnValue.headers || {};
6164
6171
  body = fetchReturnValue.body;
6172
+ timing = fetchReturnValue.timing;
6165
6173
  } else {
6166
6174
  throw new TypeError(
6167
6175
  `response must be a Response, or an Object, received ${fetchReturnValue}`,
@@ -6194,7 +6202,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
6194
6202
 
6195
6203
  onRouteMatch(route);
6196
6204
  onResponseHeaders(request, route, headers);
6197
- return { status, statusText, statusMessage, headers, body };
6205
+ return { status, statusText, statusMessage, headers, body, timing };
6198
6206
  };
6199
6207
 
6200
6208
  const matchRoutes = async (request) => {
@@ -6944,11 +6952,19 @@ The list of existing endpoints is available at ${routeInspectorUrl}.`,
6944
6952
  const timingToServerTimingResponseHeaders = (timing) => {
6945
6953
  const serverTimingHeader = {};
6946
6954
  Object.keys(timing).forEach((key, index) => {
6947
- const name = letters[index] || "zz";
6948
- serverTimingHeader[name] = {
6949
- desc: key,
6950
- dur: timing[key],
6951
- };
6955
+ // "a".."z" then "aa", "ab", …: names must be unique — they are the keys of
6956
+ // the header object, so a shared fallback would overwrite every entry past
6957
+ // it with the last one.
6958
+ const name =
6959
+ index < letters.length
6960
+ ? letters[index]
6961
+ : letters[Math.floor(index / letters.length) - 1] +
6962
+ letters[index % letters.length];
6963
+ const dur = timing[key];
6964
+ // a non-number duration is a marker ("served from memory cache"): it has
6965
+ // something to say (desc) but nothing to measure
6966
+ serverTimingHeader[name] =
6967
+ typeof dur === "number" ? { desc: key, dur } : { desc: key };
6952
6968
  });
6953
6969
  const serverTimingHeaderString =
6954
6970
  stringifyServerTimingHeader(serverTimingHeader);
@@ -7235,6 +7251,10 @@ const startServer = async ({
7235
7251
  // This param should be enabled ONLY during development on your machine
7236
7252
  canExposeSensitiveData = false,
7237
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).
7238
7258
  serverTiming = false,
7239
7259
  requestWaitingMs = 0,
7240
7260
  requestWaitingCallback = ({ request, requestWaitingMs }) => {
@@ -7588,6 +7608,10 @@ const startServer = async ({
7588
7608
  sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
7589
7609
  return [receiveRequestOperation, sendResponseOperation];
7590
7610
  };
7611
+ const serverTimingMinDuration =
7612
+ serverTiming && typeof serverTiming === "object"
7613
+ ? serverTiming.minDuration || 0
7614
+ : 0;
7591
7615
  const getResponseProperties = async (request, { pushResponse }) => {
7592
7616
  const timings = {};
7593
7617
  const timing = serverTiming
@@ -7629,9 +7653,32 @@ const startServer = async ({
7629
7653
  const finalizeResponseProperties = (responseProperties) => {
7630
7654
  if (serverTiming) {
7631
7655
  startRespondingTiming.end();
7656
+ // A response can hand back measures of its own (a `timing` property —
7657
+ // durations keyed by description, null for a plain marker): they join
7658
+ // the server's own in the same server-timing header.
7659
+ if (responseProperties.timing) {
7660
+ Object.assign(timings, responseProperties.timing);
7661
+ delete responseProperties.timing;
7662
+ }
7663
+ // serverTiming.minDuration drops what took less: a request crosses
7664
+ // every registered route, and a wall of sub-millisecond ".routing"
7665
+ // entries buries the measures worth reading. Markers (null) and the
7666
+ // overall "time to start responding" — the summary the rest details —
7667
+ // always stay.
7668
+ const timingsWorthReading = {};
7669
+ for (const name of Object.keys(timings)) {
7670
+ const duration = timings[name];
7671
+ if (
7672
+ name === startRespondingTiming.name ||
7673
+ typeof duration !== "number" ||
7674
+ duration >= serverTimingMinDuration
7675
+ ) {
7676
+ timingsWorthReading[name] = duration;
7677
+ }
7678
+ }
7632
7679
  responseProperties.headers = composeTwoHeaders(
7633
7680
  responseProperties.headers,
7634
- timingToServerTimingResponseHeaders(timings),
7681
+ timingToServerTimingResponseHeaders(timingsWorthReading),
7635
7682
  );
7636
7683
  }
7637
7684
  if (requestWaitingMs) {
@@ -8438,12 +8485,16 @@ const serverPluginCORS = ({
8438
8485
  return [];
8439
8486
  }
8440
8487
 
8488
+ const allowedOriginChecker = createAllowedOriginChecker(
8489
+ accessControlAllowedOrigins,
8490
+ );
8491
+
8441
8492
  return {
8442
8493
  name: "jsenv:cors",
8443
8494
  injectResponseProperties: (request) => {
8444
8495
  const accessControlHeaders = generateAccessControlHeaders({
8445
8496
  request,
8446
- accessControlAllowedOrigins,
8497
+ allowedOriginChecker,
8447
8498
  accessControlAllowRequestOrigin,
8448
8499
  accessControlAllowedMethods,
8449
8500
  accessControlAllowRequestMethod,
@@ -8460,11 +8511,53 @@ const serverPluginCORS = ({
8460
8511
  };
8461
8512
  };
8462
8513
 
8514
+ /**
8515
+ * An origin ("scheme://host:port") can never contain "*", so it is free to be
8516
+ * used as a wildcard standing for any run of characters except "/":
8517
+ * "https://pr-*-my-app.fly.dev" matches "https://pr-12-my-app.fly.dev".
8518
+ */
8519
+ const createAllowedOriginChecker = (allowedOrigins) => {
8520
+ const literalOrigins = [];
8521
+ const originRegExps = [];
8522
+ for (const allowedOrigin of allowedOrigins) {
8523
+ if (allowedOrigin.includes("*")) {
8524
+ originRegExps.push(originPatternToRegExp(allowedOrigin));
8525
+ } else {
8526
+ literalOrigins.push(allowedOrigin);
8527
+ }
8528
+ }
8529
+
8530
+ return {
8531
+ // when the request origin cannot be reflected back we must still send a
8532
+ // single valid origin, never a pattern
8533
+ defaultOrigin: literalOrigins[0] ?? "*",
8534
+ isAllowed: (origin) => {
8535
+ if (literalOrigins.includes(origin)) {
8536
+ return true;
8537
+ }
8538
+ for (const originRegExp of originRegExps) {
8539
+ if (originRegExp.test(origin)) {
8540
+ return true;
8541
+ }
8542
+ }
8543
+ return false;
8544
+ },
8545
+ };
8546
+ };
8547
+
8548
+ const originPatternToRegExp = (originPattern) => {
8549
+ const source = originPattern
8550
+ .split("*")
8551
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
8552
+ .join("[^/]*");
8553
+ return new RegExp(`^${source}$`);
8554
+ };
8555
+
8463
8556
  // https://www.w3.org/TR/cors/
8464
8557
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
8465
8558
  const generateAccessControlHeaders = ({
8466
8559
  request: { headers },
8467
- accessControlAllowedOrigins,
8560
+ allowedOriginChecker,
8468
8561
  accessControlAllowRequestOrigin,
8469
8562
  accessControlAllowedMethods,
8470
8563
  accessControlAllowRequestMethod,
@@ -8491,7 +8584,7 @@ const generateAccessControlHeaders = ({
8491
8584
  : null;
8492
8585
 
8493
8586
  if (requestOrigin) {
8494
- if (accessControlAllowedOrigins.includes(requestOrigin)) {
8587
+ if (allowedOriginChecker.isAllowed(requestOrigin)) {
8495
8588
  allowOrigin = requestOrigin;
8496
8589
  vary.push("origin");
8497
8590
  } else if (accessControlAllowRequestOrigin) {
@@ -8503,7 +8596,7 @@ const generateAccessControlHeaders = ({
8503
8596
  }
8504
8597
 
8505
8598
  if (allowOrigin === null) {
8506
- allowOrigin = accessControlAllowedOrigins[0] ?? "*";
8599
+ allowOrigin = allowedOriginChecker.defaultOrigin;
8507
8600
  }
8508
8601
 
8509
8602
  const allowedMethodArray = [...accessControlAllowedMethods];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.4.1",
3
+ "version": "17.6.0",
4
4
  "type": "module",
5
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
6
6
  "repository": {
@@ -31,4 +31,7 @@ const RESPONSE_KEYS_COMPOSITION = {
31
31
  headers: composeTwoHeaders,
32
32
  body: (prevBody, body) => body,
33
33
  bodyEncoding: (prevEncoding, encoding) => encoding,
34
+ // measures a response hands back, merged into the server-timing header when
35
+ // the server has serverTiming enabled (see finalizeResponseProperties)
36
+ timing: (prevTiming, timing) => ({ ...prevTiming, ...timing }),
34
37
  };
@@ -35,12 +35,16 @@ export const serverPluginCORS = ({
35
35
  return [];
36
36
  }
37
37
 
38
+ const allowedOriginChecker = createAllowedOriginChecker(
39
+ accessControlAllowedOrigins,
40
+ );
41
+
38
42
  return {
39
43
  name: "jsenv:cors",
40
44
  injectResponseProperties: (request) => {
41
45
  const accessControlHeaders = generateAccessControlHeaders({
42
46
  request,
43
- accessControlAllowedOrigins,
47
+ allowedOriginChecker,
44
48
  accessControlAllowRequestOrigin,
45
49
  accessControlAllowedMethods,
46
50
  accessControlAllowRequestMethod,
@@ -57,11 +61,53 @@ export const serverPluginCORS = ({
57
61
  };
58
62
  };
59
63
 
64
+ /**
65
+ * An origin ("scheme://host:port") can never contain "*", so it is free to be
66
+ * used as a wildcard standing for any run of characters except "/":
67
+ * "https://pr-*-my-app.fly.dev" matches "https://pr-12-my-app.fly.dev".
68
+ */
69
+ const createAllowedOriginChecker = (allowedOrigins) => {
70
+ const literalOrigins = [];
71
+ const originRegExps = [];
72
+ for (const allowedOrigin of allowedOrigins) {
73
+ if (allowedOrigin.includes("*")) {
74
+ originRegExps.push(originPatternToRegExp(allowedOrigin));
75
+ } else {
76
+ literalOrigins.push(allowedOrigin);
77
+ }
78
+ }
79
+
80
+ return {
81
+ // when the request origin cannot be reflected back we must still send a
82
+ // single valid origin, never a pattern
83
+ defaultOrigin: literalOrigins[0] ?? "*",
84
+ isAllowed: (origin) => {
85
+ if (literalOrigins.includes(origin)) {
86
+ return true;
87
+ }
88
+ for (const originRegExp of originRegExps) {
89
+ if (originRegExp.test(origin)) {
90
+ return true;
91
+ }
92
+ }
93
+ return false;
94
+ },
95
+ };
96
+ };
97
+
98
+ const originPatternToRegExp = (originPattern) => {
99
+ const source = originPattern
100
+ .split("*")
101
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
102
+ .join("[^/]*");
103
+ return new RegExp(`^${source}$`);
104
+ };
105
+
60
106
  // https://www.w3.org/TR/cors/
61
107
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
62
108
  const generateAccessControlHeaders = ({
63
109
  request: { headers },
64
- accessControlAllowedOrigins,
110
+ allowedOriginChecker,
65
111
  accessControlAllowRequestOrigin,
66
112
  accessControlAllowedMethods,
67
113
  accessControlAllowRequestMethod,
@@ -88,7 +134,7 @@ const generateAccessControlHeaders = ({
88
134
  : null;
89
135
 
90
136
  if (requestOrigin) {
91
- if (accessControlAllowedOrigins.includes(requestOrigin)) {
137
+ if (allowedOriginChecker.isAllowed(requestOrigin)) {
92
138
  allowOrigin = requestOrigin;
93
139
  vary.push("origin");
94
140
  } else if (accessControlAllowRequestOrigin) {
@@ -100,7 +146,7 @@ const generateAccessControlHeaders = ({
100
146
  }
101
147
 
102
148
  if (allowOrigin === null) {
103
- allowOrigin = accessControlAllowedOrigins[0] ?? "*";
149
+ allowOrigin = allowedOriginChecker.defaultOrigin;
104
150
  }
105
151
 
106
152
  const allowedMethodArray = [...accessControlAllowedMethods];
@@ -4,11 +4,7 @@
4
4
  * It is meant to be used inside "requestToResponse"
5
5
  */
6
6
 
7
- import {
8
- urlIsOrIsInsideOf,
9
- urlToExtension,
10
- urlToPathname,
11
- } from "@jsenv/urls";
7
+ import { urlIsOrIsInsideOf, urlToExtension, urlToPathname } from "@jsenv/urls";
12
8
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
13
9
  import { createReadStream, readFile, statSync } from "node:fs";
14
10
 
@@ -279,6 +279,9 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
279
279
  let statusMessage;
280
280
  let headers;
281
281
  let body;
282
+ // measures the route hands back, merged into the server-timing header
283
+ // when serverTiming is enabled (see finalizeResponseProperties)
284
+ let timing;
282
285
 
283
286
  if (fetchReturnValue instanceof Response) {
284
287
  status = fetchReturnValue.status;
@@ -294,6 +297,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
294
297
  statusMessage = fetchReturnValue.statusMessage;
295
298
  headers = fetchReturnValue.headers || {};
296
299
  body = fetchReturnValue.body;
300
+ timing = fetchReturnValue.timing;
297
301
  } else {
298
302
  throw new TypeError(
299
303
  `response must be a Response, or an Object, received ${fetchReturnValue}`,
@@ -326,7 +330,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
326
330
 
327
331
  onRouteMatch(route);
328
332
  onResponseHeaders(request, route, headers);
329
- return { status, statusText, statusMessage, headers, body };
333
+ return { status, statusText, statusMessage, headers, body, timing };
330
334
  };
331
335
 
332
336
  const matchRoutes = async (request) => {
@@ -412,8 +412,9 @@ const createEventHistory = (limit) => {
412
412
 
413
413
  const add = (data) => {
414
414
  events.push(data);
415
-
416
- if (events.length >= limit) {
415
+ // Strictly above, not "as many as": shifting at `>= limit` keeps one less
416
+ // than asked for.
417
+ if (events.length > limit) {
417
418
  events.shift();
418
419
  }
419
420
  };
@@ -12,11 +12,19 @@ import {
12
12
  export const timingToServerTimingResponseHeaders = (timing) => {
13
13
  const serverTimingHeader = {};
14
14
  Object.keys(timing).forEach((key, index) => {
15
- const name = letters[index] || "zz";
16
- serverTimingHeader[name] = {
17
- desc: key,
18
- dur: timing[key],
19
- };
15
+ // "a".."z" then "aa", "ab", …: names must be unique — they are the keys of
16
+ // the header object, so a shared fallback would overwrite every entry past
17
+ // it with the last one.
18
+ const name =
19
+ index < letters.length
20
+ ? letters[index]
21
+ : letters[Math.floor(index / letters.length) - 1] +
22
+ letters[index % letters.length];
23
+ const dur = timing[key];
24
+ // a non-number duration is a marker ("served from memory cache"): it has
25
+ // something to say (desc) but nothing to measure
26
+ serverTimingHeader[name] =
27
+ typeof dur === "number" ? { desc: key, dur } : { desc: key };
20
28
  });
21
29
  const serverTimingHeaderString =
22
30
  stringifyServerTimingHeader(serverTimingHeader);
@@ -149,6 +149,10 @@ export const startServer = async ({
149
149
  // This param should be enabled ONLY during development on your machine
150
150
  canExposeSensitiveData = false,
151
151
  nagle = true,
152
+ // false | true | { minDuration }: server-timing response headers.
153
+ // minDuration (ms) drops entries that took less — 0 keeps everything, which
154
+ // is what a test wants; a human reading devtools usually wants the noise
155
+ // gone (the jsenv dev server passes 0.5 for that).
152
156
  serverTiming = false,
153
157
  requestWaitingMs = 0,
154
158
  requestWaitingCallback = ({ request, requestWaitingMs }) => {
@@ -502,6 +506,10 @@ export const startServer = async ({
502
506
  sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
503
507
  return [receiveRequestOperation, sendResponseOperation];
504
508
  };
509
+ const serverTimingMinDuration =
510
+ serverTiming && typeof serverTiming === "object"
511
+ ? serverTiming.minDuration || 0
512
+ : 0;
505
513
  const getResponseProperties = async (request, { pushResponse }) => {
506
514
  const timings = {};
507
515
  const timing = serverTiming
@@ -543,9 +551,32 @@ export const startServer = async ({
543
551
  const finalizeResponseProperties = (responseProperties) => {
544
552
  if (serverTiming) {
545
553
  startRespondingTiming.end();
554
+ // A response can hand back measures of its own (a `timing` property —
555
+ // durations keyed by description, null for a plain marker): they join
556
+ // the server's own in the same server-timing header.
557
+ if (responseProperties.timing) {
558
+ Object.assign(timings, responseProperties.timing);
559
+ delete responseProperties.timing;
560
+ }
561
+ // serverTiming.minDuration drops what took less: a request crosses
562
+ // every registered route, and a wall of sub-millisecond ".routing"
563
+ // entries buries the measures worth reading. Markers (null) and the
564
+ // overall "time to start responding" — the summary the rest details —
565
+ // always stay.
566
+ const timingsWorthReading = {};
567
+ for (const name of Object.keys(timings)) {
568
+ const duration = timings[name];
569
+ if (
570
+ name === startRespondingTiming.name ||
571
+ typeof duration !== "number" ||
572
+ duration >= serverTimingMinDuration
573
+ ) {
574
+ timingsWorthReading[name] = duration;
575
+ }
576
+ }
546
577
  responseProperties.headers = composeTwoHeaders(
547
578
  responseProperties.headers,
548
- timingToServerTimingResponseHeaders(timings),
579
+ timingToServerTimingResponseHeaders(timingsWorthReading),
549
580
  );
550
581
  }
551
582
  if (requestWaitingMs) {