@jsenv/core 41.3.0 → 41.4.0

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.
@@ -132,6 +132,136 @@ const watchSourceFiles = (
132
132
 
133
133
  const jsenvCoreDirectoryUrl = new URL("../", import.meta.url);
134
134
 
135
+ /*
136
+ * Compares what a package.json declares with what is actually inside
137
+ * node_modules, so the dev server can tell a dependency apart when it is
138
+ * missing (never installed) or outdated (installed at an other version).
139
+ *
140
+ * Only the dependencies declared by a package are looked at, never the
141
+ * transitive ones: a declared dependency is what pulls the rest, so it is
142
+ * enough to know whether an install is needed or over.
143
+ *
144
+ * Only exact declared versions ("1.2.3") are compared: a range ("^1.2.3"), a
145
+ * file/workspace protocol or a tag cannot be checked without resolving what npm
146
+ * would pick, which is way beyond what is needed here.
147
+ */
148
+
149
+
150
+ const DEPENDENCY_FIELDS = [
151
+ "dependencies",
152
+ "devDependencies",
153
+ "optionalDependencies",
154
+ ];
155
+
156
+ const packageNameFromSpecifier = (specifier) => {
157
+ const parts = specifier.split("/");
158
+ if (specifier[0] === "@") {
159
+ return parts.slice(0, 2).join("/");
160
+ }
161
+ return parts[0];
162
+ };
163
+
164
+ /*
165
+ * declaringDirectoryUrl is the package directory the importer belongs to, which
166
+ * is not always the project one: a file inside node_modules resolves its bare
167
+ * specifiers against the dependencies of the package containing it.
168
+ */
169
+ const readDependencyStatus = (
170
+ packageDirectory,
171
+ packageName,
172
+ declaringDirectoryUrl = packageDirectory.url,
173
+ ) => {
174
+ const packageJSON = readPackageJSON(packageDirectory, declaringDirectoryUrl);
175
+ if (!packageJSON) {
176
+ return null;
177
+ }
178
+ const declaredVersion = readDeclaredVersion(packageJSON, packageName);
179
+ if (!declaredVersion) {
180
+ return null;
181
+ }
182
+ return createStatus(packageDirectory, {
183
+ packageName,
184
+ declaredVersion,
185
+ declaringDirectoryUrl,
186
+ declaredBy: packageJSON.name,
187
+ });
188
+ };
189
+
190
+ const createStatus = (
191
+ packageDirectory,
192
+ { packageName, declaredVersion, declaringDirectoryUrl, declaredBy },
193
+ ) => {
194
+ const status = {
195
+ packageName,
196
+ declaredVersion,
197
+ declaredBy,
198
+ installedVersion: null,
199
+ state: "missing",
200
+ };
201
+ const installedDirectoryUrl = findInstalledDirectoryUrl(
202
+ declaringDirectoryUrl,
203
+ packageName,
204
+ );
205
+ if (!installedDirectoryUrl) {
206
+ return status;
207
+ }
208
+ const installedPackageJSON = readPackageJSON(
209
+ packageDirectory,
210
+ installedDirectoryUrl,
211
+ );
212
+ status.installedVersion = installedPackageJSON
213
+ ? installedPackageJSON.version
214
+ : null;
215
+ status.state =
216
+ isExactVersion(declaredVersion) &&
217
+ status.installedVersion !== declaredVersion
218
+ ? "outdated"
219
+ : "installed";
220
+ return status;
221
+ };
222
+
223
+ const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
224
+ let directoryUrl = declaringDirectoryUrl;
225
+ while (directoryUrl) {
226
+ const candidateUrl = `${directoryUrl}node_modules/${packageName}/`;
227
+ if (existsSync(new URL(`${candidateUrl}package.json`))) {
228
+ return candidateUrl;
229
+ }
230
+ const parentUrl = new URL("../", directoryUrl).href;
231
+ if (parentUrl === directoryUrl) {
232
+ return null;
233
+ }
234
+ directoryUrl = parentUrl;
235
+ }
236
+ return null;
237
+ };
238
+
239
+ const readDeclaredVersion = (packageJSON, packageName) => {
240
+ for (const field of DEPENDENCY_FIELDS) {
241
+ const dependencies = packageJSON[field];
242
+ if (dependencies && dependencies[packageName]) {
243
+ return dependencies[packageName];
244
+ }
245
+ }
246
+ return null;
247
+ };
248
+
249
+ // an install in progress can be caught halfway, with a package.json not written yet
250
+ const readPackageJSON = (packageDirectory, directoryUrl) => {
251
+ if (!directoryUrl) {
252
+ return null;
253
+ }
254
+ try {
255
+ return packageDirectory.read(directoryUrl);
256
+ } catch {
257
+ return null;
258
+ }
259
+ };
260
+
261
+ const isExactVersion = (declaredVersion) => {
262
+ return /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(declaredVersion);
263
+ };
264
+
135
265
  const createResolveUrlError = ({
136
266
  jsenvPluginsController,
137
267
  reference,
@@ -171,6 +301,18 @@ ${reason}`,
171
301
  });
172
302
  }
173
303
  if (error.code === "MODULE_NOT_FOUND") {
304
+ const notInstalledStatus = readNotInstalledStatus(reference);
305
+ if (notInstalledStatus) {
306
+ const { packageName, declaredVersion, declaredBy, isProjectDependency } =
307
+ notInstalledStatus;
308
+ return createFailedToResolveUrlError({
309
+ "reason": isProjectDependency
310
+ ? `"${packageName}" is declared in package.json but not installed`
311
+ : `"${packageName}" is declared by "${declaredBy}" but not installed`,
312
+ "declared version": declaredVersion,
313
+ "suggestion": `run npm install, the page will reload once "${packageName}" is installed`,
314
+ });
315
+ }
174
316
  const bareSpecifierError = createFailedToResolveUrlError({
175
317
  reason: `"${reference.specifier}" is a bare specifier but cannot be remapped to a package`,
176
318
  });
@@ -425,6 +567,32 @@ const getErrorTrace = (error, reference) => {
425
567
  };
426
568
  };
427
569
 
570
+ // a bare specifier is resolved against the dependencies of the package
571
+ // containing the file that imports it, which is the project one for a source
572
+ // file but an other one for a file inside node_modules
573
+ const readNotInstalledStatus = (reference) => {
574
+ const { ownerUrlInfo } = reference;
575
+ const { packageDirectory } = ownerUrlInfo.context;
576
+ if (!packageDirectory) {
577
+ return null;
578
+ }
579
+ const declaringDirectoryUrl =
580
+ packageDirectory.find(ownerUrlInfo.url) || packageDirectory.url;
581
+ const packageName = packageNameFromSpecifier(reference.specifier);
582
+ const status = readDependencyStatus(
583
+ packageDirectory,
584
+ packageName,
585
+ declaringDirectoryUrl,
586
+ );
587
+ if (!status || status.state !== "missing") {
588
+ return null;
589
+ }
590
+ return {
591
+ ...status,
592
+ isProjectDependency: declaringDirectoryUrl === packageDirectory.url,
593
+ };
594
+ };
595
+
428
596
  const detailsFromFirstReference = (reference) => {
429
597
  const referenceInProject = getFirstReferenceInProject(reference);
430
598
  if (
@@ -8288,6 +8456,7 @@ const jsenvPluginAutoreloadClient = () => {
8288
8456
  const jsenvPluginAutoreloadServer = ({
8289
8457
  clientFileChangeEventEmitter,
8290
8458
  clientFileDereferencedEventEmitter,
8459
+ reloadRequestEventEmitter,
8291
8460
  }) => {
8292
8461
  return {
8293
8462
  name: "jsenv:autoreload_server",
@@ -8616,6 +8785,15 @@ const jsenvPluginAutoreloadServer = ({
8616
8785
  });
8617
8786
  },
8618
8787
  );
8788
+ // something outside the url graph wants the page back from scratch,
8789
+ // typically a dependency that just got installed into node_modules
8790
+ reloadRequestEventEmitter.on(({ cause, reason }) => {
8791
+ serverEventInfo.sendServerEvent({
8792
+ cause,
8793
+ type: "full",
8794
+ typeReason: reason,
8795
+ });
8796
+ });
8619
8797
  },
8620
8798
  },
8621
8799
  serverRoutes: [
@@ -8726,6 +8904,7 @@ const jsenvPluginHotSearchParam = () => {
8726
8904
  const jsenvPluginAutoreload = ({
8727
8905
  clientFileChangeEventEmitter,
8728
8906
  clientFileDereferencedEventEmitter,
8907
+ reloadRequestEventEmitter,
8729
8908
  }) => {
8730
8909
  return [
8731
8910
  jsenvPluginHotSearchParam(),
@@ -8733,10 +8912,75 @@ const jsenvPluginAutoreload = ({
8733
8912
  jsenvPluginAutoreloadServer({
8734
8913
  clientFileChangeEventEmitter,
8735
8914
  clientFileDereferencedEventEmitter,
8915
+ reloadRequestEventEmitter,
8736
8916
  }),
8737
8917
  ];
8738
8918
  };
8739
8919
 
8920
+ /*
8921
+ * Tells the browser about the dependencies declared in package.json that
8922
+ * node_modules does not match, so the page can say it is running with something
8923
+ * else than what the project asks for.
8924
+ *
8925
+ * The state is sent on page load (it is known before any client connects) and
8926
+ * again whenever it changes, so a page opened during an install is updated
8927
+ * without being reloaded.
8928
+ */
8929
+
8930
+
8931
+ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
8932
+
8933
+ const jsenvPluginDependencyStatus = ({
8934
+ dependencyProblemEventEmitter,
8935
+ getDependencyProblems,
8936
+ }) => {
8937
+ return {
8938
+ name: "jsenv:dependency_status",
8939
+ appliesDuring: "dev",
8940
+ serverEvents: {
8941
+ dependency_status: (serverEventInfo) => {
8942
+ dependencyProblemEventEmitter.on((problems) => {
8943
+ // the state is baked into the html by the injection below, so a page
8944
+ // served from the graph as it is would come back with the previous
8945
+ // state, which is exactly what a reload triggered by an install does
8946
+ for (const urlInfo of serverEventInfo.kitchen.graph.urlInfoMap.values()) {
8947
+ if (urlInfo.type === "html" && urlInfo.content !== undefined) {
8948
+ urlInfo.onModified();
8949
+ }
8950
+ }
8951
+ serverEventInfo.sendServerEvent({ problems });
8952
+ });
8953
+ },
8954
+ },
8955
+ transformUrlContent: {
8956
+ html: (htmlUrlInfo) => {
8957
+ const htmlAst = parseHtml({
8958
+ html: htmlUrlInfo.content,
8959
+ url: htmlUrlInfo.url,
8960
+ });
8961
+ const clientReference = htmlUrlInfo.dependencies.inject({
8962
+ type: "script",
8963
+ subtype: "js_module",
8964
+ expectedType: "js_module",
8965
+ specifier: clientFileUrl,
8966
+ });
8967
+ injectJsenvScript(htmlAst, {
8968
+ type: "module",
8969
+ src: clientReference.generatedSpecifier,
8970
+ initCall: {
8971
+ callee: "initDependencyStatus",
8972
+ params: { problems: getDependencyProblems() },
8973
+ },
8974
+ pluginName: "jsenv:dependency_status",
8975
+ });
8976
+ return {
8977
+ content: stringifyHtmlAst(htmlAst),
8978
+ };
8979
+ },
8980
+ },
8981
+ };
8982
+ };
8983
+
8740
8984
  const jsenvPluginCacheControl = ({
8741
8985
  versionedUrls = true,
8742
8986
  maxAge = SECONDS_IN_30_DAYS,
@@ -9158,6 +9402,16 @@ const jsenvPluginWorkspaceBundle = ({ packageDirectory }) => {
9158
9402
  // root package, we don't want to bundle
9159
9403
  return null;
9160
9404
  }
9405
+ if (reference.type !== "js_import") {
9406
+ // Only consolidate ES imports of a workspace package into its single
9407
+ // bundle. Other reference kinds are their own entry points — most
9408
+ // importantly an HTML <script src> pointing at a package file (jsenv
9409
+ // injects its own client scripts that way: server events, the client
9410
+ // monitoring reporter, custom-elements-redefine…). Redirecting those to
9411
+ // the package main would run the wrong module, and for @jsenv/core the
9412
+ // main is node-only code (node:url) that cannot be served to a browser.
9413
+ return null;
9414
+ }
9161
9415
  // we make sure we target the bundle version of the package
9162
9416
  // otherwise we might execute some parts of the package code multiple times.
9163
9417
  // so we need to redirect the potential reference to non entry point to the package main entry point
@@ -9263,6 +9517,7 @@ const getCorePlugins = ({
9263
9517
 
9264
9518
  clientAutoreload,
9265
9519
  clientAutoreloadOnServerRestart,
9520
+ dependencyStatus,
9266
9521
  cacheControl,
9267
9522
  scenarioPlaceholders = true,
9268
9523
  ribbon = true,
@@ -9361,6 +9616,9 @@ const getCorePlugins = ({
9361
9616
  ...(clientAutoreload && clientAutoreload.enabled
9362
9617
  ? [jsenvPluginAutoreload(clientAutoreload)]
9363
9618
  : []),
9619
+ ...(dependencyStatus
9620
+ ? [jsenvPluginDependencyStatus(dependencyStatus)]
9621
+ : []),
9364
9622
  ...(cacheControl ? [jsenvPluginCacheControl(cacheControl)] : []),
9365
9623
  ...(ribbon ? [jsenvPluginRibbon({ rootDirectoryUrl, ...ribbon })] : []),
9366
9624
  ...(dropToOpen ? [jsenvPluginDropToOpen()] : []),
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Displays the "some dependencies are outdated" warning overlay.
3
+ *
4
+ * A missing dependency is not handled here: it makes the import fail, so the
5
+ * error overlay already says it, with more precision (which import, where).
6
+ */
7
+
8
+ let removeOverlay = () => {};
9
+
10
+ const initDependencyStatus = ({ problems }) => {
11
+ render(problems);
12
+ // without the server events channel the page only knows the state it was
13
+ // served with, which is still better than nothing
14
+ if (window.__server_events__) {
15
+ window.__server_events__.listenEvents({
16
+ dependency_status: (event) => {
17
+ render(event.data.problems);
18
+ },
19
+ });
20
+ }
21
+ };
22
+
23
+ const render = (problems) => {
24
+ const outdatedList = problems.filter(({ state }) => state === "outdated");
25
+ removeOverlay();
26
+ removeOverlay = () => {};
27
+ if (outdatedList.length === 0) {
28
+ return;
29
+ }
30
+ const supervisor = window.__supervisor__;
31
+ if (!supervisor || !supervisor.reportWarning) {
32
+ console.warn(summarize(outdatedList));
33
+ return;
34
+ }
35
+ removeOverlay = supervisor.reportWarning({
36
+ title:
37
+ outdatedList.length === 1
38
+ ? "A dependency is outdated"
39
+ : `${outdatedList.length} dependencies are outdated`,
40
+ text: outdatedList.map(describe).join("\n\n"),
41
+ details: [
42
+ "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
+ "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
+ ],
46
+ });
47
+ };
48
+
49
+ const describe = ({ packageName, declaredVersion, installedVersion }) => {
50
+ return `${packageName}
51
+ package.json declares ${declaredVersion}
52
+ node_modules holds ${installedVersion || "an unknown version"}`;
53
+ };
54
+
55
+ const summarize = (outdatedList) => {
56
+ return `[jsenv] outdated dependencies, run npm install: ${outdatedList
57
+ .map(({ packageName }) => packageName)
58
+ .join(", ")}`;
59
+ };
60
+
61
+ export { initDependencyStatus };