@jsenv/server 17.6.3 → 17.6.5

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
  }
@@ -5277,20 +5277,44 @@ const asFlatAssociations = (associations) => {
5277
5277
  return flatAssociations;
5278
5278
  };
5279
5279
 
5280
+ // The same associations object is matched against the same urls over and
5281
+ // over (node esm resolution conditions, ignore checks: once per reference).
5282
+ // Matched values are memoized per (associations, url); the returned object
5283
+ // stays built fresh on every call (callers may mutate it). The per-url map
5284
+ // is capped: in a long-lived dev server, urls carry changing search params
5285
+ // (?hot=timestamp) and would otherwise accumulate without bound.
5286
+ const matchedValuesCacheMap = new WeakMap();
5287
+ const MATCHED_VALUES_CACHE_LIMIT = 4096;
5288
+
5280
5289
  const applyAssociations = ({ url, associations }) => {
5281
5290
  if (url && typeof url.href === "string") url = url.href;
5282
5291
  assertUrlLike(url);
5283
- const flatAssociations = asFlatAssociations(associations);
5284
- let associatedValue = {};
5285
- for (const pattern of Object.keys(flatAssociations)) {
5286
- const { matched } = applyPatternMatching({
5287
- pattern,
5288
- url,
5289
- });
5290
- if (matched) {
5291
- const value = flatAssociations[pattern];
5292
- associatedValue = deepAssign(associatedValue, value);
5292
+ let urlCache = matchedValuesCacheMap.get(associations);
5293
+ if (!urlCache) {
5294
+ urlCache = new Map();
5295
+ matchedValuesCacheMap.set(associations, urlCache);
5296
+ }
5297
+ let matchedValues = urlCache.get(url);
5298
+ if (!matchedValues) {
5299
+ const flatAssociations = asFlatAssociations(associations);
5300
+ matchedValues = [];
5301
+ for (const pattern of Object.keys(flatAssociations)) {
5302
+ const { matched } = applyPatternMatching({
5303
+ pattern,
5304
+ url,
5305
+ });
5306
+ if (matched) {
5307
+ matchedValues.push(flatAssociations[pattern]);
5308
+ }
5293
5309
  }
5310
+ if (urlCache.size >= MATCHED_VALUES_CACHE_LIMIT) {
5311
+ urlCache.clear();
5312
+ }
5313
+ urlCache.set(url, matchedValues);
5314
+ }
5315
+ let associatedValue = {};
5316
+ for (const value of matchedValues) {
5317
+ associatedValue = deepAssign(associatedValue, value);
5294
5318
  }
5295
5319
  return associatedValue;
5296
5320
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.6.3",
3
+ "version": "17.6.5",
4
4
  "type": "module",
5
5
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
6
6
  "repository": {
@@ -50,14 +50,14 @@
50
50
  "@jsenv/plugin-commonjs": "../../related/plugin-commonjs",
51
51
  "@jsenv/server": "./",
52
52
  "@jsenv/snapshot": "../../tooling/snapshot",
53
- "@jsenv/url-meta": "../../tooling/url_meta",
53
+ "@jsenv/url-meta": "../../tooling/url-meta",
54
54
  "@jsenv/url-pattern": "../../tooling/url-pattern",
55
55
  "@jsenv/urls": "../../tooling/urls",
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
  }