@jsenv/server 17.6.4 → 17.6.6

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.
@@ -94,10 +94,20 @@
94
94
  ${css}
95
95
  </style>
96
96
  <dialog>
97
- <p>Connection with server is lost</p>
98
- <button autofocus name="reconnect">Retry</button>
97
+ <p>Connection with server is lost, waiting for it to come back...</p>
98
+ <button autofocus name="retry">Retry now</button>
99
99
  </dialog>`;
100
100
 
101
+ // The peer is a dev server being restarted, not a flaky network: it can be down
102
+ // for the length of an "npm install" and there is no attempt count past which
103
+ // giving up would be correct. Attempts space out to RETRY_AFTER_MAX_MS and never
104
+ // stop; the dialog is a status the page can leave and come back to, so closing
105
+ // it keeps the loop running and the page still reloads once the server answers.
106
+ const RETRY_AFTER_MS = 500;
107
+ const RETRY_AFTER_MAX_MS = 5000;
108
+ // Most restarts complete before this, so a quick one goes unnoticed.
109
+ const DIALOG_AFTER_MS = 3000;
110
+
101
111
  class JsenvAutoreloadOnServerRestart extends HTMLElement {
102
112
  constructor({ url } = {}) {
103
113
  super();
@@ -105,59 +115,83 @@
105
115
  root.innerHTML = html;
106
116
 
107
117
  const dialog = root.querySelector("dialog");
108
- const reconnectButton = dialog.querySelector('button[name="reconnect"]');
118
+ const retryButton = dialog.querySelector('button[name="retry"]');
109
119
 
110
- (async () => {
111
- const connect = () => {
112
- const websocket = new WebSocket(url);
113
- const connectedPromise = new Promise((resolve) => {
114
- websocket.onerror = () => {
115
- resolve(null);
116
- };
117
- websocket.onopen = () => {
118
- resolve(websocket);
119
- };
120
- });
121
- return connectedPromise;
122
- };
120
+ const connect = () => {
121
+ const websocket = new WebSocket(url);
122
+ return new Promise((resolve) => {
123
+ websocket.onerror = () => {
124
+ resolve(null);
125
+ };
126
+ websocket.onopen = () => {
127
+ resolve(websocket);
128
+ };
129
+ });
130
+ };
131
+
132
+ let retryAfter = RETRY_AFTER_MS;
133
+ let retryTimeout = null;
134
+ let dialogTimeout = null;
135
+
136
+ const tryToReconnect = async () => {
137
+ retryTimeout = null;
138
+ retryButton.disabled = true;
139
+ const websocket = await connect();
140
+ if (websocket) {
141
+ clearTimeout(dialogTimeout);
142
+ console.info("reconnected to server, reloading the page...");
143
+ window.location.reload(true);
144
+ return;
145
+ }
146
+ retryButton.disabled = false;
147
+ retryTimeout = setTimeout(tryToReconnect, retryAfter);
148
+ const nextRetryAfter = retryAfter * 2;
149
+ retryAfter =
150
+ nextRetryAfter > RETRY_AFTER_MAX_MS
151
+ ? RETRY_AFTER_MAX_MS
152
+ : nextRetryAfter;
153
+ };
123
154
 
155
+ const retryNow = () => {
156
+ if (retryTimeout === null) {
157
+ return;
158
+ }
159
+ clearTimeout(retryTimeout);
160
+ retryAfter = RETRY_AFTER_MS;
161
+ tryToReconnect();
162
+ };
163
+
164
+ const waitForServerToComeBack = () => {
165
+ console.info("connection to server lost, trying to reconnect");
166
+ dialogTimeout = setTimeout(() => {
167
+ dialogTimeout = null;
168
+ dialog.showModal();
169
+ }, DIALOG_AFTER_MS);
170
+ tryToReconnect();
171
+ };
172
+
173
+ retryButton.onclick = () => {
174
+ retryNow();
175
+ };
176
+ // A tab coming back to the foreground, or the network coming back, are the
177
+ // moments the server is most likely to be up already; waiting out the
178
+ // backoff then shows a detached page for no reason.
179
+ window.addEventListener("online", retryNow);
180
+ window.addEventListener("focus", retryNow);
181
+ document.addEventListener("visibilitychange", () => {
182
+ if (document.visibilityState === "visible") {
183
+ retryNow();
184
+ }
185
+ });
186
+
187
+ (async () => {
124
188
  const initialWebSocket = await connect();
125
189
  if (!initialWebSocket) {
190
+ waitForServerToComeBack();
126
191
  return;
127
192
  }
128
- initialWebSocket.onclose = async () => {
129
- console.info("connection to server lost, trying to reconnect");
130
- const retry = async () => {
131
- let attemptCount = 0;
132
- let timeToWait = 100;
133
- const tryToReconnect = async () => {
134
- const newWebsocket = await connect();
135
- if (newWebsocket) {
136
- dialog.close();
137
- console.info("reconnected to server, reloading the page...");
138
- window.location.reload(true);
139
- return;
140
- }
141
- attemptCount++;
142
- if (attemptCount === 10) {
143
- dialog.showModal();
144
- reconnectButton.onclick = async () => {
145
- reconnectButton.disabled = true;
146
- const previousContext = reconnectButton.textContent;
147
- reconnectButton.textContent = "Retrying...";
148
- await retry();
149
- reconnectButton.textContent = previousContext;
150
- reconnectButton.disabled = false;
151
- };
152
- return;
153
- }
154
- timeToWait += 100;
155
- await new Promise((resolve) => setTimeout(resolve, timeToWait));
156
- await tryToReconnect();
157
- };
158
- await tryToReconnect();
159
- };
160
- retry();
193
+ initialWebSocket.onclose = () => {
194
+ waitForServerToComeBack();
161
195
  };
162
196
  })();
163
197
  }
@@ -275,10 +275,20 @@
275
275
  ${css}
276
276
  </style>
277
277
  <dialog>
278
- <p>Connection with server is lost</p>
279
- <button autofocus name="reconnect">Retry</button>
278
+ <p>Connection with server is lost, waiting for it to come back...</p>
279
+ <button autofocus name="retry">Retry now</button>
280
280
  </dialog>`;
281
281
 
282
+ // The peer is a dev server being restarted, not a flaky network: it can be down
283
+ // for the length of an "npm install" and there is no attempt count past which
284
+ // giving up would be correct. Attempts space out to RETRY_AFTER_MAX_MS and never
285
+ // stop; the dialog is a status the page can leave and come back to, so closing
286
+ // it keeps the loop running and the page still reloads once the server answers.
287
+ const RETRY_AFTER_MS = 500;
288
+ const RETRY_AFTER_MAX_MS = 5000;
289
+ // Most restarts complete before this, so a quick one goes unnoticed.
290
+ const DIALOG_AFTER_MS = 3000;
291
+
282
292
  class JsenvAutoreloadOnServerRestart extends HTMLElement {
283
293
  constructor({ url } = {}) {
284
294
  super();
@@ -286,59 +296,83 @@
286
296
  root.innerHTML = html;
287
297
 
288
298
  const dialog = root.querySelector("dialog");
289
- const reconnectButton = dialog.querySelector('button[name="reconnect"]');
299
+ const retryButton = dialog.querySelector('button[name="retry"]');
290
300
 
291
- (async () => {
292
- const connect = () => {
293
- const websocket = new WebSocket(url);
294
- const connectedPromise = new Promise((resolve) => {
295
- websocket.onerror = () => {
296
- resolve(null);
297
- };
298
- websocket.onopen = () => {
299
- resolve(websocket);
300
- };
301
- });
302
- return connectedPromise;
303
- };
301
+ const connect = () => {
302
+ const websocket = new WebSocket(url);
303
+ return new Promise((resolve) => {
304
+ websocket.onerror = () => {
305
+ resolve(null);
306
+ };
307
+ websocket.onopen = () => {
308
+ resolve(websocket);
309
+ };
310
+ });
311
+ };
312
+
313
+ let retryAfter = RETRY_AFTER_MS;
314
+ let retryTimeout = null;
315
+ let dialogTimeout = null;
316
+
317
+ const tryToReconnect = async () => {
318
+ retryTimeout = null;
319
+ retryButton.disabled = true;
320
+ const websocket = await connect();
321
+ if (websocket) {
322
+ clearTimeout(dialogTimeout);
323
+ console.info("reconnected to server, reloading the page...");
324
+ window.location.reload(true);
325
+ return;
326
+ }
327
+ retryButton.disabled = false;
328
+ retryTimeout = setTimeout(tryToReconnect, retryAfter);
329
+ const nextRetryAfter = retryAfter * 2;
330
+ retryAfter =
331
+ nextRetryAfter > RETRY_AFTER_MAX_MS
332
+ ? RETRY_AFTER_MAX_MS
333
+ : nextRetryAfter;
334
+ };
304
335
 
336
+ const retryNow = () => {
337
+ if (retryTimeout === null) {
338
+ return;
339
+ }
340
+ clearTimeout(retryTimeout);
341
+ retryAfter = RETRY_AFTER_MS;
342
+ tryToReconnect();
343
+ };
344
+
345
+ const waitForServerToComeBack = () => {
346
+ console.info("connection to server lost, trying to reconnect");
347
+ dialogTimeout = setTimeout(() => {
348
+ dialogTimeout = null;
349
+ dialog.showModal();
350
+ }, DIALOG_AFTER_MS);
351
+ tryToReconnect();
352
+ };
353
+
354
+ retryButton.onclick = () => {
355
+ retryNow();
356
+ };
357
+ // A tab coming back to the foreground, or the network coming back, are the
358
+ // moments the server is most likely to be up already; waiting out the
359
+ // backoff then shows a detached page for no reason.
360
+ window.addEventListener("online", retryNow);
361
+ window.addEventListener("focus", retryNow);
362
+ document.addEventListener("visibilitychange", () => {
363
+ if (document.visibilityState === "visible") {
364
+ retryNow();
365
+ }
366
+ });
367
+
368
+ (async () => {
305
369
  const initialWebSocket = await connect();
306
370
  if (!initialWebSocket) {
371
+ waitForServerToComeBack();
307
372
  return;
308
373
  }
309
- initialWebSocket.onclose = async () => {
310
- console.info("connection to server lost, trying to reconnect");
311
- const retry = async () => {
312
- let attemptCount = 0;
313
- let timeToWait = 100;
314
- const tryToReconnect = async () => {
315
- const newWebsocket = await connect();
316
- if (newWebsocket) {
317
- dialog.close();
318
- console.info("reconnected to server, reloading the page...");
319
- window.location.reload(true);
320
- return;
321
- }
322
- attemptCount++;
323
- if (attemptCount === 10) {
324
- dialog.showModal();
325
- reconnectButton.onclick = async () => {
326
- reconnectButton.disabled = true;
327
- const previousContext = reconnectButton.textContent;
328
- reconnectButton.textContent = "Retrying...";
329
- await retry();
330
- reconnectButton.textContent = previousContext;
331
- reconnectButton.disabled = false;
332
- };
333
- return;
334
- }
335
- timeToWait += 100;
336
- await new Promise((resolve) => setTimeout(resolve, timeToWait));
337
- await tryToReconnect();
338
- };
339
- await tryToReconnect();
340
- };
341
- retry();
374
+ initialWebSocket.onclose = () => {
375
+ waitForServerToComeBack();
342
376
  };
343
377
  })();
344
378
  }
@@ -7309,11 +7309,14 @@ const TIMING_NOOP = () => {
7309
7309
  * - `GET /@jsenv/server/*`, serving this package's own client files;
7310
7310
  * - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
7311
7311
  * 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.
7312
+ * @param {boolean|Function|Object} [params.serverTiming=false] - `true`, `(request) =>
7313
+ * boolean` or `{ enabled, minDuration }` to send `server-timing` response headers: the
7314
+ * time to start responding, the routing of each plugin, what routes measure with
7315
+ * `helpers.timing` and the `timing` a response hands back. `enabled` can be a function
7316
+ * too: it is asked once per request, so a server can measure for a client it recognizes
7317
+ * (a header, a cookie, an origin) and stay silent for everyone else. `minDuration` (ms)
7318
+ * drops the entries that took less: 0 keeps everything (what a test wants), a human
7319
+ * reading devtools usually wants the sub-millisecond noise gone.
7317
7320
  * @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
7318
7321
  * still has no response after that many ms (0 disables).
7319
7322
  * @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
@@ -7670,13 +7673,16 @@ const startServer = async ({
7670
7673
  sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
7671
7674
  return [receiveRequestOperation, sendResponseOperation];
7672
7675
  };
7673
- const serverTimingMinDuration =
7674
- serverTiming && typeof serverTiming === "object"
7675
- ? serverTiming.minDuration || 0
7676
- : 0;
7676
+ const { serverTimingEnabled, serverTimingMinDuration } =
7677
+ resolveServerTiming(serverTiming);
7677
7678
  const getResponseProperties = async (request) => {
7679
+ // Asked once per request, on what the client sent: an api can then measure
7680
+ // for everyone and disclose to a token, a cookie, an origin — instead of
7681
+ // choosing between measuring nothing and telling every client what it does
7682
+ // internally.
7683
+ const timingEnabled = serverTimingEnabled(request);
7678
7684
  const timings = {};
7679
- const timing = serverTiming
7685
+ const timing = timingEnabled
7680
7686
  ? (name) => {
7681
7687
  const start = performance.now();
7682
7688
  timings[name] = null;
@@ -7711,7 +7717,7 @@ const startServer = async ({
7711
7717
 
7712
7718
  let headersToInject;
7713
7719
  const finalizeResponseProperties = (responseProperties) => {
7714
- if (serverTiming) {
7720
+ if (timingEnabled) {
7715
7721
  startRespondingTiming.end();
7716
7722
  // A response can hand back measures of its own (a `timing` property —
7717
7723
  // durations keyed by description, null for a plain marker): they join
@@ -8233,6 +8239,36 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
8233
8239
  exit: STOP_REASON_PROCESS_EXIT,
8234
8240
  };
8235
8241
 
8242
+ const SERVER_TIMING_DISABLED = () => false;
8243
+ const SERVER_TIMING_ENABLED = () => true;
8244
+
8245
+ const resolveServerTiming = (serverTiming) => {
8246
+ if (typeof serverTiming === "function") {
8247
+ return {
8248
+ serverTimingEnabled: serverTiming,
8249
+ serverTimingMinDuration: 0,
8250
+ };
8251
+ }
8252
+ if (serverTiming && typeof serverTiming === "object") {
8253
+ const { enabled = true, minDuration = 0 } = serverTiming;
8254
+ return {
8255
+ serverTimingEnabled:
8256
+ typeof enabled === "function"
8257
+ ? enabled
8258
+ : enabled
8259
+ ? SERVER_TIMING_ENABLED
8260
+ : SERVER_TIMING_DISABLED,
8261
+ serverTimingMinDuration: minDuration,
8262
+ };
8263
+ }
8264
+ return {
8265
+ serverTimingEnabled: serverTiming
8266
+ ? SERVER_TIMING_ENABLED
8267
+ : SERVER_TIMING_DISABLED,
8268
+ serverTimingMinDuration: 0,
8269
+ };
8270
+ };
8271
+
8236
8272
  const internalErrorHtmlFileUrl = import.meta.resolve("./client/error_handler/500.html");
8237
8273
  let internalErrorHtmlTemplate;
8238
8274
  const readInternalErrorHtmlTemplate = () => {
@@ -8421,6 +8457,9 @@ class ProgressiveResponse {
8421
8457
  * so here we just need to add the CORS headers to the response
8422
8458
  */
8423
8459
 
8460
+ const TIMING_ALLOW_ORIGIN_DISABLED = () => false;
8461
+ const TIMING_ALLOW_ORIGIN_ENABLED = () => true;
8462
+
8424
8463
  const jsenvAccessControlAllowedHeaders = ["x-requested-with"];
8425
8464
 
8426
8465
  const jsenvAccessControlAllowedMethods = [
@@ -8453,8 +8492,10 @@ const jsenvAccessControlAllowedMethods = [
8453
8492
  * @param {boolean} [params.accessControlAllowCredentials=false] - Send
8454
8493
  * `access-control-allow-credentials: true`.
8455
8494
  * @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.
8495
+ * @param {boolean|Function} [params.timingAllowOrigin=false] - Send `timing-allow-origin`
8496
+ * so the allowed origin can read resource timing (the `server-timing` header included).
8497
+ * A `(request) => boolean` is asked once per request, so it can be the very test that
8498
+ * decides `startServer({ serverTiming })`.
8458
8499
  * @returns {Object|Array} The plugin, or `[]` when CORS stays disabled.
8459
8500
  */
8460
8501
  const serverPluginCORS = ({
@@ -8482,6 +8523,12 @@ const serverPluginCORS = ({
8482
8523
  const allowedOriginChecker = createAllowedOriginChecker(
8483
8524
  accessControlAllowedOrigins,
8484
8525
  );
8526
+ const timingAllowOriginEnabled =
8527
+ typeof timingAllowOrigin === "function"
8528
+ ? timingAllowOrigin
8529
+ : timingAllowOrigin
8530
+ ? TIMING_ALLOW_ORIGIN_ENABLED
8531
+ : TIMING_ALLOW_ORIGIN_DISABLED;
8485
8532
 
8486
8533
  return {
8487
8534
  name: "jsenv:cors",
@@ -8496,7 +8543,7 @@ const serverPluginCORS = ({
8496
8543
  accessControlAllowRequestHeaders,
8497
8544
  accessControlAllowCredentials,
8498
8545
  accessControlMaxAge,
8499
- timingAllowOrigin,
8546
+ timingAllowOriginEnabled,
8500
8547
  });
8501
8548
  return {
8502
8549
  headers: accessControlHeaders,
@@ -8550,7 +8597,7 @@ const originPatternToRegExp = (originPattern) => {
8550
8597
  // https://www.w3.org/TR/cors/
8551
8598
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
8552
8599
  const generateAccessControlHeaders = ({
8553
- request: { headers },
8600
+ request,
8554
8601
  allowedOriginChecker,
8555
8602
  accessControlAllowRequestOrigin,
8556
8603
  accessControlAllowedMethods,
@@ -8561,8 +8608,9 @@ const generateAccessControlHeaders = ({
8561
8608
  // by default OPTIONS request can be cache for a long time, it's not going to change soon ?
8562
8609
  // we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
8563
8610
  accessControlMaxAge = 600,
8564
- timingAllowOrigin,
8611
+ timingAllowOriginEnabled,
8565
8612
  } = {}) => {
8613
+ const { headers } = request;
8566
8614
  const vary = [];
8567
8615
 
8568
8616
  // Access-Control-Allow-Origin must be a single value (not a list).
@@ -8626,7 +8674,9 @@ const generateAccessControlHeaders = ({
8626
8674
  ? { "access-control-allow-credentials": true }
8627
8675
  : {}),
8628
8676
  "access-control-max-age": accessControlMaxAge,
8629
- ...(timingAllowOrigin ? { "timing-allow-origin": allowOrigin } : {}),
8677
+ ...(timingAllowOriginEnabled(request)
8678
+ ? { "timing-allow-origin": allowOrigin }
8679
+ : {}),
8630
8680
  ...(vary.length ? { vary: vary.join(", ") } : {}),
8631
8681
  };
8632
8682
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.6.4",
3
+ "version": "17.6.6",
4
4
  "type": "module",
5
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
6
6
  "repository": {
@@ -56,8 +56,8 @@
56
56
  "@jsenv/utils": "../../tooling/utils",
57
57
  "eventsource": "5.1.1",
58
58
  "formidable": "3.5.4",
59
- "playwright": "1.62.1",
60
- "undici": "8.10.0",
59
+ "playwright": "1.63.0",
60
+ "undici": "8.10.2",
61
61
  "ws": "8.21.3"
62
62
  },
63
63
  "publishConfig": {
@@ -26,10 +26,20 @@ const html = /* html */ `<style>
26
26
  ${css}
27
27
  </style>
28
28
  <dialog>
29
- <p>Connection with server is lost</p>
30
- <button autofocus name="reconnect">Retry</button>
29
+ <p>Connection with server is lost, waiting for it to come back...</p>
30
+ <button autofocus name="retry">Retry now</button>
31
31
  </dialog>`;
32
32
 
33
+ // The peer is a dev server being restarted, not a flaky network: it can be down
34
+ // for the length of an "npm install" and there is no attempt count past which
35
+ // giving up would be correct. Attempts space out to RETRY_AFTER_MAX_MS and never
36
+ // stop; the dialog is a status the page can leave and come back to, so closing
37
+ // it keeps the loop running and the page still reloads once the server answers.
38
+ const RETRY_AFTER_MS = 500;
39
+ const RETRY_AFTER_MAX_MS = 5000;
40
+ // Most restarts complete before this, so a quick one goes unnoticed.
41
+ const DIALOG_AFTER_MS = 3000;
42
+
33
43
  class JsenvAutoreloadOnServerRestart extends HTMLElement {
34
44
  constructor({ url } = {}) {
35
45
  super();
@@ -37,59 +47,83 @@ class JsenvAutoreloadOnServerRestart extends HTMLElement {
37
47
  root.innerHTML = html;
38
48
 
39
49
  const dialog = root.querySelector("dialog");
40
- const reconnectButton = dialog.querySelector('button[name="reconnect"]');
50
+ const retryButton = dialog.querySelector('button[name="retry"]');
41
51
 
42
- (async () => {
43
- const connect = () => {
44
- const websocket = new WebSocket(url);
45
- const connectedPromise = new Promise((resolve) => {
46
- websocket.onerror = () => {
47
- resolve(null);
48
- };
49
- websocket.onopen = () => {
50
- resolve(websocket);
51
- };
52
- });
53
- return connectedPromise;
54
- };
52
+ const connect = () => {
53
+ const websocket = new WebSocket(url);
54
+ return new Promise((resolve) => {
55
+ websocket.onerror = () => {
56
+ resolve(null);
57
+ };
58
+ websocket.onopen = () => {
59
+ resolve(websocket);
60
+ };
61
+ });
62
+ };
63
+
64
+ let retryAfter = RETRY_AFTER_MS;
65
+ let retryTimeout = null;
66
+ let dialogTimeout = null;
67
+
68
+ const tryToReconnect = async () => {
69
+ retryTimeout = null;
70
+ retryButton.disabled = true;
71
+ const websocket = await connect();
72
+ if (websocket) {
73
+ clearTimeout(dialogTimeout);
74
+ console.info("reconnected to server, reloading the page...");
75
+ window.location.reload(true);
76
+ return;
77
+ }
78
+ retryButton.disabled = false;
79
+ retryTimeout = setTimeout(tryToReconnect, retryAfter);
80
+ const nextRetryAfter = retryAfter * 2;
81
+ retryAfter =
82
+ nextRetryAfter > RETRY_AFTER_MAX_MS
83
+ ? RETRY_AFTER_MAX_MS
84
+ : nextRetryAfter;
85
+ };
55
86
 
87
+ const retryNow = () => {
88
+ if (retryTimeout === null) {
89
+ return;
90
+ }
91
+ clearTimeout(retryTimeout);
92
+ retryAfter = RETRY_AFTER_MS;
93
+ tryToReconnect();
94
+ };
95
+
96
+ const waitForServerToComeBack = () => {
97
+ console.info("connection to server lost, trying to reconnect");
98
+ dialogTimeout = setTimeout(() => {
99
+ dialogTimeout = null;
100
+ dialog.showModal();
101
+ }, DIALOG_AFTER_MS);
102
+ tryToReconnect();
103
+ };
104
+
105
+ retryButton.onclick = () => {
106
+ retryNow();
107
+ };
108
+ // A tab coming back to the foreground, or the network coming back, are the
109
+ // moments the server is most likely to be up already; waiting out the
110
+ // backoff then shows a detached page for no reason.
111
+ window.addEventListener("online", retryNow);
112
+ window.addEventListener("focus", retryNow);
113
+ document.addEventListener("visibilitychange", () => {
114
+ if (document.visibilityState === "visible") {
115
+ retryNow();
116
+ }
117
+ });
118
+
119
+ (async () => {
56
120
  const initialWebSocket = await connect();
57
121
  if (!initialWebSocket) {
122
+ waitForServerToComeBack();
58
123
  return;
59
124
  }
60
- initialWebSocket.onclose = async () => {
61
- console.info("connection to server lost, trying to reconnect");
62
- const retry = async () => {
63
- let attemptCount = 0;
64
- let timeToWait = 100;
65
- const tryToReconnect = async () => {
66
- const newWebsocket = await connect();
67
- if (newWebsocket) {
68
- dialog.close();
69
- console.info("reconnected to server, reloading the page...");
70
- window.location.reload(true);
71
- return;
72
- }
73
- attemptCount++;
74
- if (attemptCount === 10) {
75
- dialog.showModal();
76
- reconnectButton.onclick = async () => {
77
- reconnectButton.disabled = true;
78
- const previousContext = reconnectButton.textContent;
79
- reconnectButton.textContent = "Retrying...";
80
- await retry();
81
- reconnectButton.textContent = previousContext;
82
- reconnectButton.disabled = false;
83
- };
84
- return;
85
- }
86
- timeToWait += 100;
87
- await new Promise((resolve) => setTimeout(resolve, timeToWait));
88
- await tryToReconnect();
89
- };
90
- await tryToReconnect();
91
- };
92
- retry();
125
+ initialWebSocket.onclose = () => {
126
+ waitForServerToComeBack();
93
127
  };
94
128
  })();
95
129
  }
@@ -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
  };
@@ -129,11 +129,14 @@ const TIMING_NOOP = () => {
129
129
  * - `GET /@jsenv/server/*`, serving this package's own client files;
130
130
  * - `/.internal/alive.websocket` and `/.internal/alive.eventsource`, which a client
131
131
  * 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.
132
+ * @param {boolean|Function|Object} [params.serverTiming=false] - `true`, `(request) =>
133
+ * boolean` or `{ enabled, minDuration }` to send `server-timing` response headers: the
134
+ * time to start responding, the routing of each plugin, what routes measure with
135
+ * `helpers.timing` and the `timing` a response hands back. `enabled` can be a function
136
+ * too: it is asked once per request, so a server can measure for a client it recognizes
137
+ * (a header, a cookie, an origin) and stay silent for everyone else. `minDuration` (ms)
138
+ * drops the entries that took less: 0 keeps everything (what a test wants), a human
139
+ * reading devtools usually wants the sub-millisecond noise gone.
137
140
  * @param {number} [params.requestWaitingMs=0] - Call `requestWaitingCallback` when a request
138
141
  * still has no response after that many ms (0 disables).
139
142
  * @param {Function} [params.requestWaitingCallback] - `({ request, requestWaitingMs })`, logs a warning by default.
@@ -490,13 +493,16 @@ export const startServer = async ({
490
493
  sendResponseOperation.addAbortSignal(receiveRequestOperation.signal);
491
494
  return [receiveRequestOperation, sendResponseOperation];
492
495
  };
493
- const serverTimingMinDuration =
494
- serverTiming && typeof serverTiming === "object"
495
- ? serverTiming.minDuration || 0
496
- : 0;
496
+ const { serverTimingEnabled, serverTimingMinDuration } =
497
+ resolveServerTiming(serverTiming);
497
498
  const getResponseProperties = async (request) => {
499
+ // Asked once per request, on what the client sent: an api can then measure
500
+ // for everyone and disclose to a token, a cookie, an origin — instead of
501
+ // choosing between measuring nothing and telling every client what it does
502
+ // internally.
503
+ const timingEnabled = serverTimingEnabled(request);
498
504
  const timings = {};
499
- const timing = serverTiming
505
+ const timing = timingEnabled
500
506
  ? (name) => {
501
507
  const start = performance.now();
502
508
  timings[name] = null;
@@ -531,7 +537,7 @@ export const startServer = async ({
531
537
 
532
538
  let headersToInject;
533
539
  const finalizeResponseProperties = (responseProperties) => {
534
- if (serverTiming) {
540
+ if (timingEnabled) {
535
541
  startRespondingTiming.end();
536
542
  // A response can hand back measures of its own (a `timing` property —
537
543
  // durations keyed by description, null for a plain marker): they join
@@ -1057,3 +1063,33 @@ const PROCESS_TEARDOWN_EVENTS_MAP = {
1057
1063
  beforeExit: STOP_REASON_PROCESS_BEFORE_EXIT,
1058
1064
  exit: STOP_REASON_PROCESS_EXIT,
1059
1065
  };
1066
+
1067
+ const SERVER_TIMING_DISABLED = () => false;
1068
+ const SERVER_TIMING_ENABLED = () => true;
1069
+
1070
+ const resolveServerTiming = (serverTiming) => {
1071
+ if (typeof serverTiming === "function") {
1072
+ return {
1073
+ serverTimingEnabled: serverTiming,
1074
+ serverTimingMinDuration: 0,
1075
+ };
1076
+ }
1077
+ if (serverTiming && typeof serverTiming === "object") {
1078
+ const { enabled = true, minDuration = 0 } = serverTiming;
1079
+ return {
1080
+ serverTimingEnabled:
1081
+ typeof enabled === "function"
1082
+ ? enabled
1083
+ : enabled
1084
+ ? SERVER_TIMING_ENABLED
1085
+ : SERVER_TIMING_DISABLED,
1086
+ serverTimingMinDuration: minDuration,
1087
+ };
1088
+ }
1089
+ return {
1090
+ serverTimingEnabled: serverTiming
1091
+ ? SERVER_TIMING_ENABLED
1092
+ : SERVER_TIMING_DISABLED,
1093
+ serverTimingMinDuration: 0,
1094
+ };
1095
+ };