@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.
@@ -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) {
@@ -4119,7 +4136,9 @@ const jsenvPluginDirectoryReferenceEffect = (
4119
4136
  } else if (reference.specifierPathname.endsWith("./")) ; else {
4120
4137
  const directoryRelativeUrl = urlToRelativeUrl(
4121
4138
  reference.url,
4122
- reference.ownerUrlInfo.originalUrl,
4139
+ // the root url info has no originalUrl; it owns the reference
4140
+ // created for an incoming request ("http_request")
4141
+ reference.ownerUrlInfo.originalUrl || reference.ownerUrlInfo.url,
4123
4142
  );
4124
4143
  reference.filenameHint = directoryRelativeUrl;
4125
4144
  }
@@ -7242,6 +7261,9 @@ const jsenvPluginFsRedirection = ({
7242
7261
  const { search, hash } = urlObject;
7243
7262
  urlObject.search = "";
7244
7263
  urlObject.hash = "";
7264
+ // must be read before applyFsStatEffectsOnUrlObject which forces the
7265
+ // trailing slash on directories
7266
+ const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
7245
7267
  applyFsStatEffectsOnUrlObject(urlObject, fsStat);
7246
7268
  const shouldApplyFilesystemMagicResolution =
7247
7269
  reference.type === "js_import";
@@ -7271,20 +7293,16 @@ const jsenvPluginFsRedirection = ({
7271
7293
  // url has an extension, we assume it's a file request -> let 404 happen
7272
7294
  return null;
7273
7295
  }
7274
- const { requestedUrl, rootDirectoryUrl, mainFilePath } =
7275
- reference.ownerUrlInfo.context;
7276
- if (!requestedUrl) {
7277
- // the SPA fallback answers a request; during build there is none
7296
+ if (specifierUsesTrailingSlash) {
7297
+ // the trailing slash asks for a directory and there is none here
7298
+ // -> let 404 happen (same reasoning as the extension above)
7278
7299
  return null;
7279
7300
  }
7280
- const closestHtmlRootFile = getClosestHtmlRootFile(
7281
- requestedUrl,
7282
- rootDirectoryUrl,
7283
- );
7284
- if (closestHtmlRootFile) {
7285
- return closestHtmlRootFile;
7301
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
7302
+ if (spaFallbackUrl) {
7303
+ return spaFallbackUrl;
7286
7304
  }
7287
- return new URL(mainFilePath, rootDirectoryUrl);
7305
+ return null;
7288
7306
  }
7289
7307
  if (fsStat.isDirectory()) {
7290
7308
  // When requesting a directory, check if we have an HTML entry file for that directory
@@ -7293,6 +7311,21 @@ const jsenvPluginFsRedirection = ({
7293
7311
  reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
7294
7312
  return directoryEntryFileUrl;
7295
7313
  }
7314
+ if (!specifierUsesTrailingSlash) {
7315
+ // the trailing slash is what tells a directory apart from a route:
7316
+ // "/join/" is the directory, "/join" is a route owned by the SPA
7317
+ // even when "join/" exists in the source files.
7318
+ // Without this a source directory would shadow the route having
7319
+ // the same name and the SPA would be unreachable in dev while
7320
+ // being perfectly fine once built
7321
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
7322
+ if (spaFallbackUrl) {
7323
+ reference.fsStat = readEntryStatSync(spaFallbackUrl, {
7324
+ nullIfNotFound: true,
7325
+ });
7326
+ return spaFallbackUrl;
7327
+ }
7328
+ }
7296
7329
  }
7297
7330
  }
7298
7331
  if (!fsStat) {
@@ -7355,6 +7388,22 @@ const getDirectoryEntryFileUrl = (directoryUrl) => {
7355
7388
  }
7356
7389
  return null;
7357
7390
  };
7391
+ const getSpaFallbackUrl = (reference) => {
7392
+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
7393
+ reference.ownerUrlInfo.context;
7394
+ if (!requestedUrl) {
7395
+ // the SPA fallback answers a request; during build there is none
7396
+ return null;
7397
+ }
7398
+ const closestHtmlRootFile = getClosestHtmlRootFile(
7399
+ requestedUrl,
7400
+ rootDirectoryUrl,
7401
+ );
7402
+ if (closestHtmlRootFile) {
7403
+ return closestHtmlRootFile;
7404
+ }
7405
+ return String(new URL(mainFilePath, rootDirectoryUrl));
7406
+ };
7358
7407
  const getClosestHtmlRootFile = (requestedUrl, serverRootDirectoryUrl) => {
7359
7408
  let directoryUrl = new URL("./", requestedUrl);
7360
7409
  while (true) {
@@ -9266,6 +9315,7 @@ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
9266
9315
  const jsenvPluginDependencyStatus = ({
9267
9316
  dependencyProblemEventEmitter,
9268
9317
  getDependencyProblems,
9318
+ getDependencyWatchInfo = () => ({}),
9269
9319
  }) => {
9270
9320
  return {
9271
9321
  name: "jsenv:dependency_status",
@@ -9302,7 +9352,10 @@ const jsenvPluginDependencyStatus = ({
9302
9352
  src: clientReference.generatedSpecifier,
9303
9353
  initCall: {
9304
9354
  callee: "initDependencyStatus",
9305
- params: { problems: getDependencyProblems() },
9355
+ params: {
9356
+ problems: getDependencyProblems(),
9357
+ watchInfo: getDependencyWatchInfo(),
9358
+ },
9306
9359
  },
9307
9360
  pluginName: "jsenv:dependency_status",
9308
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) {
@@ -4075,6 +4097,9 @@ const jsenvPluginFsRedirection = ({
4075
4097
  const { search, hash } = urlObject;
4076
4098
  urlObject.search = "";
4077
4099
  urlObject.hash = "";
4100
+ // must be read before applyFsStatEffectsOnUrlObject which forces the
4101
+ // trailing slash on directories
4102
+ const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
4078
4103
  applyFsStatEffectsOnUrlObject(urlObject, fsStat);
4079
4104
  const shouldApplyFilesystemMagicResolution =
4080
4105
  reference.type === "js_import";
@@ -4104,20 +4129,16 @@ const jsenvPluginFsRedirection = ({
4104
4129
  // url has an extension, we assume it's a file request -> let 404 happen
4105
4130
  return null;
4106
4131
  }
4107
- const { requestedUrl, rootDirectoryUrl, mainFilePath } =
4108
- reference.ownerUrlInfo.context;
4109
- if (!requestedUrl) {
4110
- // the SPA fallback answers a request; during build there is none
4132
+ if (specifierUsesTrailingSlash) {
4133
+ // the trailing slash asks for a directory and there is none here
4134
+ // -> let 404 happen (same reasoning as the extension above)
4111
4135
  return null;
4112
4136
  }
4113
- const closestHtmlRootFile = getClosestHtmlRootFile(
4114
- requestedUrl,
4115
- rootDirectoryUrl,
4116
- );
4117
- if (closestHtmlRootFile) {
4118
- return closestHtmlRootFile;
4137
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
4138
+ if (spaFallbackUrl) {
4139
+ return spaFallbackUrl;
4119
4140
  }
4120
- return new URL(mainFilePath, rootDirectoryUrl);
4141
+ return null;
4121
4142
  }
4122
4143
  if (fsStat.isDirectory()) {
4123
4144
  // When requesting a directory, check if we have an HTML entry file for that directory
@@ -4126,6 +4147,21 @@ const jsenvPluginFsRedirection = ({
4126
4147
  reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
4127
4148
  return directoryEntryFileUrl;
4128
4149
  }
4150
+ if (!specifierUsesTrailingSlash) {
4151
+ // the trailing slash is what tells a directory apart from a route:
4152
+ // "/join/" is the directory, "/join" is a route owned by the SPA
4153
+ // even when "join/" exists in the source files.
4154
+ // Without this a source directory would shadow the route having
4155
+ // the same name and the SPA would be unreachable in dev while
4156
+ // being perfectly fine once built
4157
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
4158
+ if (spaFallbackUrl) {
4159
+ reference.fsStat = readEntryStatSync(spaFallbackUrl, {
4160
+ nullIfNotFound: true,
4161
+ });
4162
+ return spaFallbackUrl;
4163
+ }
4164
+ }
4129
4165
  }
4130
4166
  }
4131
4167
  if (!fsStat) {
@@ -4188,6 +4224,22 @@ const getDirectoryEntryFileUrl = (directoryUrl) => {
4188
4224
  }
4189
4225
  return null;
4190
4226
  };
4227
+ const getSpaFallbackUrl = (reference) => {
4228
+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
4229
+ reference.ownerUrlInfo.context;
4230
+ if (!requestedUrl) {
4231
+ // the SPA fallback answers a request; during build there is none
4232
+ return null;
4233
+ }
4234
+ const closestHtmlRootFile = getClosestHtmlRootFile(
4235
+ requestedUrl,
4236
+ rootDirectoryUrl,
4237
+ );
4238
+ if (closestHtmlRootFile) {
4239
+ return closestHtmlRootFile;
4240
+ }
4241
+ return String(new URL(mainFilePath, rootDirectoryUrl));
4242
+ };
4191
4243
  const getClosestHtmlRootFile = (requestedUrl, serverRootDirectoryUrl) => {
4192
4244
  let directoryUrl = new URL("./", requestedUrl);
4193
4245
  while (true) {
@@ -4995,7 +5047,9 @@ const jsenvPluginDirectoryReferenceEffect = (
4995
5047
  } else if (reference.specifierPathname.endsWith("./")) ; else {
4996
5048
  const directoryRelativeUrl = urlToRelativeUrl(
4997
5049
  reference.url,
4998
- reference.ownerUrlInfo.originalUrl,
5050
+ // the root url info has no originalUrl; it owns the reference
5051
+ // created for an incoming request ("http_request")
5052
+ reference.ownerUrlInfo.originalUrl || reference.ownerUrlInfo.url,
4999
5053
  );
5000
5054
  reference.filenameHint = directoryRelativeUrl;
5001
5055
  }
@@ -7090,6 +7144,7 @@ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
7090
7144
  const jsenvPluginDependencyStatus = ({
7091
7145
  dependencyProblemEventEmitter,
7092
7146
  getDependencyProblems,
7147
+ getDependencyWatchInfo = () => ({}),
7093
7148
  }) => {
7094
7149
  return {
7095
7150
  name: "jsenv:dependency_status",
@@ -7126,7 +7181,10 @@ const jsenvPluginDependencyStatus = ({
7126
7181
  src: clientReference.generatedSpecifier,
7127
7182
  initCall: {
7128
7183
  callee: "initDependencyStatus",
7129
- params: { problems: getDependencyProblems() },
7184
+ params: {
7185
+ problems: getDependencyProblems(),
7186
+ watchInfo: getDependencyWatchInfo(),
7187
+ },
7130
7188
  },
7131
7189
  pluginName: "jsenv:dependency_status",
7132
7190
  });
@@ -11421,6 +11479,14 @@ const devServerPluginServeSourceFiles = ({
11421
11479
  !inlineParentUrlInfo &&
11422
11480
  !urlInfo.response &&
11423
11481
  urlInfo.content !== undefined &&
11482
+ // content can be defined while a cook is still in flight (a file
11483
+ // watcher invalidation re-cooking in the background, for
11484
+ // instance): at that point it holds the raw fetched content,
11485
+ // transformations not applied yet. Serving that would send an
11486
+ // html without any of the injected scripts. Only finalized
11487
+ // content is a complete response; anything else must go through
11488
+ // cook() below, which joins the pending cook (see debounceCook).
11489
+ urlInfo.contentFinalized &&
11424
11490
  !cacheIsDisabledInResponseHeader(urlInfo) &&
11425
11491
  // a "?hot" request exists to bypass every cache, this one
11426
11492
  // included: it must be cooked, because cooking is what rewrites
@@ -11465,7 +11531,10 @@ const devServerPluginServeSourceFiles = ({
11465
11531
  }
11466
11532
  response = {
11467
11533
  url: reference.url,
11468
- status: 200,
11534
+ // a plugin can cook a complete response body for an url that is
11535
+ // not a 200: the directory listing does this to answer a request
11536
+ // for a file that does not exist with the explorer page
11537
+ status: urlInfo.status,
11469
11538
  headers: {
11470
11539
  // when we send eTag to the client the next request to the server
11471
11540
  // will send etag in request headers.
@@ -11839,6 +11908,7 @@ const startDevServer = async ({
11839
11908
  dependencyStatus: {
11840
11909
  dependencyProblemEventEmitter,
11841
11910
  getDependencyProblems: dependencyWatcher.getProblems,
11911
+ getDependencyWatchInfo: dependencyWatcher.getWatchInfo,
11842
11912
  },
11843
11913
  cacheControl,
11844
11914
  ribbon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.9",
3
+ "version": "41.4.11",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -317,6 +317,14 @@ export const devServerPluginServeSourceFiles = ({
317
317
  !inlineParentUrlInfo &&
318
318
  !urlInfo.response &&
319
319
  urlInfo.content !== undefined &&
320
+ // content can be defined while a cook is still in flight (a file
321
+ // watcher invalidation re-cooking in the background, for
322
+ // instance): at that point it holds the raw fetched content,
323
+ // transformations not applied yet. Serving that would send an
324
+ // html without any of the injected scripts. Only finalized
325
+ // content is a complete response; anything else must go through
326
+ // cook() below, which joins the pending cook (see debounceCook).
327
+ urlInfo.contentFinalized &&
320
328
  !cacheIsDisabledInResponseHeader(urlInfo) &&
321
329
  // a "?hot" request exists to bypass every cache, this one
322
330
  // included: it must be cooked, because cooking is what rewrites
@@ -361,7 +369,10 @@ export const devServerPluginServeSourceFiles = ({
361
369
  }
362
370
  response = {
363
371
  url: reference.url,
364
- status: 200,
372
+ // a plugin can cook a complete response body for an url that is
373
+ // not a 200: the directory listing does this to answer a request
374
+ // for a file that does not exist with the explorer page
375
+ status: urlInfo.status,
365
376
  headers: {
366
377
  // when we send eTag to the client the next request to the server
367
378
  // will send etag in request headers.
@@ -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) {