@jsenv/core 41.4.10 → 41.4.11

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.
@@ -196,12 +196,21 @@ const createStatus = (
196
196
  declaredVersion,
197
197
  declaredBy,
198
198
  installedVersion: null,
199
+ // the file telling this dependency apart; it is what an install rewrites and
200
+ // what the dev server looks at to know the dependency became the declared one
201
+ watchedPath: null,
199
202
  state: "missing",
200
203
  };
201
204
  const installedDirectoryUrl = findInstalledDirectoryUrl(
202
205
  declaringDirectoryUrl,
203
206
  packageName,
204
207
  );
208
+ status.watchedPath = watchedPathFromDirectoryUrl(
209
+ packageDirectory,
210
+ // not installed yet: name the place where it is expected to appear
211
+ installedDirectoryUrl ||
212
+ `${declaringDirectoryUrl}node_modules/${packageName}/`,
213
+ );
205
214
  if (!installedDirectoryUrl) {
206
215
  return status;
207
216
  }
@@ -220,6 +229,14 @@ const createStatus = (
220
229
  return status;
221
230
  };
222
231
 
232
+ const watchedPathFromDirectoryUrl = (packageDirectory, directoryUrl) => {
233
+ const packageJsonUrl = `${directoryUrl}package.json`;
234
+ if (!packageDirectory.url) {
235
+ return packageJsonUrl;
236
+ }
237
+ return urlToRelativeUrl(packageJsonUrl, packageDirectory.url);
238
+ };
239
+
223
240
  const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
224
241
  let directoryUrl = declaringDirectoryUrl;
225
242
  while (directoryUrl) {
@@ -9298,6 +9315,7 @@ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
9298
9315
  const jsenvPluginDependencyStatus = ({
9299
9316
  dependencyProblemEventEmitter,
9300
9317
  getDependencyProblems,
9318
+ getDependencyWatchInfo = () => ({}),
9301
9319
  }) => {
9302
9320
  return {
9303
9321
  name: "jsenv:dependency_status",
@@ -9334,7 +9352,10 @@ const jsenvPluginDependencyStatus = ({
9334
9352
  src: clientReference.generatedSpecifier,
9335
9353
  initCall: {
9336
9354
  callee: "initDependencyStatus",
9337
- params: { problems: getDependencyProblems() },
9355
+ params: {
9356
+ problems: getDependencyProblems(),
9357
+ watchInfo: getDependencyWatchInfo(),
9358
+ },
9338
9359
  },
9339
9360
  pluginName: "jsenv:dependency_status",
9340
9361
  });
@@ -3,11 +3,22 @@
3
3
  *
4
4
  * A missing dependency is not handled here: it makes the import fail, so the
5
5
  * error overlay already says it, with more precision (which import, where).
6
+ *
7
+ * The overlay does not only describe the problem, it shows the dev server
8
+ * watching for the fix: the paths being looked at are named and kept alive on
9
+ * screen, so waiting for "npm install" feels like waiting for something that is
10
+ * actually going to happen.
6
11
  */
7
12
 
8
13
  let removeOverlay = () => {};
14
+ let watchInfoFromServer = {};
15
+ // packages listed as outdated at some point, and the path watched for each;
16
+ // what leaves this map has been installed while the overlay was open, which is
17
+ // worth showing as such
18
+ const outdatedPathMap = new Map();
9
19
 
10
- const initDependencyStatus = ({ problems }) => {
20
+ const initDependencyStatus = ({ problems, watchInfo }) => {
21
+ watchInfoFromServer = watchInfo || {};
11
22
  render(problems);
12
23
  // without the server events channel the page only knows the state it was
13
24
  // served with, which is still better than nothing
@@ -24,9 +35,19 @@ const render = (problems) => {
24
35
  const outdatedList = problems.filter(({ state }) => state === "outdated");
25
36
  removeOverlay();
26
37
  removeOverlay = () => {};
38
+ const installedList = [];
39
+ for (const [packageName, watchedPath] of outdatedPathMap) {
40
+ if (!outdatedList.some((problem) => problem.packageName === packageName)) {
41
+ installedList.push({ packageName, watchedPath });
42
+ }
43
+ }
27
44
  if (outdatedList.length === 0) {
45
+ outdatedPathMap.clear();
28
46
  return;
29
47
  }
48
+ for (const { packageName, watchedPath } of outdatedList) {
49
+ outdatedPathMap.set(packageName, watchedPath);
50
+ }
30
51
  const supervisor = window.__supervisor__;
31
52
  if (!supervisor || !supervisor.reportWarning) {
32
53
  console.warn(summarize(outdatedList));
@@ -41,8 +62,8 @@ const render = (problems) => {
41
62
  details: [
42
63
  "The page is running with what is installed in node_modules, which is not what package.json asks for. It may work, but it is not the code the project expects.",
43
64
  "Run npm install to fix it. If an install is already running, there is nothing to do but wait.",
44
- "This page reloads on its own as soon as node_modules matches package.json.",
45
65
  ],
66
+ node: createWatchNode(outdatedList, installedList),
46
67
  });
47
68
  };
48
69
 
@@ -58,4 +79,214 @@ const summarize = (outdatedList) => {
58
79
  .join(", ")}`;
59
80
  };
60
81
 
82
+ const createWatchNode = (outdatedList, installedList) => {
83
+ const { packageJsonPath = "package.json", pollInterval } =
84
+ watchInfoFromServer;
85
+ const node = document.createElement("div");
86
+ node.className = "dependency_watch";
87
+ const rows = [];
88
+ rows.push(
89
+ createRow({
90
+ state: "watching",
91
+ path: packageJsonPath,
92
+ // a file watcher, not a poll: an edit here is what puts a dependency out
93
+ // of date in the first place
94
+ note: "on every change",
95
+ pollInterval,
96
+ }),
97
+ );
98
+ for (const { packageName, declaredVersion, watchedPath } of outdatedList) {
99
+ rows.push(
100
+ createRow({
101
+ state: "watching",
102
+ path: watchedPath || `node_modules/${packageName}/package.json`,
103
+ note: pollInterval
104
+ ? `waiting for ${declaredVersion}, read every ${pollInterval}ms`
105
+ : `waiting for ${declaredVersion}`,
106
+ pollInterval,
107
+ }),
108
+ );
109
+ }
110
+ for (const { packageName, watchedPath } of installedList) {
111
+ rows.push(
112
+ createRow({
113
+ state: "done",
114
+ path: watchedPath || `node_modules/${packageName}/package.json`,
115
+ note: "installed",
116
+ }),
117
+ );
118
+ }
119
+ node.innerHTML = `
120
+ <style>
121
+ ${watchCSS}
122
+ </style>
123
+ <div class="dependency_watch_head">
124
+ <span class="dependency_watch_live">
125
+ <span class="dependency_watch_live_dot"></span>watching
126
+ </span>
127
+ <span class="dependency_watch_head_text">
128
+ the files that will tell the install is over
129
+ </span>
130
+ </div>
131
+ <div class="dependency_watch_rows">
132
+ ${rows.join("\n ")}
133
+ </div>
134
+ <div class="dependency_watch_effect" data-copy-line>
135
+ As soon as node_modules matches package.json, this page reloads by itself.
136
+ </div>`;
137
+ return node;
138
+ };
139
+
140
+ const createRow = ({ state, path, note, pollInterval }) => {
141
+ const sweepStyle = pollInterval
142
+ ? ` style="animation-duration: ${pollInterval}ms"`
143
+ : "";
144
+ return `<div class="dependency_watch_row" data-state="${state}">
145
+ <span class="dependency_watch_row_icon"></span>
146
+ <code class="dependency_watch_row_path">${escapeHtml(path)}</code>
147
+ <span class="dependency_watch_row_note">${escapeHtml(note)}</span>
148
+ ${
149
+ state === "watching"
150
+ ? `<span class="dependency_watch_row_sweep"${sweepStyle}></span>`
151
+ : ""
152
+ }
153
+ </div>`;
154
+ };
155
+
156
+ const escapeHtml = (string) => {
157
+ return String(string)
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;")
162
+ .replace(/'/g, "&#039;");
163
+ };
164
+
165
+ const watchCSS = /* css */ `
166
+ .dependency_watch_head {
167
+ display: flex;
168
+ margin-bottom: 8px;
169
+ align-items: center;
170
+ gap: 8px;
171
+ }
172
+ .dependency_watch_live {
173
+ display: flex;
174
+ padding: 2px 8px;
175
+ align-items: center;
176
+ gap: 6px;
177
+ color: #ffab40;
178
+ font-size: 12px;
179
+ text-transform: uppercase;
180
+ letter-spacing: 0.08em;
181
+ border: 1px solid currentColor;
182
+ border-radius: 999px;
183
+ }
184
+ .dependency_watch_live_dot {
185
+ width: 7px;
186
+ height: 7px;
187
+ background: currentColor;
188
+ border-radius: 50%;
189
+ animation: dependency_watch_pulse 1.4s ease-in-out infinite;
190
+ }
191
+ @keyframes dependency_watch_pulse {
192
+ 0%,
193
+ 100% {
194
+ opacity: 0.25;
195
+ transform: scale(0.8);
196
+ }
197
+ 50% {
198
+ opacity: 1;
199
+ transform: scale(1.15);
200
+ }
201
+ }
202
+ .dependency_watch_head_text {
203
+ font-size: 13px;
204
+ }
205
+
206
+ .dependency_watch_rows {
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: 4px;
210
+ }
211
+ .dependency_watch_row {
212
+ position: relative;
213
+ display: flex;
214
+ padding: 6px 10px;
215
+ align-items: center;
216
+ gap: 8px;
217
+ font-size: 13px;
218
+ background: rgba(255, 255, 255, 0.04);
219
+ border: 1px solid #333;
220
+ border-radius: 4px;
221
+ overflow: hidden;
222
+ }
223
+ .dependency_watch_row_icon {
224
+ width: 8px;
225
+ height: 8px;
226
+ flex: none;
227
+ border: 1px solid #ffab40;
228
+ border-radius: 50%;
229
+ }
230
+ .dependency_watch_row[data-state="done"] .dependency_watch_row_icon {
231
+ background: #7fd67f;
232
+ border-color: #7fd67f;
233
+ }
234
+ .dependency_watch_row_path {
235
+ color: #eee;
236
+ text-overflow: ellipsis;
237
+ white-space: nowrap;
238
+ overflow: hidden;
239
+ }
240
+ .dependency_watch_row_note {
241
+ margin-left: auto;
242
+ padding-left: 8px;
243
+ font-size: 12px;
244
+ white-space: nowrap;
245
+ opacity: 0.8;
246
+ }
247
+ .dependency_watch_row[data-state="done"] .dependency_watch_row_note {
248
+ color: #7fd67f;
249
+ opacity: 1;
250
+ }
251
+ /* the sweep is the watching made visible: one pass per read */
252
+ .dependency_watch_row_sweep {
253
+ position: absolute;
254
+ top: 0;
255
+ left: 0;
256
+ width: 35%;
257
+ height: 100%;
258
+ background: linear-gradient(
259
+ 90deg,
260
+ transparent,
261
+ rgba(255, 171, 64, 0.16),
262
+ transparent
263
+ );
264
+ animation: dependency_watch_sweep 2s linear infinite;
265
+ pointer-events: none;
266
+ }
267
+ @keyframes dependency_watch_sweep {
268
+ from {
269
+ transform: translateX(-100%);
270
+ }
271
+ to {
272
+ transform: translateX(340%);
273
+ }
274
+ }
275
+
276
+ .dependency_watch_effect {
277
+ margin-top: 8px;
278
+ color: #ffab40;
279
+ font-size: 13px;
280
+ }
281
+
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .dependency_watch_live_dot {
284
+ animation: none;
285
+ }
286
+ .dependency_watch_row_sweep {
287
+ display: none;
288
+ }
289
+ }
290
+ `;
291
+
61
292
  export { initDependencyStatus };
@@ -1,6 +1,6 @@
1
1
  import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
2
  import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
3
- import { registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, urlToRelativeUrl, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, registerDirectoryLifecycle, asUrlWithoutSearch, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, urlToFilename, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, createDetailedMessage, stringifyUrlSite, injectQueryParamsIntoSpecifier, isSpecifierForNodeBuiltin, injectQueryParams, urlToFileSystemPath, writeFileSync, moveUrl, ensureWindowsDriveLetter, validateResponseIntegrity, setUrlFilename, getCallerPosition, asSpecifierWithoutSearch, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, createLogger, normalizeUrl, ANSI, RUNTIME_COMPAT, formatError, assertAndNormalizeDirectoryUrl, createTaskLog } from "./jsenv_core_packages.js";
3
+ import { urlToRelativeUrl, registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, registerDirectoryLifecycle, asUrlWithoutSearch, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, urlToFilename, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, createDetailedMessage, stringifyUrlSite, injectQueryParamsIntoSpecifier, isSpecifierForNodeBuiltin, injectQueryParams, urlToFileSystemPath, writeFileSync, moveUrl, ensureWindowsDriveLetter, validateResponseIntegrity, setUrlFilename, getCallerPosition, asSpecifierWithoutSearch, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, createLogger, normalizeUrl, ANSI, RUNTIME_COMPAT, formatError, assertAndNormalizeDirectoryUrl, createTaskLog } from "./jsenv_core_packages.js";
4
4
  import { createPluginsController } from "@jsenv/server/src/plugins_controller.js";
5
5
  import { parseHtml, injectJsenvScript, stringifyHtmlAst, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, applyBabelPlugins, analyzeLinkNode, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, generateUrlForInlineContent, parseJsWithAcorn } from "@jsenv/ast";
6
6
  import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
@@ -144,12 +144,21 @@ const createStatus = (
144
144
  declaredVersion,
145
145
  declaredBy,
146
146
  installedVersion: null,
147
+ // the file telling this dependency apart; it is what an install rewrites and
148
+ // what the dev server looks at to know the dependency became the declared one
149
+ watchedPath: null,
147
150
  state: "missing",
148
151
  };
149
152
  const installedDirectoryUrl = findInstalledDirectoryUrl(
150
153
  declaringDirectoryUrl,
151
154
  packageName,
152
155
  );
156
+ status.watchedPath = watchedPathFromDirectoryUrl(
157
+ packageDirectory,
158
+ // not installed yet: name the place where it is expected to appear
159
+ installedDirectoryUrl ||
160
+ `${declaringDirectoryUrl}node_modules/${packageName}/`,
161
+ );
153
162
  if (!installedDirectoryUrl) {
154
163
  return status;
155
164
  }
@@ -168,6 +177,14 @@ const createStatus = (
168
177
  return status;
169
178
  };
170
179
 
180
+ const watchedPathFromDirectoryUrl = (packageDirectory, directoryUrl) => {
181
+ const packageJsonUrl = `${directoryUrl}package.json`;
182
+ if (!packageDirectory.url) {
183
+ return packageJsonUrl;
184
+ }
185
+ return urlToRelativeUrl(packageJsonUrl, packageDirectory.url);
186
+ };
187
+
171
188
  const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
172
189
  let directoryUrl = declaringDirectoryUrl;
173
190
  while (directoryUrl) {
@@ -232,8 +249,13 @@ const watchDependencies = (
232
249
  { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
233
250
  ) => {
234
251
  let problemMap = new Map();
252
+ // every path given to the browser is relative to the package directory, the
253
+ // one holding the package.json being watched
254
+ const packageJsonPath = "package.json";
235
255
  const watcher = {
236
256
  getProblems: () => Array.from(problemMap.values()),
257
+ // what the browser needs to display the watching in progress
258
+ getWatchInfo: () => ({ packageJsonPath, pollInterval }),
237
259
  stop: () => {},
238
260
  };
239
261
  if (!packageDirectory.url) {
@@ -7122,6 +7144,7 @@ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
7122
7144
  const jsenvPluginDependencyStatus = ({
7123
7145
  dependencyProblemEventEmitter,
7124
7146
  getDependencyProblems,
7147
+ getDependencyWatchInfo = () => ({}),
7125
7148
  }) => {
7126
7149
  return {
7127
7150
  name: "jsenv:dependency_status",
@@ -7158,7 +7181,10 @@ const jsenvPluginDependencyStatus = ({
7158
7181
  src: clientReference.generatedSpecifier,
7159
7182
  initCall: {
7160
7183
  callee: "initDependencyStatus",
7161
- params: { problems: getDependencyProblems() },
7184
+ params: {
7185
+ problems: getDependencyProblems(),
7186
+ watchInfo: getDependencyWatchInfo(),
7187
+ },
7162
7188
  },
7163
7189
  pluginName: "jsenv:dependency_status",
7164
7190
  });
@@ -11882,6 +11908,7 @@ const startDevServer = async ({
11882
11908
  dependencyStatus: {
11883
11909
  dependencyProblemEventEmitter,
11884
11910
  getDependencyProblems: dependencyWatcher.getProblems,
11911
+ getDependencyWatchInfo: dependencyWatcher.getWatchInfo,
11885
11912
  },
11886
11913
  cacheControl,
11887
11914
  ribbon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.10",
3
+ "version": "41.4.11",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -265,6 +265,7 @@ export const startDevServer = async ({
265
265
  dependencyStatus: {
266
266
  dependencyProblemEventEmitter,
267
267
  getDependencyProblems: dependencyWatcher.getProblems,
268
+ getDependencyWatchInfo: dependencyWatcher.getWatchInfo,
268
269
  },
269
270
  cacheControl,
270
271
  ribbon,
@@ -23,8 +23,13 @@ export const watchDependencies = (
23
23
  { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
24
24
  ) => {
25
25
  let problemMap = new Map();
26
+ // every path given to the browser is relative to the package directory, the
27
+ // one holding the package.json being watched
28
+ const packageJsonPath = "package.json";
26
29
  const watcher = {
27
30
  getProblems: () => Array.from(problemMap.values()),
31
+ // what the browser needs to display the watching in progress
32
+ getWatchInfo: () => ({ packageJsonPath, pollInterval }),
28
33
  stop: () => {},
29
34
  };
30
35
  if (!packageDirectory.url) {
@@ -12,6 +12,7 @@
12
12
  * would pick, which is way beyond what is needed here.
13
13
  */
14
14
 
15
+ import { urlToRelativeUrl } from "@jsenv/urls";
15
16
  import { existsSync } from "node:fs";
16
17
 
17
18
  const DEPENDENCY_FIELDS = [
@@ -93,12 +94,21 @@ const createStatus = (
93
94
  declaredVersion,
94
95
  declaredBy,
95
96
  installedVersion: null,
97
+ // the file telling this dependency apart; it is what an install rewrites and
98
+ // what the dev server looks at to know the dependency became the declared one
99
+ watchedPath: null,
96
100
  state: "missing",
97
101
  };
98
102
  const installedDirectoryUrl = findInstalledDirectoryUrl(
99
103
  declaringDirectoryUrl,
100
104
  packageName,
101
105
  );
106
+ status.watchedPath = watchedPathFromDirectoryUrl(
107
+ packageDirectory,
108
+ // not installed yet: name the place where it is expected to appear
109
+ installedDirectoryUrl ||
110
+ `${declaringDirectoryUrl}node_modules/${packageName}/`,
111
+ );
102
112
  if (!installedDirectoryUrl) {
103
113
  return status;
104
114
  }
@@ -117,6 +127,14 @@ const createStatus = (
117
127
  return status;
118
128
  };
119
129
 
130
+ const watchedPathFromDirectoryUrl = (packageDirectory, directoryUrl) => {
131
+ const packageJsonUrl = `${directoryUrl}package.json`;
132
+ if (!packageDirectory.url) {
133
+ return packageJsonUrl;
134
+ }
135
+ return urlToRelativeUrl(packageJsonUrl, packageDirectory.url);
136
+ };
137
+
120
138
  const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
121
139
  let directoryUrl = declaringDirectoryUrl;
122
140
  while (directoryUrl) {
@@ -3,11 +3,22 @@
3
3
  *
4
4
  * A missing dependency is not handled here: it makes the import fail, so the
5
5
  * error overlay already says it, with more precision (which import, where).
6
+ *
7
+ * The overlay does not only describe the problem, it shows the dev server
8
+ * watching for the fix: the paths being looked at are named and kept alive on
9
+ * screen, so waiting for "npm install" feels like waiting for something that is
10
+ * actually going to happen.
6
11
  */
7
12
 
8
13
  let removeOverlay = () => {};
14
+ let watchInfoFromServer = {};
15
+ // packages listed as outdated at some point, and the path watched for each;
16
+ // what leaves this map has been installed while the overlay was open, which is
17
+ // worth showing as such
18
+ const outdatedPathMap = new Map();
9
19
 
10
- export const initDependencyStatus = ({ problems }) => {
20
+ export const initDependencyStatus = ({ problems, watchInfo }) => {
21
+ watchInfoFromServer = watchInfo || {};
11
22
  render(problems);
12
23
  // without the server events channel the page only knows the state it was
13
24
  // served with, which is still better than nothing
@@ -24,9 +35,19 @@ const render = (problems) => {
24
35
  const outdatedList = problems.filter(({ state }) => state === "outdated");
25
36
  removeOverlay();
26
37
  removeOverlay = () => {};
38
+ const installedList = [];
39
+ for (const [packageName, watchedPath] of outdatedPathMap) {
40
+ if (!outdatedList.some((problem) => problem.packageName === packageName)) {
41
+ installedList.push({ packageName, watchedPath });
42
+ }
43
+ }
27
44
  if (outdatedList.length === 0) {
45
+ outdatedPathMap.clear();
28
46
  return;
29
47
  }
48
+ for (const { packageName, watchedPath } of outdatedList) {
49
+ outdatedPathMap.set(packageName, watchedPath);
50
+ }
30
51
  const supervisor = window.__supervisor__;
31
52
  if (!supervisor || !supervisor.reportWarning) {
32
53
  console.warn(summarize(outdatedList));
@@ -41,8 +62,8 @@ const render = (problems) => {
41
62
  details: [
42
63
  "The page is running with what is installed in node_modules, which is not what package.json asks for. It may work, but it is not the code the project expects.",
43
64
  "Run npm install to fix it. If an install is already running, there is nothing to do but wait.",
44
- "This page reloads on its own as soon as node_modules matches package.json.",
45
65
  ],
66
+ node: createWatchNode(outdatedList, installedList),
46
67
  });
47
68
  };
48
69
 
@@ -57,3 +78,213 @@ const summarize = (outdatedList) => {
57
78
  .map(({ packageName }) => packageName)
58
79
  .join(", ")}`;
59
80
  };
81
+
82
+ const createWatchNode = (outdatedList, installedList) => {
83
+ const { packageJsonPath = "package.json", pollInterval } =
84
+ watchInfoFromServer;
85
+ const node = document.createElement("div");
86
+ node.className = "dependency_watch";
87
+ const rows = [];
88
+ rows.push(
89
+ createRow({
90
+ state: "watching",
91
+ path: packageJsonPath,
92
+ // a file watcher, not a poll: an edit here is what puts a dependency out
93
+ // of date in the first place
94
+ note: "on every change",
95
+ pollInterval,
96
+ }),
97
+ );
98
+ for (const { packageName, declaredVersion, watchedPath } of outdatedList) {
99
+ rows.push(
100
+ createRow({
101
+ state: "watching",
102
+ path: watchedPath || `node_modules/${packageName}/package.json`,
103
+ note: pollInterval
104
+ ? `waiting for ${declaredVersion}, read every ${pollInterval}ms`
105
+ : `waiting for ${declaredVersion}`,
106
+ pollInterval,
107
+ }),
108
+ );
109
+ }
110
+ for (const { packageName, watchedPath } of installedList) {
111
+ rows.push(
112
+ createRow({
113
+ state: "done",
114
+ path: watchedPath || `node_modules/${packageName}/package.json`,
115
+ note: "installed",
116
+ }),
117
+ );
118
+ }
119
+ node.innerHTML = `
120
+ <style>
121
+ ${watchCSS}
122
+ </style>
123
+ <div class="dependency_watch_head">
124
+ <span class="dependency_watch_live">
125
+ <span class="dependency_watch_live_dot"></span>watching
126
+ </span>
127
+ <span class="dependency_watch_head_text">
128
+ the files that will tell the install is over
129
+ </span>
130
+ </div>
131
+ <div class="dependency_watch_rows">
132
+ ${rows.join("\n ")}
133
+ </div>
134
+ <div class="dependency_watch_effect" data-copy-line>
135
+ As soon as node_modules matches package.json, this page reloads by itself.
136
+ </div>`;
137
+ return node;
138
+ };
139
+
140
+ const createRow = ({ state, path, note, pollInterval }) => {
141
+ const sweepStyle = pollInterval
142
+ ? ` style="animation-duration: ${pollInterval}ms"`
143
+ : "";
144
+ return `<div class="dependency_watch_row" data-state="${state}">
145
+ <span class="dependency_watch_row_icon"></span>
146
+ <code class="dependency_watch_row_path">${escapeHtml(path)}</code>
147
+ <span class="dependency_watch_row_note">${escapeHtml(note)}</span>
148
+ ${
149
+ state === "watching"
150
+ ? `<span class="dependency_watch_row_sweep"${sweepStyle}></span>`
151
+ : ""
152
+ }
153
+ </div>`;
154
+ };
155
+
156
+ const escapeHtml = (string) => {
157
+ return String(string)
158
+ .replace(/&/g, "&amp;")
159
+ .replace(/</g, "&lt;")
160
+ .replace(/>/g, "&gt;")
161
+ .replace(/"/g, "&quot;")
162
+ .replace(/'/g, "&#039;");
163
+ };
164
+
165
+ const watchCSS = /* css */ `
166
+ .dependency_watch_head {
167
+ display: flex;
168
+ margin-bottom: 8px;
169
+ align-items: center;
170
+ gap: 8px;
171
+ }
172
+ .dependency_watch_live {
173
+ display: flex;
174
+ padding: 2px 8px;
175
+ align-items: center;
176
+ gap: 6px;
177
+ color: #ffab40;
178
+ font-size: 12px;
179
+ text-transform: uppercase;
180
+ letter-spacing: 0.08em;
181
+ border: 1px solid currentColor;
182
+ border-radius: 999px;
183
+ }
184
+ .dependency_watch_live_dot {
185
+ width: 7px;
186
+ height: 7px;
187
+ background: currentColor;
188
+ border-radius: 50%;
189
+ animation: dependency_watch_pulse 1.4s ease-in-out infinite;
190
+ }
191
+ @keyframes dependency_watch_pulse {
192
+ 0%,
193
+ 100% {
194
+ opacity: 0.25;
195
+ transform: scale(0.8);
196
+ }
197
+ 50% {
198
+ opacity: 1;
199
+ transform: scale(1.15);
200
+ }
201
+ }
202
+ .dependency_watch_head_text {
203
+ font-size: 13px;
204
+ }
205
+
206
+ .dependency_watch_rows {
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: 4px;
210
+ }
211
+ .dependency_watch_row {
212
+ position: relative;
213
+ display: flex;
214
+ padding: 6px 10px;
215
+ align-items: center;
216
+ gap: 8px;
217
+ font-size: 13px;
218
+ background: rgba(255, 255, 255, 0.04);
219
+ border: 1px solid #333;
220
+ border-radius: 4px;
221
+ overflow: hidden;
222
+ }
223
+ .dependency_watch_row_icon {
224
+ width: 8px;
225
+ height: 8px;
226
+ flex: none;
227
+ border: 1px solid #ffab40;
228
+ border-radius: 50%;
229
+ }
230
+ .dependency_watch_row[data-state="done"] .dependency_watch_row_icon {
231
+ background: #7fd67f;
232
+ border-color: #7fd67f;
233
+ }
234
+ .dependency_watch_row_path {
235
+ color: #eee;
236
+ text-overflow: ellipsis;
237
+ white-space: nowrap;
238
+ overflow: hidden;
239
+ }
240
+ .dependency_watch_row_note {
241
+ margin-left: auto;
242
+ padding-left: 8px;
243
+ font-size: 12px;
244
+ white-space: nowrap;
245
+ opacity: 0.8;
246
+ }
247
+ .dependency_watch_row[data-state="done"] .dependency_watch_row_note {
248
+ color: #7fd67f;
249
+ opacity: 1;
250
+ }
251
+ /* the sweep is the watching made visible: one pass per read */
252
+ .dependency_watch_row_sweep {
253
+ position: absolute;
254
+ top: 0;
255
+ left: 0;
256
+ width: 35%;
257
+ height: 100%;
258
+ background: linear-gradient(
259
+ 90deg,
260
+ transparent,
261
+ rgba(255, 171, 64, 0.16),
262
+ transparent
263
+ );
264
+ animation: dependency_watch_sweep 2s linear infinite;
265
+ pointer-events: none;
266
+ }
267
+ @keyframes dependency_watch_sweep {
268
+ from {
269
+ transform: translateX(-100%);
270
+ }
271
+ to {
272
+ transform: translateX(340%);
273
+ }
274
+ }
275
+
276
+ .dependency_watch_effect {
277
+ margin-top: 8px;
278
+ color: #ffab40;
279
+ font-size: 13px;
280
+ }
281
+
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .dependency_watch_live_dot {
284
+ animation: none;
285
+ }
286
+ .dependency_watch_row_sweep {
287
+ display: none;
288
+ }
289
+ }
290
+ `;
@@ -15,6 +15,7 @@ const clientFileUrl = import.meta.resolve("./client/dependency_status.js");
15
15
  export const jsenvPluginDependencyStatus = ({
16
16
  dependencyProblemEventEmitter,
17
17
  getDependencyProblems,
18
+ getDependencyWatchInfo = () => ({}),
18
19
  }) => {
19
20
  return {
20
21
  name: "jsenv:dependency_status",
@@ -51,7 +52,10 @@ export const jsenvPluginDependencyStatus = ({
51
52
  src: clientReference.generatedSpecifier,
52
53
  initCall: {
53
54
  callee: "initDependencyStatus",
54
- params: { problems: getDependencyProblems() },
55
+ params: {
56
+ problems: getDependencyProblems(),
57
+ watchInfo: getDependencyWatchInfo(),
58
+ },
55
59
  },
56
60
  pluginName: "jsenv:dependency_status",
57
61
  });