@jsenv/core 41.5.21 → 41.5.23

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.
@@ -10128,17 +10128,16 @@ const jsenvPluginAutoreloadOnServerRestart = () => {
10128
10128
  };
10129
10129
  };
10130
10130
 
10131
- /**
10132
- * Lorsqu'on bundle un package ayant pas le field sideEffects
10133
- * alors on fini potentiellement par dire
10134
- * sideEffect: false
10135
- * sur le package racine alors qu'on en sait rien
10136
- * on pourrait mettre un package.json dans dist dans ce cas
10137
- * qui ne déclare pas le field side effect afin
10138
- * d'override le package.json du project qui lui dit qu'il ny en a pas
10139
- *
10140
- * On part du principe pour le moment que c'est la respo du package racine de déclarer cela
10131
+ /*
10132
+ * Tells the bundler which modules may be dropped when nothing imports a value
10133
+ * from them, from the "sideEffects" field of the package.json closest to each
10134
+ * file — a dependency's own declaration for its files, the project's for its
10135
+ * own. A package that declares nothing keeps every module (the bundler's own
10136
+ * default), so an undeclared root only loses precision, never code.
10141
10137
  *
10138
+ * Whether the project's package.json is written back with the side-effect
10139
+ * files this build produced is decided in build.js, and only for a project that
10140
+ * already declares the field; it is not this plugin's concern.
10142
10141
  */
10143
10142
 
10144
10143
 
@@ -10146,15 +10145,6 @@ const jsenvPluginPackageSideEffects = ({ packageDirectory }) => {
10146
10145
  if (!packageDirectory.url) {
10147
10146
  return [];
10148
10147
  }
10149
- const packageJson = packageDirectory.read(packageDirectory.url);
10150
- if (!packageJson) {
10151
- return [];
10152
- }
10153
- const { sideEffects } = packageJson;
10154
- if (sideEffects !== false && !Array.isArray(sideEffects)) {
10155
- return [];
10156
- }
10157
-
10158
10148
  const packageSideEffectsCacheMap = new Map();
10159
10149
  const readSideEffectInfoFromClosestPackage = (urlInfo) => {
10160
10150
  const closestPackageDirectoryUrl = urlInfo.packageDirectoryUrl;
@@ -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;
294
350
  }
351
+ const install = installMap.get(packageName);
352
+ if (install) {
353
+ install.declaredVersion = previousStatus.declaredVersion;
354
+ continue;
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 = ({
@@ -7909,17 +8028,16 @@ const jsenvPluginAutoreloadOnServerRestart = () => {
7909
8028
  };
7910
8029
  };
7911
8030
 
7912
- /**
7913
- * Lorsqu'on bundle un package ayant pas le field sideEffects
7914
- * alors on fini potentiellement par dire
7915
- * sideEffect: false
7916
- * sur le package racine alors qu'on en sait rien
7917
- * on pourrait mettre un package.json dans dist dans ce cas
7918
- * qui ne déclare pas le field side effect afin
7919
- * d'override le package.json du project qui lui dit qu'il ny en a pas
7920
- *
7921
- * On part du principe pour le moment que c'est la respo du package racine de déclarer cela
8031
+ /*
8032
+ * Tells the bundler which modules may be dropped when nothing imports a value
8033
+ * from them, from the "sideEffects" field of the package.json closest to each
8034
+ * file — a dependency's own declaration for its files, the project's for its
8035
+ * own. A package that declares nothing keeps every module (the bundler's own
8036
+ * default), so an undeclared root only loses precision, never code.
7922
8037
  *
8038
+ * Whether the project's package.json is written back with the side-effect
8039
+ * files this build produced is decided in build.js, and only for a project that
8040
+ * already declares the field; it is not this plugin's concern.
7923
8041
  */
7924
8042
 
7925
8043
 
@@ -7927,15 +8045,6 @@ const jsenvPluginPackageSideEffects = ({ packageDirectory }) => {
7927
8045
  if (!packageDirectory.url) {
7928
8046
  return [];
7929
8047
  }
7930
- const packageJson = packageDirectory.read(packageDirectory.url);
7931
- if (!packageJson) {
7932
- return [];
7933
- }
7934
- const { sideEffects } = packageJson;
7935
- if (sideEffects !== false && !Array.isArray(sideEffects)) {
7936
- return [];
7937
- }
7938
-
7939
8048
  const packageSideEffectsCacheMap = new Map();
7940
8049
  const readSideEffectInfoFromClosestPackage = (urlInfo) => {
7941
8050
  const closestPackageDirectoryUrl = urlInfo.packageDirectoryUrl;
@@ -12584,6 +12693,7 @@ const startDevServer = async ({
12584
12693
  };
12585
12694
  const dependencyProblemEventEmitter = createEventEmitter();
12586
12695
  const dependencyWatcher = watchDependencies(packageDirectory, {
12696
+ getKitchens: () => kitchenCache.values(),
12587
12697
  onChange: (problems) => {
12588
12698
  dependencyProblemEventEmitter.emit(problems);
12589
12699
  },
@@ -12605,11 +12715,30 @@ const startDevServer = async ({
12605
12715
  logger.info(message);
12606
12716
  }
12607
12717
  },
12608
- onInstalled: ({ packageName, declaredVersion, severity }) => {
12609
- logger.info(`"${packageName}@${declaredVersion}" is now installed`);
12718
+ onInstalled: ({
12719
+ packageName,
12720
+ installedVersion,
12721
+ servedVersion,
12722
+ declaredVersion,
12723
+ severity,
12724
+ }) => {
12725
+ if (servedVersion) {
12726
+ logger.info(
12727
+ `"${packageName}@${installedVersion}" is now installed, the page runs ${servedVersion}`,
12728
+ );
12729
+ } else {
12730
+ logger.info(`"${packageName}@${declaredVersion}" is now installed`);
12731
+ }
12610
12732
  if (severity !== "warning") {
12611
12733
  return;
12612
12734
  }
12735
+ if (servedVersion) {
12736
+ reloadRequestEventEmitter.emit({
12737
+ cause: `${packageName} ${servedVersion} -> ${installedVersion} installed`,
12738
+ reason: `a dependency the page runs changed version in node_modules`,
12739
+ });
12740
+ return;
12741
+ }
12613
12742
  reloadRequestEventEmitter.emit({
12614
12743
  cause: `${packageName}@${declaredVersion} installed`,
12615
12744
  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.23",
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
+ };
@@ -1,14 +1,13 @@
1
- /**
2
- * Lorsqu'on bundle un package ayant pas le field sideEffects
3
- * alors on fini potentiellement par dire
4
- * sideEffect: false
5
- * sur le package racine alors qu'on en sait rien
6
- * on pourrait mettre un package.json dans dist dans ce cas
7
- * qui ne déclare pas le field side effect afin
8
- * d'override le package.json du project qui lui dit qu'il ny en a pas
9
- *
10
- * On part du principe pour le moment que c'est la respo du package racine de déclarer cela
1
+ /*
2
+ * Tells the bundler which modules may be dropped when nothing imports a value
3
+ * from them, from the "sideEffects" field of the package.json closest to each
4
+ * file — a dependency's own declaration for its files, the project's for its
5
+ * own. A package that declares nothing keeps every module (the bundler's own
6
+ * default), so an undeclared root only loses precision, never code.
11
7
  *
8
+ * Whether the project's package.json is written back with the side-effect
9
+ * files this build produced is decided in build.js, and only for a project that
10
+ * already declares the field; it is not this plugin's concern.
12
11
  */
13
12
 
14
13
  import { isSpecifierForNodeBuiltin } from "@jsenv/node-esm-resolution/src/node_builtin_specifiers.js";
@@ -18,15 +17,6 @@ export const jsenvPluginPackageSideEffects = ({ packageDirectory }) => {
18
17
  if (!packageDirectory.url) {
19
18
  return [];
20
19
  }
21
- const packageJson = packageDirectory.read(packageDirectory.url);
22
- if (!packageJson) {
23
- return [];
24
- }
25
- const { sideEffects } = packageJson;
26
- if (sideEffects !== false && !Array.isArray(sideEffects)) {
27
- return [];
28
- }
29
-
30
20
  const packageSideEffectsCacheMap = new Map();
31
21
  const readSideEffectInfoFromClosestPackage = (urlInfo) => {
32
22
  const closestPackageDirectoryUrl = urlInfo.packageDirectoryUrl;
@@ -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 } =