@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.
- package/dist/build/build.js +258 -0
- package/dist/js/dependency_status.js +61 -0
- package/dist/start_dev_server/jsenv_core_packages.js +262 -10
- package/dist/start_dev_server/start_dev_server.js +467 -23
- package/package.json +6 -6
- package/src/dev/dev_server_plugins/dev_server_plugin_serve_source_files.js +42 -21
- package/src/dev/start_dev_server.js +28 -0
- package/src/helpers/watch_dependencies.js +110 -0
- package/src/kitchen/errors.js +43 -0
- package/src/kitchen/package_dependencies.js +160 -0
- package/src/plugins/autoreload/jsenv_plugin_autoreload.js +2 -0
- package/src/plugins/autoreload/jsenv_plugin_autoreload_server.js +10 -0
- package/src/plugins/dependency_status/client/dependency_status.js +59 -0
- package/src/plugins/dependency_status/jsenv_plugin_dependency_status.js +64 -0
- package/src/plugins/plugins.js +5 -0
- package/src/plugins/workspace_bundle/jsenv_plugin_workspace_bundle.js +10 -0
|
@@ -246,31 +246,36 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
246
246
|
}
|
|
247
247
|
const urlInfo = reference.urlInfo;
|
|
248
248
|
const ifNoneMatch = request.headers["if-none-match"];
|
|
249
|
-
const
|
|
250
|
-
|
|
249
|
+
const inlineParentUrlInfo = urlInfo.findParentIfInline();
|
|
250
|
+
const urlInfoTargetedByCache = inlineParentUrlInfo || urlInfo;
|
|
251
|
+
const respondWithNotModified = () => {
|
|
252
|
+
const headers = {
|
|
253
|
+
"cache-control": `private,max-age=0,must-revalidate`,
|
|
254
|
+
};
|
|
255
|
+
Object.keys(urlInfo.headers).forEach((key) => {
|
|
256
|
+
if (key !== "content-length") {
|
|
257
|
+
headers[key] = urlInfo.headers[key];
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
status: 304,
|
|
262
|
+
headers,
|
|
263
|
+
};
|
|
264
|
+
};
|
|
251
265
|
|
|
252
266
|
try {
|
|
253
|
-
|
|
267
|
+
// an inline url info is cooked again every time the file containing it
|
|
268
|
+
// is cooked, so its content is only known after cooking; its etag is
|
|
269
|
+
// compared below, once cooked
|
|
270
|
+
if (!urlInfo.error && ifNoneMatch && !inlineParentUrlInfo) {
|
|
254
271
|
const [clientOriginalContentEtag, clientContentEtag] =
|
|
255
272
|
ifNoneMatch.split("_");
|
|
256
273
|
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
urlInfoTargetedByCache.isValid()
|
|
274
|
+
urlInfo.originalContentEtag === clientOriginalContentEtag &&
|
|
275
|
+
urlInfo.contentEtag === clientContentEtag &&
|
|
276
|
+
urlInfo.isValid()
|
|
261
277
|
) {
|
|
262
|
-
|
|
263
|
-
"cache-control": `private,max-age=0,must-revalidate`,
|
|
264
|
-
};
|
|
265
|
-
Object.keys(urlInfo.headers).forEach((key) => {
|
|
266
|
-
if (key !== "content-length") {
|
|
267
|
-
headers[key] = urlInfo.headers[key];
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
return {
|
|
271
|
-
status: 304,
|
|
272
|
-
headers,
|
|
273
|
-
};
|
|
278
|
+
return respondWithNotModified();
|
|
274
279
|
}
|
|
275
280
|
}
|
|
276
281
|
await urlInfo.cook({ request, reference });
|
|
@@ -278,6 +283,19 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
278
283
|
if (response) {
|
|
279
284
|
return response;
|
|
280
285
|
}
|
|
286
|
+
// the original content of an inline url info is the one of the file
|
|
287
|
+
// containing it, but its cooked content is its own: it can change while
|
|
288
|
+
// the containing file stays identical (an import resolving to a new
|
|
289
|
+
// version of a package for instance)
|
|
290
|
+
const eTag = `${urlInfoTargetedByCache.originalContentEtag}_${urlInfo.contentEtag}`;
|
|
291
|
+
if (
|
|
292
|
+
!urlInfo.error &&
|
|
293
|
+
ifNoneMatch === eTag &&
|
|
294
|
+
inlineParentUrlInfo &&
|
|
295
|
+
!cacheIsDisabledInResponseHeader(urlInfoTargetedByCache)
|
|
296
|
+
) {
|
|
297
|
+
return respondWithNotModified();
|
|
298
|
+
}
|
|
281
299
|
response = {
|
|
282
300
|
url: reference.url,
|
|
283
301
|
status: 200,
|
|
@@ -294,7 +312,7 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
294
312
|
: {
|
|
295
313
|
"cache-control": `private,max-age=0,must-revalidate`,
|
|
296
314
|
// it's safe to use "_" separator because etag is encoded with base64 (see https://stackoverflow.com/a/13195197)
|
|
297
|
-
|
|
315
|
+
eTag,
|
|
298
316
|
}),
|
|
299
317
|
...urlInfo.headers,
|
|
300
318
|
"content-type": urlInfo.contentType,
|
|
@@ -373,7 +391,10 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
373
391
|
statusText: originalError.reason,
|
|
374
392
|
};
|
|
375
393
|
}
|
|
376
|
-
|
|
394
|
+
// MODULE_NOT_FOUND: a specifier could not be resolved to a file,
|
|
395
|
+
// so something is missing on the filesystem; 500 is for the errors
|
|
396
|
+
// the server does not see coming
|
|
397
|
+
if (code === "NOT_FOUND" || code === "MODULE_NOT_FOUND") {
|
|
377
398
|
return {
|
|
378
399
|
url: reference.url,
|
|
379
400
|
status: 404,
|
|
@@ -13,6 +13,7 @@ import { existsSync } from "node:fs";
|
|
|
13
13
|
|
|
14
14
|
import { defaultRuntimeCompat } from "../build/build_params.js";
|
|
15
15
|
import { createEventEmitter } from "../helpers/event_emitter.js";
|
|
16
|
+
import { watchDependencies } from "../helpers/watch_dependencies.js";
|
|
16
17
|
import { jsenvCoreDirectoryUrl } from "../jsenv_core_directory_url.js";
|
|
17
18
|
import { createPackageDirectory } from "../kitchen/package_directory.js";
|
|
18
19
|
import { createJsenvPluginStore } from "../plugins/jsenv_plugins_controller.js";
|
|
@@ -187,13 +188,36 @@ export const startDevServer = async ({
|
|
|
187
188
|
const packageDirectory = createPackageDirectory({ sourceDirectoryUrl });
|
|
188
189
|
const clientFileChangeEventEmitter = createEventEmitter();
|
|
189
190
|
const clientFileDereferencedEventEmitter = createEventEmitter();
|
|
191
|
+
const reloadRequestEventEmitter = createEventEmitter();
|
|
190
192
|
clientAutoreload = {
|
|
191
193
|
enabled: true,
|
|
192
194
|
clientServerEventsConfig: {},
|
|
193
195
|
clientFileChangeEventEmitter,
|
|
194
196
|
clientFileDereferencedEventEmitter,
|
|
197
|
+
reloadRequestEventEmitter,
|
|
195
198
|
...clientAutoreload,
|
|
196
199
|
};
|
|
200
|
+
const dependencyProblemEventEmitter = createEventEmitter();
|
|
201
|
+
const dependencyWatcher = watchDependencies(packageDirectory, {
|
|
202
|
+
onChange: (problems) => {
|
|
203
|
+
dependencyProblemEventEmitter.emit(problems);
|
|
204
|
+
},
|
|
205
|
+
onProblem: ({ packageName, declaredVersion, installedVersion, state }) => {
|
|
206
|
+
logger.warn(
|
|
207
|
+
state === "missing"
|
|
208
|
+
? `"${packageName}@${declaredVersion}" is declared in package.json but not installed, run npm install`
|
|
209
|
+
: `"${packageName}" is installed in ${installedVersion} but package.json declares ${declaredVersion}, run npm install`,
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
onInstalled: ({ packageName, declaredVersion }) => {
|
|
213
|
+
logger.info(`"${packageName}@${declaredVersion}" is now installed`);
|
|
214
|
+
reloadRequestEventEmitter.emit({
|
|
215
|
+
cause: `${packageName}@${declaredVersion} installed`,
|
|
216
|
+
reason: `a dependency became available in node_modules`,
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
serverStopCallbackSet.add(dependencyWatcher.stop);
|
|
197
221
|
|
|
198
222
|
const devServerJsenvPluginStore = await createJsenvPluginStore([
|
|
199
223
|
jsenvPluginServerEvents({ clientAutoreload }),
|
|
@@ -228,6 +252,10 @@ export const startDevServer = async ({
|
|
|
228
252
|
|
|
229
253
|
clientAutoreload,
|
|
230
254
|
clientAutoreloadOnServerRestart,
|
|
255
|
+
dependencyStatus: {
|
|
256
|
+
dependencyProblemEventEmitter,
|
|
257
|
+
getDependencyProblems: dependencyWatcher.getProblems,
|
|
258
|
+
},
|
|
231
259
|
cacheControl,
|
|
232
260
|
ribbon,
|
|
233
261
|
dropToOpen,
|
|
@@ -0,0 +1,110 @@
|
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* node_modules is deliberately not watched: it is far too big, and an install
|
|
7
|
+
* 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.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { registerFileLifecycle } from "@jsenv/filesystem";
|
|
16
|
+
|
|
17
|
+
import { readDependencyStatuses } from "../kitchen/package_dependencies.js";
|
|
18
|
+
|
|
19
|
+
const POLL_INTERVAL = 500;
|
|
20
|
+
|
|
21
|
+
export const watchDependencies = (
|
|
22
|
+
packageDirectory,
|
|
23
|
+
{ onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
|
|
24
|
+
) => {
|
|
25
|
+
let problemMap = new Map();
|
|
26
|
+
const watcher = {
|
|
27
|
+
getProblems: () => Array.from(problemMap.values()),
|
|
28
|
+
stop: () => {},
|
|
29
|
+
};
|
|
30
|
+
if (!packageDirectory.url) {
|
|
31
|
+
return watcher;
|
|
32
|
+
}
|
|
33
|
+
let timer = null;
|
|
34
|
+
|
|
35
|
+
const check = () => {
|
|
36
|
+
const nextProblemMap = new Map();
|
|
37
|
+
for (const status of readDependencyStatuses(packageDirectory)) {
|
|
38
|
+
if (status.state === "missing" || status.state === "outdated") {
|
|
39
|
+
nextProblemMap.set(status.packageName, status);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const [packageName, status] of nextProblemMap) {
|
|
43
|
+
const previousStatus = problemMap.get(packageName);
|
|
44
|
+
if (
|
|
45
|
+
!previousStatus ||
|
|
46
|
+
previousStatus.state !== status.state ||
|
|
47
|
+
previousStatus.declaredVersion !== status.declaredVersion
|
|
48
|
+
) {
|
|
49
|
+
onProblem(status);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const [packageName, previousStatus] of problemMap) {
|
|
53
|
+
if (!nextProblemMap.has(packageName)) {
|
|
54
|
+
onInstalled(previousStatus);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const changed =
|
|
58
|
+
nextProblemMap.size !== problemMap.size ||
|
|
59
|
+
Array.from(nextProblemMap.keys()).some((packageName) => {
|
|
60
|
+
const previousStatus = problemMap.get(packageName);
|
|
61
|
+
const status = nextProblemMap.get(packageName);
|
|
62
|
+
return (
|
|
63
|
+
!previousStatus ||
|
|
64
|
+
previousStatus.state !== status.state ||
|
|
65
|
+
previousStatus.declaredVersion !== status.declaredVersion ||
|
|
66
|
+
previousStatus.installedVersion !== status.installedVersion
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
problemMap = nextProblemMap;
|
|
70
|
+
if (changed) {
|
|
71
|
+
onChange(watcher.getProblems());
|
|
72
|
+
}
|
|
73
|
+
if (problemMap.size === 0) {
|
|
74
|
+
stopPolling();
|
|
75
|
+
} else {
|
|
76
|
+
startPolling();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const startPolling = () => {
|
|
81
|
+
if (timer) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
timer = setInterval(check, pollInterval);
|
|
85
|
+
timer.unref();
|
|
86
|
+
};
|
|
87
|
+
const stopPolling = () => {
|
|
88
|
+
if (!timer) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
clearInterval(timer);
|
|
92
|
+
timer = null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const unwatchPackageJson = registerFileLifecycle(
|
|
96
|
+
new URL("package.json", packageDirectory.url),
|
|
97
|
+
{
|
|
98
|
+
added: check,
|
|
99
|
+
updated: check,
|
|
100
|
+
keepProcessAlive: false,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
check();
|
|
104
|
+
|
|
105
|
+
watcher.stop = () => {
|
|
106
|
+
stopPolling();
|
|
107
|
+
unwatchPackageJson();
|
|
108
|
+
};
|
|
109
|
+
return watcher;
|
|
110
|
+
};
|
package/src/kitchen/errors.js
CHANGED
|
@@ -2,6 +2,11 @@ import { createDetailedMessage, generateContentFrame } from "@jsenv/humanize";
|
|
|
2
2
|
import { stringifyUrlSite } from "@jsenv/urls";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
packageNameFromSpecifier,
|
|
7
|
+
readDependencyStatus,
|
|
8
|
+
} from "./package_dependencies.js";
|
|
9
|
+
|
|
5
10
|
export const createResolveUrlError = ({
|
|
6
11
|
jsenvPluginsController,
|
|
7
12
|
reference,
|
|
@@ -41,6 +46,18 @@ ${reason}`,
|
|
|
41
46
|
});
|
|
42
47
|
}
|
|
43
48
|
if (error.code === "MODULE_NOT_FOUND") {
|
|
49
|
+
const notInstalledStatus = readNotInstalledStatus(reference);
|
|
50
|
+
if (notInstalledStatus) {
|
|
51
|
+
const { packageName, declaredVersion, declaredBy, isProjectDependency } =
|
|
52
|
+
notInstalledStatus;
|
|
53
|
+
return createFailedToResolveUrlError({
|
|
54
|
+
"reason": isProjectDependency
|
|
55
|
+
? `"${packageName}" is declared in package.json but not installed`
|
|
56
|
+
: `"${packageName}" is declared by "${declaredBy}" but not installed`,
|
|
57
|
+
"declared version": declaredVersion,
|
|
58
|
+
"suggestion": `run npm install, the page will reload once "${packageName}" is installed`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
44
61
|
const bareSpecifierError = createFailedToResolveUrlError({
|
|
45
62
|
reason: `"${reference.specifier}" is a bare specifier but cannot be remapped to a package`,
|
|
46
63
|
});
|
|
@@ -295,6 +312,32 @@ const getErrorTrace = (error, reference) => {
|
|
|
295
312
|
};
|
|
296
313
|
};
|
|
297
314
|
|
|
315
|
+
// a bare specifier is resolved against the dependencies of the package
|
|
316
|
+
// containing the file that imports it, which is the project one for a source
|
|
317
|
+
// file but an other one for a file inside node_modules
|
|
318
|
+
const readNotInstalledStatus = (reference) => {
|
|
319
|
+
const { ownerUrlInfo } = reference;
|
|
320
|
+
const { packageDirectory } = ownerUrlInfo.context;
|
|
321
|
+
if (!packageDirectory) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const declaringDirectoryUrl =
|
|
325
|
+
packageDirectory.find(ownerUrlInfo.url) || packageDirectory.url;
|
|
326
|
+
const packageName = packageNameFromSpecifier(reference.specifier);
|
|
327
|
+
const status = readDependencyStatus(
|
|
328
|
+
packageDirectory,
|
|
329
|
+
packageName,
|
|
330
|
+
declaringDirectoryUrl,
|
|
331
|
+
);
|
|
332
|
+
if (!status || status.state !== "missing") {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
...status,
|
|
337
|
+
isProjectDependency: declaringDirectoryUrl === packageDirectory.url,
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
298
341
|
const detailsFromFirstReference = (reference) => {
|
|
299
342
|
const referenceInProject = getFirstReferenceInProject(reference);
|
|
300
343
|
if (
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Compares what a package.json declares with what is actually inside
|
|
3
|
+
* node_modules, so the dev server can tell a dependency apart when it is
|
|
4
|
+
* missing (never installed) or outdated (installed at an other version).
|
|
5
|
+
*
|
|
6
|
+
* Only the dependencies declared by a package are looked at, never the
|
|
7
|
+
* transitive ones: a declared dependency is what pulls the rest, so it is
|
|
8
|
+
* enough to know whether an install is needed or over.
|
|
9
|
+
*
|
|
10
|
+
* Only exact declared versions ("1.2.3") are compared: a range ("^1.2.3"), a
|
|
11
|
+
* file/workspace protocol or a tag cannot be checked without resolving what npm
|
|
12
|
+
* would pick, which is way beyond what is needed here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
const DEPENDENCY_FIELDS = [
|
|
18
|
+
"dependencies",
|
|
19
|
+
"devDependencies",
|
|
20
|
+
"optionalDependencies",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const packageNameFromSpecifier = (specifier) => {
|
|
24
|
+
const parts = specifier.split("/");
|
|
25
|
+
if (specifier[0] === "@") {
|
|
26
|
+
return parts.slice(0, 2).join("/");
|
|
27
|
+
}
|
|
28
|
+
return parts[0];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* declaringDirectoryUrl is the package directory the importer belongs to, which
|
|
33
|
+
* is not always the project one: a file inside node_modules resolves its bare
|
|
34
|
+
* specifiers against the dependencies of the package containing it.
|
|
35
|
+
*/
|
|
36
|
+
export const readDependencyStatus = (
|
|
37
|
+
packageDirectory,
|
|
38
|
+
packageName,
|
|
39
|
+
declaringDirectoryUrl = packageDirectory.url,
|
|
40
|
+
) => {
|
|
41
|
+
const packageJSON = readPackageJSON(packageDirectory, declaringDirectoryUrl);
|
|
42
|
+
if (!packageJSON) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const declaredVersion = readDeclaredVersion(packageJSON, packageName);
|
|
46
|
+
if (!declaredVersion) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return createStatus(packageDirectory, {
|
|
50
|
+
packageName,
|
|
51
|
+
declaredVersion,
|
|
52
|
+
declaringDirectoryUrl,
|
|
53
|
+
declaredBy: packageJSON.name,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const readDependencyStatuses = (packageDirectory) => {
|
|
58
|
+
const packageJSON = readPackageJSON(packageDirectory, packageDirectory.url);
|
|
59
|
+
if (!packageJSON) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const statuses = [];
|
|
63
|
+
const packageNameSet = new Set();
|
|
64
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
65
|
+
const dependencies = packageJSON[field];
|
|
66
|
+
if (!dependencies) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const packageName of Object.keys(dependencies)) {
|
|
70
|
+
if (packageNameSet.has(packageName)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
packageNameSet.add(packageName);
|
|
74
|
+
statuses.push(
|
|
75
|
+
createStatus(packageDirectory, {
|
|
76
|
+
packageName,
|
|
77
|
+
declaredVersion: dependencies[packageName],
|
|
78
|
+
declaringDirectoryUrl: packageDirectory.url,
|
|
79
|
+
declaredBy: packageJSON.name,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return statuses;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const createStatus = (
|
|
88
|
+
packageDirectory,
|
|
89
|
+
{ packageName, declaredVersion, declaringDirectoryUrl, declaredBy },
|
|
90
|
+
) => {
|
|
91
|
+
const status = {
|
|
92
|
+
packageName,
|
|
93
|
+
declaredVersion,
|
|
94
|
+
declaredBy,
|
|
95
|
+
installedVersion: null,
|
|
96
|
+
state: "missing",
|
|
97
|
+
};
|
|
98
|
+
const installedDirectoryUrl = findInstalledDirectoryUrl(
|
|
99
|
+
declaringDirectoryUrl,
|
|
100
|
+
packageName,
|
|
101
|
+
);
|
|
102
|
+
if (!installedDirectoryUrl) {
|
|
103
|
+
return status;
|
|
104
|
+
}
|
|
105
|
+
const installedPackageJSON = readPackageJSON(
|
|
106
|
+
packageDirectory,
|
|
107
|
+
installedDirectoryUrl,
|
|
108
|
+
);
|
|
109
|
+
status.installedVersion = installedPackageJSON
|
|
110
|
+
? installedPackageJSON.version
|
|
111
|
+
: null;
|
|
112
|
+
status.state =
|
|
113
|
+
isExactVersion(declaredVersion) &&
|
|
114
|
+
status.installedVersion !== declaredVersion
|
|
115
|
+
? "outdated"
|
|
116
|
+
: "installed";
|
|
117
|
+
return status;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
|
|
121
|
+
let directoryUrl = declaringDirectoryUrl;
|
|
122
|
+
while (directoryUrl) {
|
|
123
|
+
const candidateUrl = `${directoryUrl}node_modules/${packageName}/`;
|
|
124
|
+
if (existsSync(new URL(`${candidateUrl}package.json`))) {
|
|
125
|
+
return candidateUrl;
|
|
126
|
+
}
|
|
127
|
+
const parentUrl = new URL("../", directoryUrl).href;
|
|
128
|
+
if (parentUrl === directoryUrl) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
directoryUrl = parentUrl;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const readDeclaredVersion = (packageJSON, packageName) => {
|
|
137
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
138
|
+
const dependencies = packageJSON[field];
|
|
139
|
+
if (dependencies && dependencies[packageName]) {
|
|
140
|
+
return dependencies[packageName];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// an install in progress can be caught halfway, with a package.json not written yet
|
|
147
|
+
const readPackageJSON = (packageDirectory, directoryUrl) => {
|
|
148
|
+
if (!directoryUrl) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return packageDirectory.read(directoryUrl);
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const isExactVersion = (declaredVersion) => {
|
|
159
|
+
return /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(declaredVersion);
|
|
160
|
+
};
|
|
@@ -5,6 +5,7 @@ import { jsenvPluginHotSearchParam } from "./jsenv_plugin_hot_search_param.js";
|
|
|
5
5
|
export const jsenvPluginAutoreload = ({
|
|
6
6
|
clientFileChangeEventEmitter,
|
|
7
7
|
clientFileDereferencedEventEmitter,
|
|
8
|
+
reloadRequestEventEmitter,
|
|
8
9
|
}) => {
|
|
9
10
|
return [
|
|
10
11
|
jsenvPluginHotSearchParam(),
|
|
@@ -12,6 +13,7 @@ export const jsenvPluginAutoreload = ({
|
|
|
12
13
|
jsenvPluginAutoreloadServer({
|
|
13
14
|
clientFileChangeEventEmitter,
|
|
14
15
|
clientFileDereferencedEventEmitter,
|
|
16
|
+
reloadRequestEventEmitter,
|
|
15
17
|
}),
|
|
16
18
|
];
|
|
17
19
|
};
|
|
@@ -3,6 +3,7 @@ import { urlIsOrIsInsideOf, urlToRelativeUrl } from "@jsenv/urls";
|
|
|
3
3
|
export const jsenvPluginAutoreloadServer = ({
|
|
4
4
|
clientFileChangeEventEmitter,
|
|
5
5
|
clientFileDereferencedEventEmitter,
|
|
6
|
+
reloadRequestEventEmitter,
|
|
6
7
|
}) => {
|
|
7
8
|
return {
|
|
8
9
|
name: "jsenv:autoreload_server",
|
|
@@ -331,6 +332,15 @@ export const jsenvPluginAutoreloadServer = ({
|
|
|
331
332
|
});
|
|
332
333
|
},
|
|
333
334
|
);
|
|
335
|
+
// something outside the url graph wants the page back from scratch,
|
|
336
|
+
// typically a dependency that just got installed into node_modules
|
|
337
|
+
reloadRequestEventEmitter.on(({ cause, reason }) => {
|
|
338
|
+
serverEventInfo.sendServerEvent({
|
|
339
|
+
cause,
|
|
340
|
+
type: "full",
|
|
341
|
+
typeReason: reason,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
334
344
|
},
|
|
335
345
|
},
|
|
336
346
|
serverRoutes: [
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
export 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
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tells the browser about the dependencies declared in package.json that
|
|
3
|
+
* node_modules does not match, so the page can say it is running with something
|
|
4
|
+
* else than what the project asks for.
|
|
5
|
+
*
|
|
6
|
+
* The state is sent on page load (it is known before any client connects) and
|
|
7
|
+
* again whenever it changes, so a page opened during an install is updated
|
|
8
|
+
* without being reloaded.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { injectJsenvScript, parseHtml, stringifyHtmlAst } from "@jsenv/ast";
|
|
12
|
+
|
|
13
|
+
const clientFileUrl = import.meta.resolve("./client/dependency_status.js");
|
|
14
|
+
|
|
15
|
+
export const jsenvPluginDependencyStatus = ({
|
|
16
|
+
dependencyProblemEventEmitter,
|
|
17
|
+
getDependencyProblems,
|
|
18
|
+
}) => {
|
|
19
|
+
return {
|
|
20
|
+
name: "jsenv:dependency_status",
|
|
21
|
+
appliesDuring: "dev",
|
|
22
|
+
serverEvents: {
|
|
23
|
+
dependency_status: (serverEventInfo) => {
|
|
24
|
+
dependencyProblemEventEmitter.on((problems) => {
|
|
25
|
+
// the state is baked into the html by the injection below, so a page
|
|
26
|
+
// served from the graph as it is would come back with the previous
|
|
27
|
+
// state, which is exactly what a reload triggered by an install does
|
|
28
|
+
for (const urlInfo of serverEventInfo.kitchen.graph.urlInfoMap.values()) {
|
|
29
|
+
if (urlInfo.type === "html" && urlInfo.content !== undefined) {
|
|
30
|
+
urlInfo.onModified();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
serverEventInfo.sendServerEvent({ problems });
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
transformUrlContent: {
|
|
38
|
+
html: (htmlUrlInfo) => {
|
|
39
|
+
const htmlAst = parseHtml({
|
|
40
|
+
html: htmlUrlInfo.content,
|
|
41
|
+
url: htmlUrlInfo.url,
|
|
42
|
+
});
|
|
43
|
+
const clientReference = htmlUrlInfo.dependencies.inject({
|
|
44
|
+
type: "script",
|
|
45
|
+
subtype: "js_module",
|
|
46
|
+
expectedType: "js_module",
|
|
47
|
+
specifier: clientFileUrl,
|
|
48
|
+
});
|
|
49
|
+
injectJsenvScript(htmlAst, {
|
|
50
|
+
type: "module",
|
|
51
|
+
src: clientReference.generatedSpecifier,
|
|
52
|
+
initCall: {
|
|
53
|
+
callee: "initDependencyStatus",
|
|
54
|
+
params: { problems: getDependencyProblems() },
|
|
55
|
+
},
|
|
56
|
+
pluginName: "jsenv:dependency_status",
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
content: stringifyHtmlAst(htmlAst),
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
};
|
package/src/plugins/plugins.js
CHANGED
|
@@ -20,6 +20,7 @@ import { jsenvPluginImportMetaCss } from "./import_meta_css/jsenv_plugin_import_
|
|
|
20
20
|
// autoreload
|
|
21
21
|
import { jsenvPluginImportMetaHot } from "./import_meta_hot/jsenv_plugin_import_meta_hot.js";
|
|
22
22
|
import { jsenvPluginAutoreload } from "./autoreload/jsenv_plugin_autoreload.js";
|
|
23
|
+
import { jsenvPluginDependencyStatus } from "./dependency_status/jsenv_plugin_dependency_status.js";
|
|
23
24
|
import { jsenvPluginCacheControl } from "./cache_control/jsenv_plugin_cache_control.js";
|
|
24
25
|
import { jsenvPluginCustomElementsRedefine } from "./custom_elements_redefine/jsenv_plugin_custom_elements_redefine.js";
|
|
25
26
|
// other
|
|
@@ -55,6 +56,7 @@ export const getCorePlugins = ({
|
|
|
55
56
|
|
|
56
57
|
clientAutoreload,
|
|
57
58
|
clientAutoreloadOnServerRestart,
|
|
59
|
+
dependencyStatus,
|
|
58
60
|
cacheControl,
|
|
59
61
|
scenarioPlaceholders = true,
|
|
60
62
|
ribbon = true,
|
|
@@ -153,6 +155,9 @@ export const getCorePlugins = ({
|
|
|
153
155
|
...(clientAutoreload && clientAutoreload.enabled
|
|
154
156
|
? [jsenvPluginAutoreload(clientAutoreload)]
|
|
155
157
|
: []),
|
|
158
|
+
...(dependencyStatus
|
|
159
|
+
? [jsenvPluginDependencyStatus(dependencyStatus)]
|
|
160
|
+
: []),
|
|
156
161
|
...(cacheControl ? [jsenvPluginCacheControl(cacheControl)] : []),
|
|
157
162
|
...(ribbon ? [jsenvPluginRibbon({ rootDirectoryUrl, ...ribbon })] : []),
|
|
158
163
|
...(dropToOpen ? [jsenvPluginDropToOpen()] : []),
|