@jsenv/core 41.4.9 → 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.
@@ -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
  });
@@ -54,7 +54,9 @@ export const jsenvPluginDirectoryReferenceEffect = (
54
54
  } else {
55
55
  const directoryRelativeUrl = urlToRelativeUrl(
56
56
  reference.url,
57
- reference.ownerUrlInfo.originalUrl,
57
+ // the root url info has no originalUrl; it owns the reference
58
+ // created for an incoming request ("http_request")
59
+ reference.ownerUrlInfo.originalUrl || reference.ownerUrlInfo.url,
58
60
  );
59
61
  reference.filenameHint = directoryRelativeUrl;
60
62
  }
@@ -67,6 +67,9 @@ export const jsenvPluginFsRedirection = ({
67
67
  const { search, hash } = urlObject;
68
68
  urlObject.search = "";
69
69
  urlObject.hash = "";
70
+ // must be read before applyFsStatEffectsOnUrlObject which forces the
71
+ // trailing slash on directories
72
+ const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
70
73
  applyFsStatEffectsOnUrlObject(urlObject, fsStat);
71
74
  const shouldApplyFilesystemMagicResolution =
72
75
  reference.type === "js_import";
@@ -96,20 +99,16 @@ export const jsenvPluginFsRedirection = ({
96
99
  // url has an extension, we assume it's a file request -> let 404 happen
97
100
  return null;
98
101
  }
99
- const { requestedUrl, rootDirectoryUrl, mainFilePath } =
100
- reference.ownerUrlInfo.context;
101
- if (!requestedUrl) {
102
- // the SPA fallback answers a request; during build there is none
102
+ if (specifierUsesTrailingSlash) {
103
+ // the trailing slash asks for a directory and there is none here
104
+ // -> let 404 happen (same reasoning as the extension above)
103
105
  return null;
104
106
  }
105
- const closestHtmlRootFile = getClosestHtmlRootFile(
106
- requestedUrl,
107
- rootDirectoryUrl,
108
- );
109
- if (closestHtmlRootFile) {
110
- return closestHtmlRootFile;
107
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
108
+ if (spaFallbackUrl) {
109
+ return spaFallbackUrl;
111
110
  }
112
- return new URL(mainFilePath, rootDirectoryUrl);
111
+ return null;
113
112
  }
114
113
  if (fsStat.isDirectory()) {
115
114
  // When requesting a directory, check if we have an HTML entry file for that directory
@@ -118,6 +117,21 @@ export const jsenvPluginFsRedirection = ({
118
117
  reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
119
118
  return directoryEntryFileUrl;
120
119
  }
120
+ if (!specifierUsesTrailingSlash) {
121
+ // the trailing slash is what tells a directory apart from a route:
122
+ // "/join/" is the directory, "/join" is a route owned by the SPA
123
+ // even when "join/" exists in the source files.
124
+ // Without this a source directory would shadow the route having
125
+ // the same name and the SPA would be unreachable in dev while
126
+ // being perfectly fine once built
127
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
128
+ if (spaFallbackUrl) {
129
+ reference.fsStat = readEntryStatSync(spaFallbackUrl, {
130
+ nullIfNotFound: true,
131
+ });
132
+ return spaFallbackUrl;
133
+ }
134
+ }
121
135
  }
122
136
  }
123
137
  if (!fsStat) {
@@ -180,6 +194,22 @@ const getDirectoryEntryFileUrl = (directoryUrl) => {
180
194
  }
181
195
  return null;
182
196
  };
197
+ const getSpaFallbackUrl = (reference) => {
198
+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
199
+ reference.ownerUrlInfo.context;
200
+ if (!requestedUrl) {
201
+ // the SPA fallback answers a request; during build there is none
202
+ return null;
203
+ }
204
+ const closestHtmlRootFile = getClosestHtmlRootFile(
205
+ requestedUrl,
206
+ rootDirectoryUrl,
207
+ );
208
+ if (closestHtmlRootFile) {
209
+ return closestHtmlRootFile;
210
+ }
211
+ return String(new URL(mainFilePath, rootDirectoryUrl));
212
+ };
183
213
  const getClosestHtmlRootFile = (requestedUrl, serverRootDirectoryUrl) => {
184
214
  let directoryUrl = new URL("./", requestedUrl);
185
215
  while (true) {