@jsenv/core 41.5.21 → 41.5.22

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.
@@ -4,7 +4,7 @@ let createEventsManager;
4
4
  const callbacksMap = new Map();
5
5
  let cleanup;
6
6
  const addCallbacks = (namedCallbacks) => {
7
- let callbacksMapSize = callbacksMap.size;
7
+ const sizeBeforeAdd = callbacksMap.size;
8
8
  Object.keys(namedCallbacks).forEach((eventName) => {
9
9
  const callback = namedCallbacks[eventName];
10
10
  const existingCallbacks = callbacksMap.get(eventName);
@@ -17,7 +17,10 @@ let createEventsManager;
17
17
  }
18
18
  callbacks.push(callback);
19
19
  });
20
- if (effect && callbacksMapSize === 0 && callbacksMapSize.size > 0) {
20
+ // the effect owns whatever the callbacks need to exist (the connection),
21
+ // so it runs on the edge into "someone is listening" and its cleanup runs
22
+ // on the edge back out of it
23
+ if (sizeBeforeAdd === 0 && callbacksMap.size > 0) {
21
24
  cleanup = effect();
22
25
  }
23
26
 
@@ -25,7 +28,7 @@ let createEventsManager;
25
28
  return () => {
26
29
  if (removed) return;
27
30
  removed = true;
28
- callbacksMapSize = callbacksMap.size;
31
+ const sizeBeforeRemove = callbacksMap.size;
29
32
  Object.keys(namedCallbacks).forEach((eventName) => {
30
33
  const callback = namedCallbacks[eventName];
31
34
  const callbacks = callbacksMap.get(eventName);
@@ -40,12 +43,7 @@ let createEventsManager;
40
43
  }
41
44
  });
42
45
  namedCallbacks = null; // allow garbage collect
43
- if (
44
- cleanup &&
45
- typeof cleanup === "function" &&
46
- callbacksMapSize > 0 &&
47
- callbacksMapSize.size === 0
48
- ) {
46
+ if (cleanup && sizeBeforeRemove > 0 && callbacksMap.size === 0) {
49
47
  cleanup();
50
48
  cleanup = null;
51
49
  }
@@ -85,9 +83,14 @@ let createConnectionManager;
85
83
  CLOSED: "closed",
86
84
  };
87
85
 
86
+ // The peer is a dev server being restarted, not a flaky network: there is no
87
+ // attempt count or time budget past which giving up would be correct. Attempts
88
+ // space out to retryAfterMax and never stop, and the moments where the server
89
+ // is most likely to be back already (tab visible, window focused, network
90
+ // back) skip the wait instead of leaving a detached page on screen.
88
91
  createConnectionManager = (
89
92
  attemptConnection,
90
- { logs, retry, retryAfter, retryMaxAttempt, retryAllocatedMs },
93
+ { logs, retry, retryAfter, retryAfterMax },
91
94
  ) => {
92
95
  const readyState = {
93
96
  value: READY_STATES.CLOSED,
@@ -102,6 +105,7 @@ let createConnectionManager;
102
105
  };
103
106
 
104
107
  let _disconnect = () => {};
108
+ let _retryNow = () => {};
105
109
  const connect = () => {
106
110
  if (
107
111
  readyState.value === READY_STATES.CONNECTING ||
@@ -110,11 +114,11 @@ let createConnectionManager;
110
114
  return;
111
115
  }
112
116
 
113
- let retryCount = 0;
114
- let msSpent = 0;
117
+ let retryDelay = retryAfter;
118
+ let retryTimeout = null;
115
119
  const attempt = () => {
120
+ retryTimeout = null;
116
121
  readyState.goTo(READY_STATES.CONNECTING);
117
- let timeout;
118
122
  const cancelAttempt = attemptConnection({
119
123
  onClosed: () => {
120
124
  if (!retry) {
@@ -124,24 +128,6 @@ let createConnectionManager;
124
128
  }
125
129
  return;
126
130
  }
127
- if (retryCount > retryMaxAttempt) {
128
- readyState.goTo(READY_STATES.CLOSED);
129
- if (logs) {
130
- console.info(
131
- `[jsenv] could not connect to server after ${retryMaxAttempt} attempt`,
132
- );
133
- }
134
- return;
135
- }
136
- if (retryAllocatedMs && msSpent > retryAllocatedMs) {
137
- readyState.goTo(READY_STATES.CLOSED);
138
- if (logs) {
139
- console.info(
140
- `[jsenv] could not connect to server in less than ${retryAllocatedMs}ms`,
141
- );
142
- }
143
- return;
144
- }
145
131
  // if closed while open -> connection lost
146
132
  // otherwise it's the attempt to connect for the first time
147
133
  // or to reconnect
@@ -152,22 +138,31 @@ let createConnectionManager;
152
138
  );
153
139
  }
154
140
  }
155
- retryCount++;
156
- timeout = setTimeout(() => {
157
- msSpent += retryAfter;
158
- attempt();
159
- }, retryAfter);
141
+ retryTimeout = setTimeout(attempt, retryDelay);
142
+ const nextRetryDelay = retryDelay * 2;
143
+ retryDelay =
144
+ nextRetryDelay > retryAfterMax ? retryAfterMax : nextRetryDelay;
160
145
  },
161
146
  onOpen: () => {
147
+ retryDelay = retryAfter;
162
148
  readyState.goTo(READY_STATES.OPEN);
163
149
  },
164
150
  });
165
151
  _disconnect = () => {
166
152
  cancelAttempt();
167
- clearTimeout(timeout);
153
+ clearTimeout(retryTimeout);
154
+ retryTimeout = null;
168
155
  readyState.goTo(READY_STATES.CLOSED);
169
156
  };
170
157
  };
158
+ _retryNow = () => {
159
+ if (retryTimeout === null) {
160
+ return;
161
+ }
162
+ clearTimeout(retryTimeout);
163
+ retryDelay = retryAfter;
164
+ attempt();
165
+ };
171
166
  attempt();
172
167
  };
173
168
 
@@ -194,6 +189,9 @@ let createConnectionManager;
194
189
  _disconnect();
195
190
  }
196
191
  });
192
+ const removeServerMightBeBackListeners = listenServerMightBeBack(() => {
193
+ _retryNow();
194
+ });
197
195
 
198
196
  return {
199
197
  readyState,
@@ -201,11 +199,31 @@ let createConnectionManager;
201
199
  disconnect,
202
200
  destroy: () => {
203
201
  removePageUnloadListener();
202
+ removeServerMightBeBackListeners();
204
203
  disconnect();
205
204
  },
206
205
  };
207
206
  };
208
207
 
208
+ const listenServerMightBeBack = (callback) => {
209
+ const removeOnlineListener = listenEvent(window, "online", callback);
210
+ const removeFocusListener = listenEvent(window, "focus", callback);
211
+ const removeVisibilityChangeListener = listenEvent(
212
+ document,
213
+ "visibilitychange",
214
+ () => {
215
+ if (document.visibilityState === "visible") {
216
+ callback();
217
+ }
218
+ },
219
+ );
220
+ return () => {
221
+ removeOnlineListener();
222
+ removeFocusListener();
223
+ removeVisibilityChangeListener();
224
+ };
225
+ };
226
+
209
227
  // const listenPageMightFreeze = (callback) => {
210
228
  // const removePageHideListener = listenEvent(window, "pagehide", (pageHideEvent) => {
211
229
  // if (pageHideEvent.persisted === true) {
@@ -271,8 +289,7 @@ let createWebSocketConnection;
271
289
  useEventsToManageConnection = true,
272
290
  retry = false,
273
291
  retryAfter = 1000,
274
- retryMaxAttempt = Infinity,
275
- retryAllocatedMs = Infinity,
292
+ retryAfterMax = 5000,
276
293
  } = {},
277
294
  ) => {
278
295
  const connectionManager = createConnectionManager(
@@ -315,7 +332,7 @@ let createWebSocketConnection;
315
332
  }
316
333
  };
317
334
  },
318
- { logs, retry, retryAfter, retryMaxAttempt, retryAllocatedMs },
335
+ { logs, retry, retryAfter, retryAfterMax },
319
336
  );
320
337
  const eventsManager = createEventsManager({
321
338
  effect: () => {
@@ -439,7 +456,6 @@ const serverEventsInterface = {
439
456
  const websocketConnection = createWebSocketConnection(websocketUrl, {
440
457
  logs,
441
458
  retry: true,
442
- retryAllocatedMs: 10_000,
443
459
  });
444
460
 
445
461
  const { readyState, connect, disconnect, listenEvents } =
@@ -236,17 +236,29 @@ const isExactVersion = (declaredVersion) => {
236
236
  };
237
237
 
238
238
  /*
239
- * Detects when "npm install" makes a missing or outdated dependency match what
240
- * the project package.json declares, so the browser can be reloaded at that
241
- * moment.
239
+ * Detects the moment "npm install" changes what the running pages get from
240
+ * node_modules, so the browser can be reloaded at that moment:
241
+ *
242
+ * - a declared dependency stops being missing or outdated: what package.json
243
+ * asks for is there;
244
+ * - a package the pages were served changes version: the "?v=" baked into the
245
+ * urls they evaluated names a version node_modules no longer holds. Left
246
+ * alone, the next hot reload of a file importing that package resolves it to
247
+ * the new version and the page evaluates a second copy of the package next
248
+ * to the first, two module scopes for something meant to exist once.
242
249
  *
243
250
  * node_modules is deliberately not watched: it is far too big, and an install
244
251
  * rewrites, dedupes and moves package directories around, so a watcher placed
245
- * on one of them is unreliable. Instead the few packages known to be missing or
246
- * outdated are polled, which costs a couple of readFileSync and stops as soon as
247
- * they are all installed. The project package.json is watched though: it is a
248
- * single file, and editing it is what puts a dependency out of date in the first
249
- * place.
252
+ * on one of them is unreliable. Instead a few package.json files are polled,
253
+ * at the cost of a stat each: the packages known to be missing or outdated,
254
+ * and the packages the pages were served, whose package.json the url graph
255
+ * keeps (that is where "?v=" comes from). The project package.json is watched
256
+ * though: it is a single file, and editing it is what puts a dependency out of
257
+ * date in the first place.
258
+ *
259
+ * Both detections run in the same tick so that an install fixing an outdated
260
+ * package the page runs is reported once: two reports would be two reloads,
261
+ * the second one landing on the page that just came back.
250
262
  */
251
263
 
252
264
 
@@ -254,7 +266,13 @@ const POLL_INTERVAL = 500;
254
266
 
255
267
  const watchDependencies = (
256
268
  packageDirectory,
257
- { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
269
+ {
270
+ getKitchens = () => [],
271
+ onProblem,
272
+ onInstalled,
273
+ onChange,
274
+ pollInterval = POLL_INTERVAL,
275
+ },
258
276
  ) => {
259
277
  let problemMap = new Map();
260
278
  // every path given to the browser is relative to the package directory, the
@@ -269,11 +287,49 @@ const watchDependencies = (
269
287
  if (!packageDirectory.url) {
270
288
  return watcher;
271
289
  }
272
- let timer = null;
273
290
 
274
- const check = () => {
291
+ const checkServedVersions = (installMap) => {
292
+ for (const kitchen of getKitchens()) {
293
+ for (const urlInfo of kitchen.graph.urlInfoMap.values()) {
294
+ const served = readServedVersion(urlInfo);
295
+ if (!served) {
296
+ continue;
297
+ }
298
+ const installedPackageJson = readInstalledPackageJson(
299
+ packageDirectory,
300
+ served.directoryUrl,
301
+ );
302
+ if (!installedPackageJson) {
303
+ // the package is being rewritten, what it becomes is known once it is back
304
+ continue;
305
+ }
306
+ const installedVersion = installedPackageJson.version;
307
+ if (installedVersion === served.version) {
308
+ continue;
309
+ }
310
+ // the graph learns the file moved, as it would from a watcher: the
311
+ // files importing this package are cooked again with the new "?v="
312
+ // when the page comes back
313
+ urlInfo.onModified();
314
+ if (installMap.has(served.packageName)) {
315
+ continue;
316
+ }
317
+ installMap.set(served.packageName, {
318
+ packageName: served.packageName,
319
+ installedVersion,
320
+ servedVersion: served.version,
321
+ declaredVersion: null,
322
+ severity: "warning",
323
+ });
324
+ }
325
+ }
326
+ };
327
+
328
+ const checkDeclared = (installMap) => {
329
+ const statusMap = new Map();
275
330
  const nextProblemMap = new Map();
276
331
  for (const status of readDependencyStatuses(packageDirectory)) {
332
+ statusMap.set(status.packageName, status);
277
333
  if (status.state === "missing" || status.state === "outdated") {
278
334
  nextProblemMap.set(status.packageName, status);
279
335
  }
@@ -289,9 +345,21 @@ const watchDependencies = (
289
345
  }
290
346
  }
291
347
  for (const [packageName, previousStatus] of problemMap) {
292
- if (!nextProblemMap.has(packageName)) {
293
- onInstalled(previousStatus);
348
+ if (nextProblemMap.has(packageName)) {
349
+ continue;
350
+ }
351
+ const install = installMap.get(packageName);
352
+ if (install) {
353
+ install.declaredVersion = previousStatus.declaredVersion;
354
+ continue;
294
355
  }
356
+ installMap.set(packageName, {
357
+ packageName,
358
+ installedVersion: statusMap.get(packageName).installedVersion,
359
+ servedVersion: null,
360
+ declaredVersion: previousStatus.declaredVersion,
361
+ severity: previousStatus.severity,
362
+ });
295
363
  }
296
364
  const changed =
297
365
  nextProblemMap.size !== problemMap.size ||
@@ -309,45 +377,96 @@ const watchDependencies = (
309
377
  if (changed) {
310
378
  onChange(watcher.getProblems());
311
379
  }
312
- if (problemMap.size === 0) {
313
- stopPolling();
314
- } else {
315
- startPolling();
316
- }
317
380
  };
318
381
 
319
- const startPolling = () => {
320
- if (timer) {
321
- return;
382
+ // The declared dependencies are compared with node_modules only when
383
+ // something can have changed for them: the project package.json was edited,
384
+ // or a problem is known and an install may be fixing it. Comparing them at
385
+ // every tick would catch installs halfway and report a package as missing
386
+ // while it is being rewritten.
387
+ const check = ({ declared }) => {
388
+ const installMap = new Map();
389
+ checkServedVersions(installMap);
390
+ if (declared || problemMap.size > 0) {
391
+ checkDeclared(installMap);
322
392
  }
323
- timer = setInterval(check, pollInterval);
324
- timer.unref();
325
- };
326
- const stopPolling = () => {
327
- if (!timer) {
328
- return;
393
+ for (const install of installMap.values()) {
394
+ onInstalled(install);
329
395
  }
330
- clearInterval(timer);
331
- timer = null;
332
396
  };
333
397
 
334
398
  const unwatchPackageJson = registerFileLifecycle(
335
399
  new URL("package.json", packageDirectory.url),
336
400
  {
337
- added: check,
338
- updated: check,
401
+ added: () => check({ declared: true }),
402
+ updated: () => check({ declared: true }),
339
403
  keepProcessAlive: false,
340
404
  },
341
405
  );
342
- check();
406
+ check({ declared: true });
407
+ const timer = setInterval(() => {
408
+ check({ declared: false });
409
+ }, pollInterval);
410
+ timer.unref();
343
411
 
344
412
  watcher.stop = () => {
345
- stopPolling();
413
+ clearInterval(timer);
346
414
  unwatchPackageJson();
347
415
  };
348
416
  return watcher;
349
417
  };
350
418
 
419
+ // The url graph keeps the content of every package.json it resolved a bare
420
+ // specifier against, and that content holds the version the page received.
421
+ // It is parsed once per content: the same string is seen at every tick.
422
+ const servedVersionCache = new WeakMap();
423
+ const readServedVersion = (urlInfo) => {
424
+ const { url, content } = urlInfo;
425
+ if (content === undefined) {
426
+ return null;
427
+ }
428
+ if (!url.startsWith("file:") || !url.endsWith("/package.json")) {
429
+ return null;
430
+ }
431
+ const nodeModulesIndex = url.lastIndexOf("/node_modules/");
432
+ if (nodeModulesIndex === -1) {
433
+ return null;
434
+ }
435
+ // dereferenced url infos stay in the graph; nothing imports this package anymore
436
+ if (urlInfo.referenceFromOthersSet.size === 0) {
437
+ return null;
438
+ }
439
+ const fromCache = servedVersionCache.get(urlInfo);
440
+ if (fromCache && fromCache.content === content) {
441
+ return fromCache;
442
+ }
443
+ let packageJson;
444
+ try {
445
+ packageJson = JSON.parse(content);
446
+ } catch {
447
+ return null;
448
+ }
449
+ const served = {
450
+ content,
451
+ directoryUrl: new URL("./", url).href,
452
+ packageName: packageNameFromSpecifier(
453
+ url.slice(nodeModulesIndex + "/node_modules/".length),
454
+ ),
455
+ version: packageJson.version,
456
+ };
457
+ servedVersionCache.set(urlInfo, served);
458
+ return served;
459
+ };
460
+
461
+ // an install in progress can be caught halfway, with a package.json not written yet
462
+ const readInstalledPackageJson = (packageDirectory, directoryUrl) => {
463
+ try {
464
+ return packageDirectory.read(directoryUrl);
465
+ } catch {
466
+ return null;
467
+ }
468
+ };
469
+
351
470
  const jsenvCoreDirectoryUrl = new URL("../", import.meta.url);
352
471
 
353
472
  const createPackageDirectory = ({
@@ -12584,6 +12703,7 @@ const startDevServer = async ({
12584
12703
  };
12585
12704
  const dependencyProblemEventEmitter = createEventEmitter();
12586
12705
  const dependencyWatcher = watchDependencies(packageDirectory, {
12706
+ getKitchens: () => kitchenCache.values(),
12587
12707
  onChange: (problems) => {
12588
12708
  dependencyProblemEventEmitter.emit(problems);
12589
12709
  },
@@ -12605,11 +12725,30 @@ const startDevServer = async ({
12605
12725
  logger.info(message);
12606
12726
  }
12607
12727
  },
12608
- onInstalled: ({ packageName, declaredVersion, severity }) => {
12609
- logger.info(`"${packageName}@${declaredVersion}" is now installed`);
12728
+ onInstalled: ({
12729
+ packageName,
12730
+ installedVersion,
12731
+ servedVersion,
12732
+ declaredVersion,
12733
+ severity,
12734
+ }) => {
12735
+ if (servedVersion) {
12736
+ logger.info(
12737
+ `"${packageName}@${installedVersion}" is now installed, the page runs ${servedVersion}`,
12738
+ );
12739
+ } else {
12740
+ logger.info(`"${packageName}@${declaredVersion}" is now installed`);
12741
+ }
12610
12742
  if (severity !== "warning") {
12611
12743
  return;
12612
12744
  }
12745
+ if (servedVersion) {
12746
+ reloadRequestEventEmitter.emit({
12747
+ cause: `${packageName} ${servedVersion} -> ${installedVersion} installed`,
12748
+ reason: `a dependency the page runs changed version in node_modules`,
12749
+ });
12750
+ return;
12751
+ }
12613
12752
  reloadRequestEventEmitter.emit({
12614
12753
  cause: `${packageName}@${declaredVersion} installed`,
12615
12754
  reason: `a dependency became available in node_modules`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.5.21",
3
+ "version": "41.5.22",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -79,7 +79,7 @@
79
79
  "@jsenv/plugin-minification": "1.7.22",
80
80
  "@jsenv/plugin-supervisor": "1.8.24",
81
81
  "@jsenv/plugin-transpilation": "1.7.9",
82
- "@jsenv/server": "17.6.4",
82
+ "@jsenv/server": "17.6.5",
83
83
  "@jsenv/sourcemap": "1.4.9",
84
84
  "react-table": "7.8.0"
85
85
  },
@@ -229,6 +229,7 @@ export const startDevServer = async ({
229
229
  };
230
230
  const dependencyProblemEventEmitter = createEventEmitter();
231
231
  const dependencyWatcher = watchDependencies(packageDirectory, {
232
+ getKitchens: () => kitchenCache.values(),
232
233
  onChange: (problems) => {
233
234
  dependencyProblemEventEmitter.emit(problems);
234
235
  },
@@ -250,11 +251,30 @@ export const startDevServer = async ({
250
251
  logger.info(message);
251
252
  }
252
253
  },
253
- onInstalled: ({ packageName, declaredVersion, severity }) => {
254
- logger.info(`"${packageName}@${declaredVersion}" is now installed`);
254
+ onInstalled: ({
255
+ packageName,
256
+ installedVersion,
257
+ servedVersion,
258
+ declaredVersion,
259
+ severity,
260
+ }) => {
261
+ if (servedVersion) {
262
+ logger.info(
263
+ `"${packageName}@${installedVersion}" is now installed, the page runs ${servedVersion}`,
264
+ );
265
+ } else {
266
+ logger.info(`"${packageName}@${declaredVersion}" is now installed`);
267
+ }
255
268
  if (severity !== "warning") {
256
269
  return;
257
270
  }
271
+ if (servedVersion) {
272
+ reloadRequestEventEmitter.emit({
273
+ cause: `${packageName} ${servedVersion} -> ${installedVersion} installed`,
274
+ reason: `a dependency the page runs changed version in node_modules`,
275
+ });
276
+ return;
277
+ }
258
278
  reloadRequestEventEmitter.emit({
259
279
  cause: `${packageName}@${declaredVersion} installed`,
260
280
  reason: `a dependency became available in node_modules`,
@@ -1,26 +1,47 @@
1
1
  /*
2
- * Detects when "npm install" makes a missing or outdated dependency match what
3
- * the project package.json declares, so the browser can be reloaded at that
4
- * moment.
2
+ * Detects the moment "npm install" changes what the running pages get from
3
+ * node_modules, so the browser can be reloaded at that moment:
4
+ *
5
+ * - a declared dependency stops being missing or outdated: what package.json
6
+ * asks for is there;
7
+ * - a package the pages were served changes version: the "?v=" baked into the
8
+ * urls they evaluated names a version node_modules no longer holds. Left
9
+ * alone, the next hot reload of a file importing that package resolves it to
10
+ * the new version and the page evaluates a second copy of the package next
11
+ * to the first, two module scopes for something meant to exist once.
5
12
  *
6
13
  * node_modules is deliberately not watched: it is far too big, and an install
7
14
  * rewrites, dedupes and moves package directories around, so a watcher placed
8
- * on one of them is unreliable. Instead the few packages known to be missing or
9
- * outdated are polled, which costs a couple of readFileSync and stops as soon as
10
- * they are all installed. The project package.json is watched though: it is a
11
- * single file, and editing it is what puts a dependency out of date in the first
12
- * place.
15
+ * on one of them is unreliable. Instead a few package.json files are polled,
16
+ * at the cost of a stat each: the packages known to be missing or outdated,
17
+ * and the packages the pages were served, whose package.json the url graph
18
+ * keeps (that is where "?v=" comes from). The project package.json is watched
19
+ * though: it is a single file, and editing it is what puts a dependency out of
20
+ * date in the first place.
21
+ *
22
+ * Both detections run in the same tick so that an install fixing an outdated
23
+ * package the page runs is reported once: two reports would be two reloads,
24
+ * the second one landing on the page that just came back.
13
25
  */
14
26
 
15
27
  import { registerFileLifecycle } from "@jsenv/filesystem";
16
28
 
17
- import { readDependencyStatuses } from "../kitchen/package_dependencies.js";
29
+ import {
30
+ packageNameFromSpecifier,
31
+ readDependencyStatuses,
32
+ } from "../kitchen/package_dependencies.js";
18
33
 
19
34
  const POLL_INTERVAL = 500;
20
35
 
21
36
  export const watchDependencies = (
22
37
  packageDirectory,
23
- { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
38
+ {
39
+ getKitchens = () => [],
40
+ onProblem,
41
+ onInstalled,
42
+ onChange,
43
+ pollInterval = POLL_INTERVAL,
44
+ },
24
45
  ) => {
25
46
  let problemMap = new Map();
26
47
  // every path given to the browser is relative to the package directory, the
@@ -35,11 +56,49 @@ export const watchDependencies = (
35
56
  if (!packageDirectory.url) {
36
57
  return watcher;
37
58
  }
38
- let timer = null;
39
59
 
40
- const check = () => {
60
+ const checkServedVersions = (installMap) => {
61
+ for (const kitchen of getKitchens()) {
62
+ for (const urlInfo of kitchen.graph.urlInfoMap.values()) {
63
+ const served = readServedVersion(urlInfo);
64
+ if (!served) {
65
+ continue;
66
+ }
67
+ const installedPackageJson = readInstalledPackageJson(
68
+ packageDirectory,
69
+ served.directoryUrl,
70
+ );
71
+ if (!installedPackageJson) {
72
+ // the package is being rewritten, what it becomes is known once it is back
73
+ continue;
74
+ }
75
+ const installedVersion = installedPackageJson.version;
76
+ if (installedVersion === served.version) {
77
+ continue;
78
+ }
79
+ // the graph learns the file moved, as it would from a watcher: the
80
+ // files importing this package are cooked again with the new "?v="
81
+ // when the page comes back
82
+ urlInfo.onModified();
83
+ if (installMap.has(served.packageName)) {
84
+ continue;
85
+ }
86
+ installMap.set(served.packageName, {
87
+ packageName: served.packageName,
88
+ installedVersion,
89
+ servedVersion: served.version,
90
+ declaredVersion: null,
91
+ severity: "warning",
92
+ });
93
+ }
94
+ }
95
+ };
96
+
97
+ const checkDeclared = (installMap) => {
98
+ const statusMap = new Map();
41
99
  const nextProblemMap = new Map();
42
100
  for (const status of readDependencyStatuses(packageDirectory)) {
101
+ statusMap.set(status.packageName, status);
43
102
  if (status.state === "missing" || status.state === "outdated") {
44
103
  nextProblemMap.set(status.packageName, status);
45
104
  }
@@ -55,9 +114,21 @@ export const watchDependencies = (
55
114
  }
56
115
  }
57
116
  for (const [packageName, previousStatus] of problemMap) {
58
- if (!nextProblemMap.has(packageName)) {
59
- onInstalled(previousStatus);
117
+ if (nextProblemMap.has(packageName)) {
118
+ continue;
60
119
  }
120
+ const install = installMap.get(packageName);
121
+ if (install) {
122
+ install.declaredVersion = previousStatus.declaredVersion;
123
+ continue;
124
+ }
125
+ installMap.set(packageName, {
126
+ packageName,
127
+ installedVersion: statusMap.get(packageName).installedVersion,
128
+ servedVersion: null,
129
+ declaredVersion: previousStatus.declaredVersion,
130
+ severity: previousStatus.severity,
131
+ });
61
132
  }
62
133
  const changed =
63
134
  nextProblemMap.size !== problemMap.size ||
@@ -75,41 +146,92 @@ export const watchDependencies = (
75
146
  if (changed) {
76
147
  onChange(watcher.getProblems());
77
148
  }
78
- if (problemMap.size === 0) {
79
- stopPolling();
80
- } else {
81
- startPolling();
82
- }
83
149
  };
84
150
 
85
- const startPolling = () => {
86
- if (timer) {
87
- return;
151
+ // The declared dependencies are compared with node_modules only when
152
+ // something can have changed for them: the project package.json was edited,
153
+ // or a problem is known and an install may be fixing it. Comparing them at
154
+ // every tick would catch installs halfway and report a package as missing
155
+ // while it is being rewritten.
156
+ const check = ({ declared }) => {
157
+ const installMap = new Map();
158
+ checkServedVersions(installMap);
159
+ if (declared || problemMap.size > 0) {
160
+ checkDeclared(installMap);
88
161
  }
89
- timer = setInterval(check, pollInterval);
90
- timer.unref();
91
- };
92
- const stopPolling = () => {
93
- if (!timer) {
94
- return;
162
+ for (const install of installMap.values()) {
163
+ onInstalled(install);
95
164
  }
96
- clearInterval(timer);
97
- timer = null;
98
165
  };
99
166
 
100
167
  const unwatchPackageJson = registerFileLifecycle(
101
168
  new URL("package.json", packageDirectory.url),
102
169
  {
103
- added: check,
104
- updated: check,
170
+ added: () => check({ declared: true }),
171
+ updated: () => check({ declared: true }),
105
172
  keepProcessAlive: false,
106
173
  },
107
174
  );
108
- check();
175
+ check({ declared: true });
176
+ const timer = setInterval(() => {
177
+ check({ declared: false });
178
+ }, pollInterval);
179
+ timer.unref();
109
180
 
110
181
  watcher.stop = () => {
111
- stopPolling();
182
+ clearInterval(timer);
112
183
  unwatchPackageJson();
113
184
  };
114
185
  return watcher;
115
186
  };
187
+
188
+ // The url graph keeps the content of every package.json it resolved a bare
189
+ // specifier against, and that content holds the version the page received.
190
+ // It is parsed once per content: the same string is seen at every tick.
191
+ const servedVersionCache = new WeakMap();
192
+ const readServedVersion = (urlInfo) => {
193
+ const { url, content } = urlInfo;
194
+ if (content === undefined) {
195
+ return null;
196
+ }
197
+ if (!url.startsWith("file:") || !url.endsWith("/package.json")) {
198
+ return null;
199
+ }
200
+ const nodeModulesIndex = url.lastIndexOf("/node_modules/");
201
+ if (nodeModulesIndex === -1) {
202
+ return null;
203
+ }
204
+ // dereferenced url infos stay in the graph; nothing imports this package anymore
205
+ if (urlInfo.referenceFromOthersSet.size === 0) {
206
+ return null;
207
+ }
208
+ const fromCache = servedVersionCache.get(urlInfo);
209
+ if (fromCache && fromCache.content === content) {
210
+ return fromCache;
211
+ }
212
+ let packageJson;
213
+ try {
214
+ packageJson = JSON.parse(content);
215
+ } catch {
216
+ return null;
217
+ }
218
+ const served = {
219
+ content,
220
+ directoryUrl: new URL("./", url).href,
221
+ packageName: packageNameFromSpecifier(
222
+ url.slice(nodeModulesIndex + "/node_modules/".length),
223
+ ),
224
+ version: packageJson.version,
225
+ };
226
+ servedVersionCache.set(urlInfo, served);
227
+ return served;
228
+ };
229
+
230
+ // an install in progress can be caught halfway, with a package.json not written yet
231
+ const readInstalledPackageJson = (packageDirectory, directoryUrl) => {
232
+ try {
233
+ return packageDirectory.read(directoryUrl);
234
+ } catch {
235
+ return null;
236
+ }
237
+ };
@@ -4,7 +4,7 @@ events_manager: {
4
4
  const callbacksMap = new Map();
5
5
  let cleanup;
6
6
  const addCallbacks = (namedCallbacks) => {
7
- let callbacksMapSize = callbacksMap.size;
7
+ const sizeBeforeAdd = callbacksMap.size;
8
8
  Object.keys(namedCallbacks).forEach((eventName) => {
9
9
  const callback = namedCallbacks[eventName];
10
10
  const existingCallbacks = callbacksMap.get(eventName);
@@ -17,7 +17,10 @@ events_manager: {
17
17
  }
18
18
  callbacks.push(callback);
19
19
  });
20
- if (effect && callbacksMapSize === 0 && callbacksMapSize.size > 0) {
20
+ // the effect owns whatever the callbacks need to exist (the connection),
21
+ // so it runs on the edge into "someone is listening" and its cleanup runs
22
+ // on the edge back out of it
23
+ if (sizeBeforeAdd === 0 && callbacksMap.size > 0) {
21
24
  cleanup = effect();
22
25
  }
23
26
 
@@ -25,7 +28,7 @@ events_manager: {
25
28
  return () => {
26
29
  if (removed) return;
27
30
  removed = true;
28
- callbacksMapSize = callbacksMap.size;
31
+ const sizeBeforeRemove = callbacksMap.size;
29
32
  Object.keys(namedCallbacks).forEach((eventName) => {
30
33
  const callback = namedCallbacks[eventName];
31
34
  const callbacks = callbacksMap.get(eventName);
@@ -40,12 +43,7 @@ events_manager: {
40
43
  }
41
44
  });
42
45
  namedCallbacks = null; // allow garbage collect
43
- if (
44
- cleanup &&
45
- typeof cleanup === "function" &&
46
- callbacksMapSize > 0 &&
47
- callbacksMapSize.size === 0
48
- ) {
46
+ if (cleanup && sizeBeforeRemove > 0 && callbacksMap.size === 0) {
49
47
  cleanup();
50
48
  cleanup = null;
51
49
  }
@@ -86,9 +84,14 @@ connection_manager: {
86
84
  CLOSED: "closed",
87
85
  };
88
86
 
87
+ // The peer is a dev server being restarted, not a flaky network: there is no
88
+ // attempt count or time budget past which giving up would be correct. Attempts
89
+ // space out to retryAfterMax and never stop, and the moments where the server
90
+ // is most likely to be back already (tab visible, window focused, network
91
+ // back) skip the wait instead of leaving a detached page on screen.
89
92
  createConnectionManager = (
90
93
  attemptConnection,
91
- { logs, retry, retryAfter, retryMaxAttempt, retryAllocatedMs },
94
+ { logs, retry, retryAfter, retryAfterMax },
92
95
  ) => {
93
96
  const readyState = {
94
97
  value: READY_STATES.CLOSED,
@@ -103,6 +106,7 @@ connection_manager: {
103
106
  };
104
107
 
105
108
  let _disconnect = () => {};
109
+ let _retryNow = () => {};
106
110
  const connect = () => {
107
111
  if (
108
112
  readyState.value === READY_STATES.CONNECTING ||
@@ -111,11 +115,11 @@ connection_manager: {
111
115
  return;
112
116
  }
113
117
 
114
- let retryCount = 0;
115
- let msSpent = 0;
118
+ let retryDelay = retryAfter;
119
+ let retryTimeout = null;
116
120
  const attempt = () => {
121
+ retryTimeout = null;
117
122
  readyState.goTo(READY_STATES.CONNECTING);
118
- let timeout;
119
123
  const cancelAttempt = attemptConnection({
120
124
  onClosed: () => {
121
125
  if (!retry) {
@@ -125,24 +129,6 @@ connection_manager: {
125
129
  }
126
130
  return;
127
131
  }
128
- if (retryCount > retryMaxAttempt) {
129
- readyState.goTo(READY_STATES.CLOSED);
130
- if (logs) {
131
- console.info(
132
- `[jsenv] could not connect to server after ${retryMaxAttempt} attempt`,
133
- );
134
- }
135
- return;
136
- }
137
- if (retryAllocatedMs && msSpent > retryAllocatedMs) {
138
- readyState.goTo(READY_STATES.CLOSED);
139
- if (logs) {
140
- console.info(
141
- `[jsenv] could not connect to server in less than ${retryAllocatedMs}ms`,
142
- );
143
- }
144
- return;
145
- }
146
132
  // if closed while open -> connection lost
147
133
  // otherwise it's the attempt to connect for the first time
148
134
  // or to reconnect
@@ -153,13 +139,13 @@ connection_manager: {
153
139
  );
154
140
  }
155
141
  }
156
- retryCount++;
157
- timeout = setTimeout(() => {
158
- msSpent += retryAfter;
159
- attempt();
160
- }, retryAfter);
142
+ retryTimeout = setTimeout(attempt, retryDelay);
143
+ const nextRetryDelay = retryDelay * 2;
144
+ retryDelay =
145
+ nextRetryDelay > retryAfterMax ? retryAfterMax : nextRetryDelay;
161
146
  },
162
147
  onOpen: () => {
148
+ retryDelay = retryAfter;
163
149
  readyState.goTo(READY_STATES.OPEN);
164
150
  if (logs) {
165
151
  // console.info(`[jsenv] connected to server`);
@@ -168,10 +154,19 @@ connection_manager: {
168
154
  });
169
155
  _disconnect = () => {
170
156
  cancelAttempt();
171
- clearTimeout(timeout);
157
+ clearTimeout(retryTimeout);
158
+ retryTimeout = null;
172
159
  readyState.goTo(READY_STATES.CLOSED);
173
160
  };
174
161
  };
162
+ _retryNow = () => {
163
+ if (retryTimeout === null) {
164
+ return;
165
+ }
166
+ clearTimeout(retryTimeout);
167
+ retryDelay = retryAfter;
168
+ attempt();
169
+ };
175
170
  attempt();
176
171
  };
177
172
 
@@ -198,6 +193,9 @@ connection_manager: {
198
193
  _disconnect();
199
194
  }
200
195
  });
196
+ const removeServerMightBeBackListeners = listenServerMightBeBack(() => {
197
+ _retryNow();
198
+ });
201
199
 
202
200
  return {
203
201
  readyState,
@@ -205,11 +203,31 @@ connection_manager: {
205
203
  disconnect,
206
204
  destroy: () => {
207
205
  removePageUnloadListener();
206
+ removeServerMightBeBackListeners();
208
207
  disconnect();
209
208
  },
210
209
  };
211
210
  };
212
211
 
212
+ const listenServerMightBeBack = (callback) => {
213
+ const removeOnlineListener = listenEvent(window, "online", callback);
214
+ const removeFocusListener = listenEvent(window, "focus", callback);
215
+ const removeVisibilityChangeListener = listenEvent(
216
+ document,
217
+ "visibilitychange",
218
+ () => {
219
+ if (document.visibilityState === "visible") {
220
+ callback();
221
+ }
222
+ },
223
+ );
224
+ return () => {
225
+ removeOnlineListener();
226
+ removeFocusListener();
227
+ removeVisibilityChangeListener();
228
+ };
229
+ };
230
+
213
231
  // const listenPageMightFreeze = (callback) => {
214
232
  // const removePageHideListener = listenEvent(window, "pagehide", (pageHideEvent) => {
215
233
  // if (pageHideEvent.persisted === true) {
@@ -275,8 +293,7 @@ connection_using_websocket: {
275
293
  useEventsToManageConnection = true,
276
294
  retry = false,
277
295
  retryAfter = 1000,
278
- retryMaxAttempt = Infinity,
279
- retryAllocatedMs = Infinity,
296
+ retryAfterMax = 5000,
280
297
  } = {},
281
298
  ) => {
282
299
  const connectionManager = createConnectionManager(
@@ -319,7 +336,7 @@ connection_using_websocket: {
319
336
  }
320
337
  };
321
338
  },
322
- { logs, retry, retryAfter, retryMaxAttempt, retryAllocatedMs },
339
+ { logs, retry, retryAfter, retryAfterMax },
323
340
  );
324
341
  const eventsManager = createEventsManager({
325
342
  effect: () => {
@@ -443,7 +460,6 @@ const serverEventsInterface = {
443
460
  const websocketConnection = createWebSocketConnection(websocketUrl, {
444
461
  logs,
445
462
  retry: true,
446
- retryAllocatedMs: 10_000,
447
463
  });
448
464
 
449
465
  const { readyState, connect, disconnect, listenEvents } =