@jsenv/core 41.3.1 → 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.
@@ -1,6 +1,6 @@
1
1
  import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
- import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
3
- import { lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, urlToRelativeUrl, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, 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";
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, 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";
@@ -50,6 +50,274 @@ const createEventEmitter = () => {
50
50
  return { on, off, emit };
51
51
  };
52
52
 
53
+ /*
54
+ * Compares what a package.json declares with what is actually inside
55
+ * node_modules, so the dev server can tell a dependency apart when it is
56
+ * missing (never installed) or outdated (installed at an other version).
57
+ *
58
+ * Only the dependencies declared by a package are looked at, never the
59
+ * transitive ones: a declared dependency is what pulls the rest, so it is
60
+ * enough to know whether an install is needed or over.
61
+ *
62
+ * Only exact declared versions ("1.2.3") are compared: a range ("^1.2.3"), a
63
+ * file/workspace protocol or a tag cannot be checked without resolving what npm
64
+ * would pick, which is way beyond what is needed here.
65
+ */
66
+
67
+
68
+ const DEPENDENCY_FIELDS = [
69
+ "dependencies",
70
+ "devDependencies",
71
+ "optionalDependencies",
72
+ ];
73
+
74
+ const packageNameFromSpecifier = (specifier) => {
75
+ const parts = specifier.split("/");
76
+ if (specifier[0] === "@") {
77
+ return parts.slice(0, 2).join("/");
78
+ }
79
+ return parts[0];
80
+ };
81
+
82
+ /*
83
+ * declaringDirectoryUrl is the package directory the importer belongs to, which
84
+ * is not always the project one: a file inside node_modules resolves its bare
85
+ * specifiers against the dependencies of the package containing it.
86
+ */
87
+ const readDependencyStatus = (
88
+ packageDirectory,
89
+ packageName,
90
+ declaringDirectoryUrl = packageDirectory.url,
91
+ ) => {
92
+ const packageJSON = readPackageJSON(packageDirectory, declaringDirectoryUrl);
93
+ if (!packageJSON) {
94
+ return null;
95
+ }
96
+ const declaredVersion = readDeclaredVersion(packageJSON, packageName);
97
+ if (!declaredVersion) {
98
+ return null;
99
+ }
100
+ return createStatus(packageDirectory, {
101
+ packageName,
102
+ declaredVersion,
103
+ declaringDirectoryUrl,
104
+ declaredBy: packageJSON.name,
105
+ });
106
+ };
107
+
108
+ const readDependencyStatuses = (packageDirectory) => {
109
+ const packageJSON = readPackageJSON(packageDirectory, packageDirectory.url);
110
+ if (!packageJSON) {
111
+ return [];
112
+ }
113
+ const statuses = [];
114
+ const packageNameSet = new Set();
115
+ for (const field of DEPENDENCY_FIELDS) {
116
+ const dependencies = packageJSON[field];
117
+ if (!dependencies) {
118
+ continue;
119
+ }
120
+ for (const packageName of Object.keys(dependencies)) {
121
+ if (packageNameSet.has(packageName)) {
122
+ continue;
123
+ }
124
+ packageNameSet.add(packageName);
125
+ statuses.push(
126
+ createStatus(packageDirectory, {
127
+ packageName,
128
+ declaredVersion: dependencies[packageName],
129
+ declaringDirectoryUrl: packageDirectory.url,
130
+ declaredBy: packageJSON.name,
131
+ }),
132
+ );
133
+ }
134
+ }
135
+ return statuses;
136
+ };
137
+
138
+ const createStatus = (
139
+ packageDirectory,
140
+ { packageName, declaredVersion, declaringDirectoryUrl, declaredBy },
141
+ ) => {
142
+ const status = {
143
+ packageName,
144
+ declaredVersion,
145
+ declaredBy,
146
+ installedVersion: null,
147
+ state: "missing",
148
+ };
149
+ const installedDirectoryUrl = findInstalledDirectoryUrl(
150
+ declaringDirectoryUrl,
151
+ packageName,
152
+ );
153
+ if (!installedDirectoryUrl) {
154
+ return status;
155
+ }
156
+ const installedPackageJSON = readPackageJSON(
157
+ packageDirectory,
158
+ installedDirectoryUrl,
159
+ );
160
+ status.installedVersion = installedPackageJSON
161
+ ? installedPackageJSON.version
162
+ : null;
163
+ status.state =
164
+ isExactVersion(declaredVersion) &&
165
+ status.installedVersion !== declaredVersion
166
+ ? "outdated"
167
+ : "installed";
168
+ return status;
169
+ };
170
+
171
+ const findInstalledDirectoryUrl = (declaringDirectoryUrl, packageName) => {
172
+ let directoryUrl = declaringDirectoryUrl;
173
+ while (directoryUrl) {
174
+ const candidateUrl = `${directoryUrl}node_modules/${packageName}/`;
175
+ if (existsSync(new URL(`${candidateUrl}package.json`))) {
176
+ return candidateUrl;
177
+ }
178
+ const parentUrl = new URL("../", directoryUrl).href;
179
+ if (parentUrl === directoryUrl) {
180
+ return null;
181
+ }
182
+ directoryUrl = parentUrl;
183
+ }
184
+ return null;
185
+ };
186
+
187
+ const readDeclaredVersion = (packageJSON, packageName) => {
188
+ for (const field of DEPENDENCY_FIELDS) {
189
+ const dependencies = packageJSON[field];
190
+ if (dependencies && dependencies[packageName]) {
191
+ return dependencies[packageName];
192
+ }
193
+ }
194
+ return null;
195
+ };
196
+
197
+ // an install in progress can be caught halfway, with a package.json not written yet
198
+ const readPackageJSON = (packageDirectory, directoryUrl) => {
199
+ if (!directoryUrl) {
200
+ return null;
201
+ }
202
+ try {
203
+ return packageDirectory.read(directoryUrl);
204
+ } catch {
205
+ return null;
206
+ }
207
+ };
208
+
209
+ const isExactVersion = (declaredVersion) => {
210
+ return /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(declaredVersion);
211
+ };
212
+
213
+ /*
214
+ * Detects when "npm install" makes a missing or outdated dependency match what
215
+ * the project package.json declares, so the browser can be reloaded at that
216
+ * moment.
217
+ *
218
+ * node_modules is deliberately not watched: it is far too big, and an install
219
+ * rewrites, dedupes and moves package directories around, so a watcher placed
220
+ * on one of them is unreliable. Instead the few packages known to be missing or
221
+ * outdated are polled, which costs a couple of readFileSync and stops as soon as
222
+ * they are all installed. The project package.json is watched though: it is a
223
+ * single file, and editing it is what puts a dependency out of date in the first
224
+ * place.
225
+ */
226
+
227
+
228
+ const POLL_INTERVAL = 500;
229
+
230
+ const watchDependencies = (
231
+ packageDirectory,
232
+ { onProblem, onInstalled, onChange, pollInterval = POLL_INTERVAL },
233
+ ) => {
234
+ let problemMap = new Map();
235
+ const watcher = {
236
+ getProblems: () => Array.from(problemMap.values()),
237
+ stop: () => {},
238
+ };
239
+ if (!packageDirectory.url) {
240
+ return watcher;
241
+ }
242
+ let timer = null;
243
+
244
+ const check = () => {
245
+ const nextProblemMap = new Map();
246
+ for (const status of readDependencyStatuses(packageDirectory)) {
247
+ if (status.state === "missing" || status.state === "outdated") {
248
+ nextProblemMap.set(status.packageName, status);
249
+ }
250
+ }
251
+ for (const [packageName, status] of nextProblemMap) {
252
+ const previousStatus = problemMap.get(packageName);
253
+ if (
254
+ !previousStatus ||
255
+ previousStatus.state !== status.state ||
256
+ previousStatus.declaredVersion !== status.declaredVersion
257
+ ) {
258
+ onProblem(status);
259
+ }
260
+ }
261
+ for (const [packageName, previousStatus] of problemMap) {
262
+ if (!nextProblemMap.has(packageName)) {
263
+ onInstalled(previousStatus);
264
+ }
265
+ }
266
+ const changed =
267
+ nextProblemMap.size !== problemMap.size ||
268
+ Array.from(nextProblemMap.keys()).some((packageName) => {
269
+ const previousStatus = problemMap.get(packageName);
270
+ const status = nextProblemMap.get(packageName);
271
+ return (
272
+ !previousStatus ||
273
+ previousStatus.state !== status.state ||
274
+ previousStatus.declaredVersion !== status.declaredVersion ||
275
+ previousStatus.installedVersion !== status.installedVersion
276
+ );
277
+ });
278
+ problemMap = nextProblemMap;
279
+ if (changed) {
280
+ onChange(watcher.getProblems());
281
+ }
282
+ if (problemMap.size === 0) {
283
+ stopPolling();
284
+ } else {
285
+ startPolling();
286
+ }
287
+ };
288
+
289
+ const startPolling = () => {
290
+ if (timer) {
291
+ return;
292
+ }
293
+ timer = setInterval(check, pollInterval);
294
+ timer.unref();
295
+ };
296
+ const stopPolling = () => {
297
+ if (!timer) {
298
+ return;
299
+ }
300
+ clearInterval(timer);
301
+ timer = null;
302
+ };
303
+
304
+ const unwatchPackageJson = registerFileLifecycle(
305
+ new URL("package.json", packageDirectory.url),
306
+ {
307
+ added: check,
308
+ updated: check,
309
+ keepProcessAlive: false,
310
+ },
311
+ );
312
+ check();
313
+
314
+ watcher.stop = () => {
315
+ stopPolling();
316
+ unwatchPackageJson();
317
+ };
318
+ return watcher;
319
+ };
320
+
53
321
  const jsenvCoreDirectoryUrl = new URL("../", import.meta.url);
54
322
 
55
323
  const createPackageDirectory = ({
@@ -4050,6 +4318,18 @@ ${reason}`,
4050
4318
  });
4051
4319
  }
4052
4320
  if (error.code === "MODULE_NOT_FOUND") {
4321
+ const notInstalledStatus = readNotInstalledStatus(reference);
4322
+ if (notInstalledStatus) {
4323
+ const { packageName, declaredVersion, declaredBy, isProjectDependency } =
4324
+ notInstalledStatus;
4325
+ return createFailedToResolveUrlError({
4326
+ "reason": isProjectDependency
4327
+ ? `"${packageName}" is declared in package.json but not installed`
4328
+ : `"${packageName}" is declared by "${declaredBy}" but not installed`,
4329
+ "declared version": declaredVersion,
4330
+ "suggestion": `run npm install, the page will reload once "${packageName}" is installed`,
4331
+ });
4332
+ }
4053
4333
  const bareSpecifierError = createFailedToResolveUrlError({
4054
4334
  reason: `"${reference.specifier}" is a bare specifier but cannot be remapped to a package`,
4055
4335
  });
@@ -4304,6 +4584,32 @@ const getErrorTrace = (error, reference) => {
4304
4584
  };
4305
4585
  };
4306
4586
 
4587
+ // a bare specifier is resolved against the dependencies of the package
4588
+ // containing the file that imports it, which is the project one for a source
4589
+ // file but an other one for a file inside node_modules
4590
+ const readNotInstalledStatus = (reference) => {
4591
+ const { ownerUrlInfo } = reference;
4592
+ const { packageDirectory } = ownerUrlInfo.context;
4593
+ if (!packageDirectory) {
4594
+ return null;
4595
+ }
4596
+ const declaringDirectoryUrl =
4597
+ packageDirectory.find(ownerUrlInfo.url) || packageDirectory.url;
4598
+ const packageName = packageNameFromSpecifier(reference.specifier);
4599
+ const status = readDependencyStatus(
4600
+ packageDirectory,
4601
+ packageName,
4602
+ declaringDirectoryUrl,
4603
+ );
4604
+ if (!status || status.state !== "missing") {
4605
+ return null;
4606
+ }
4607
+ return {
4608
+ ...status,
4609
+ isProjectDependency: declaringDirectoryUrl === packageDirectory.url,
4610
+ };
4611
+ };
4612
+
4307
4613
  const detailsFromFirstReference = (reference) => {
4308
4614
  const referenceInProject = getFirstReferenceInProject(reference);
4309
4615
  if (
@@ -5928,6 +6234,7 @@ const jsenvPluginAutoreloadClient = () => {
5928
6234
  const jsenvPluginAutoreloadServer = ({
5929
6235
  clientFileChangeEventEmitter,
5930
6236
  clientFileDereferencedEventEmitter,
6237
+ reloadRequestEventEmitter,
5931
6238
  }) => {
5932
6239
  return {
5933
6240
  name: "jsenv:autoreload_server",
@@ -6256,6 +6563,15 @@ const jsenvPluginAutoreloadServer = ({
6256
6563
  });
6257
6564
  },
6258
6565
  );
6566
+ // something outside the url graph wants the page back from scratch,
6567
+ // typically a dependency that just got installed into node_modules
6568
+ reloadRequestEventEmitter.on(({ cause, reason }) => {
6569
+ serverEventInfo.sendServerEvent({
6570
+ cause,
6571
+ type: "full",
6572
+ typeReason: reason,
6573
+ });
6574
+ });
6259
6575
  },
6260
6576
  },
6261
6577
  serverRoutes: [
@@ -6366,6 +6682,7 @@ const jsenvPluginHotSearchParam = () => {
6366
6682
  const jsenvPluginAutoreload = ({
6367
6683
  clientFileChangeEventEmitter,
6368
6684
  clientFileDereferencedEventEmitter,
6685
+ reloadRequestEventEmitter,
6369
6686
  }) => {
6370
6687
  return [
6371
6688
  jsenvPluginHotSearchParam(),
@@ -6373,10 +6690,75 @@ const jsenvPluginAutoreload = ({
6373
6690
  jsenvPluginAutoreloadServer({
6374
6691
  clientFileChangeEventEmitter,
6375
6692
  clientFileDereferencedEventEmitter,
6693
+ reloadRequestEventEmitter,
6376
6694
  }),
6377
6695
  ];
6378
6696
  };
6379
6697
 
6698
+ /*
6699
+ * Tells the browser about the dependencies declared in package.json that
6700
+ * node_modules does not match, so the page can say it is running with something
6701
+ * else than what the project asks for.
6702
+ *
6703
+ * The state is sent on page load (it is known before any client connects) and
6704
+ * again whenever it changes, so a page opened during an install is updated
6705
+ * without being reloaded.
6706
+ */
6707
+
6708
+
6709
+ const clientFileUrl = import.meta.resolve("../js/dependency_status.js");
6710
+
6711
+ const jsenvPluginDependencyStatus = ({
6712
+ dependencyProblemEventEmitter,
6713
+ getDependencyProblems,
6714
+ }) => {
6715
+ return {
6716
+ name: "jsenv:dependency_status",
6717
+ appliesDuring: "dev",
6718
+ serverEvents: {
6719
+ dependency_status: (serverEventInfo) => {
6720
+ dependencyProblemEventEmitter.on((problems) => {
6721
+ // the state is baked into the html by the injection below, so a page
6722
+ // served from the graph as it is would come back with the previous
6723
+ // state, which is exactly what a reload triggered by an install does
6724
+ for (const urlInfo of serverEventInfo.kitchen.graph.urlInfoMap.values()) {
6725
+ if (urlInfo.type === "html" && urlInfo.content !== undefined) {
6726
+ urlInfo.onModified();
6727
+ }
6728
+ }
6729
+ serverEventInfo.sendServerEvent({ problems });
6730
+ });
6731
+ },
6732
+ },
6733
+ transformUrlContent: {
6734
+ html: (htmlUrlInfo) => {
6735
+ const htmlAst = parseHtml({
6736
+ html: htmlUrlInfo.content,
6737
+ url: htmlUrlInfo.url,
6738
+ });
6739
+ const clientReference = htmlUrlInfo.dependencies.inject({
6740
+ type: "script",
6741
+ subtype: "js_module",
6742
+ expectedType: "js_module",
6743
+ specifier: clientFileUrl,
6744
+ });
6745
+ injectJsenvScript(htmlAst, {
6746
+ type: "module",
6747
+ src: clientReference.generatedSpecifier,
6748
+ initCall: {
6749
+ callee: "initDependencyStatus",
6750
+ params: { problems: getDependencyProblems() },
6751
+ },
6752
+ pluginName: "jsenv:dependency_status",
6753
+ });
6754
+ return {
6755
+ content: stringifyHtmlAst(htmlAst),
6756
+ };
6757
+ },
6758
+ },
6759
+ };
6760
+ };
6761
+
6380
6762
  const jsenvPluginCacheControl = ({
6381
6763
  versionedUrls = true,
6382
6764
  maxAge = SECONDS_IN_30_DAYS,
@@ -6913,6 +7295,7 @@ const getCorePlugins = ({
6913
7295
 
6914
7296
  clientAutoreload,
6915
7297
  clientAutoreloadOnServerRestart,
7298
+ dependencyStatus,
6916
7299
  cacheControl,
6917
7300
  scenarioPlaceholders = true,
6918
7301
  ribbon = true,
@@ -7011,6 +7394,9 @@ const getCorePlugins = ({
7011
7394
  ...(clientAutoreload && clientAutoreload.enabled
7012
7395
  ? [jsenvPluginAutoreload(clientAutoreload)]
7013
7396
  : []),
7397
+ ...(dependencyStatus
7398
+ ? [jsenvPluginDependencyStatus(dependencyStatus)]
7399
+ : []),
7014
7400
  ...(cacheControl ? [jsenvPluginCacheControl(cacheControl)] : []),
7015
7401
  ...(ribbon ? [jsenvPluginRibbon({ rootDirectoryUrl, ...ribbon })] : []),
7016
7402
  ...(dropToOpen ? [jsenvPluginDropToOpen()] : []),
@@ -10561,31 +10947,36 @@ const devServerPluginServeSourceFiles = ({
10561
10947
  }
10562
10948
  const urlInfo = reference.urlInfo;
10563
10949
  const ifNoneMatch = request.headers["if-none-match"];
10564
- const urlInfoTargetedByCache =
10565
- urlInfo.findParentIfInline() || urlInfo;
10950
+ const inlineParentUrlInfo = urlInfo.findParentIfInline();
10951
+ const urlInfoTargetedByCache = inlineParentUrlInfo || urlInfo;
10952
+ const respondWithNotModified = () => {
10953
+ const headers = {
10954
+ "cache-control": `private,max-age=0,must-revalidate`,
10955
+ };
10956
+ Object.keys(urlInfo.headers).forEach((key) => {
10957
+ if (key !== "content-length") {
10958
+ headers[key] = urlInfo.headers[key];
10959
+ }
10960
+ });
10961
+ return {
10962
+ status: 304,
10963
+ headers,
10964
+ };
10965
+ };
10566
10966
 
10567
10967
  try {
10568
- if (!urlInfo.error && ifNoneMatch) {
10968
+ // an inline url info is cooked again every time the file containing it
10969
+ // is cooked, so its content is only known after cooking; its etag is
10970
+ // compared below, once cooked
10971
+ if (!urlInfo.error && ifNoneMatch && !inlineParentUrlInfo) {
10569
10972
  const [clientOriginalContentEtag, clientContentEtag] =
10570
10973
  ifNoneMatch.split("_");
10571
10974
  if (
10572
- urlInfoTargetedByCache.originalContentEtag ===
10573
- clientOriginalContentEtag &&
10574
- urlInfoTargetedByCache.contentEtag === clientContentEtag &&
10575
- urlInfoTargetedByCache.isValid()
10975
+ urlInfo.originalContentEtag === clientOriginalContentEtag &&
10976
+ urlInfo.contentEtag === clientContentEtag &&
10977
+ urlInfo.isValid()
10576
10978
  ) {
10577
- const headers = {
10578
- "cache-control": `private,max-age=0,must-revalidate`,
10579
- };
10580
- Object.keys(urlInfo.headers).forEach((key) => {
10581
- if (key !== "content-length") {
10582
- headers[key] = urlInfo.headers[key];
10583
- }
10584
- });
10585
- return {
10586
- status: 304,
10587
- headers,
10588
- };
10979
+ return respondWithNotModified();
10589
10980
  }
10590
10981
  }
10591
10982
  await urlInfo.cook({ request, reference });
@@ -10593,6 +10984,19 @@ const devServerPluginServeSourceFiles = ({
10593
10984
  if (response) {
10594
10985
  return response;
10595
10986
  }
10987
+ // the original content of an inline url info is the one of the file
10988
+ // containing it, but its cooked content is its own: it can change while
10989
+ // the containing file stays identical (an import resolving to a new
10990
+ // version of a package for instance)
10991
+ const eTag = `${urlInfoTargetedByCache.originalContentEtag}_${urlInfo.contentEtag}`;
10992
+ if (
10993
+ !urlInfo.error &&
10994
+ ifNoneMatch === eTag &&
10995
+ inlineParentUrlInfo &&
10996
+ !cacheIsDisabledInResponseHeader(urlInfoTargetedByCache)
10997
+ ) {
10998
+ return respondWithNotModified();
10999
+ }
10596
11000
  response = {
10597
11001
  url: reference.url,
10598
11002
  status: 200,
@@ -10609,7 +11013,7 @@ const devServerPluginServeSourceFiles = ({
10609
11013
  : {
10610
11014
  "cache-control": `private,max-age=0,must-revalidate`,
10611
11015
  // it's safe to use "_" separator because etag is encoded with base64 (see https://stackoverflow.com/a/13195197)
10612
- "eTag": `${urlInfoTargetedByCache.originalContentEtag}_${urlInfoTargetedByCache.contentEtag}`,
11016
+ eTag,
10613
11017
  }),
10614
11018
  ...urlInfo.headers,
10615
11019
  "content-type": urlInfo.contentType,
@@ -10688,7 +11092,10 @@ const devServerPluginServeSourceFiles = ({
10688
11092
  statusText: originalError.reason,
10689
11093
  };
10690
11094
  }
10691
- if (code === "NOT_FOUND") {
11095
+ // MODULE_NOT_FOUND: a specifier could not be resolved to a file,
11096
+ // so something is missing on the filesystem; 500 is for the errors
11097
+ // the server does not see coming
11098
+ if (code === "NOT_FOUND" || code === "MODULE_NOT_FOUND") {
10692
11099
  return {
10693
11100
  url: reference.url,
10694
11101
  status: 404,
@@ -10883,13 +11290,36 @@ const startDevServer = async ({
10883
11290
  const packageDirectory = createPackageDirectory({ sourceDirectoryUrl });
10884
11291
  const clientFileChangeEventEmitter = createEventEmitter();
10885
11292
  const clientFileDereferencedEventEmitter = createEventEmitter();
11293
+ const reloadRequestEventEmitter = createEventEmitter();
10886
11294
  clientAutoreload = {
10887
11295
  enabled: true,
10888
11296
  clientServerEventsConfig: {},
10889
11297
  clientFileChangeEventEmitter,
10890
11298
  clientFileDereferencedEventEmitter,
11299
+ reloadRequestEventEmitter,
10891
11300
  ...clientAutoreload,
10892
11301
  };
11302
+ const dependencyProblemEventEmitter = createEventEmitter();
11303
+ const dependencyWatcher = watchDependencies(packageDirectory, {
11304
+ onChange: (problems) => {
11305
+ dependencyProblemEventEmitter.emit(problems);
11306
+ },
11307
+ onProblem: ({ packageName, declaredVersion, installedVersion, state }) => {
11308
+ logger.warn(
11309
+ state === "missing"
11310
+ ? `"${packageName}@${declaredVersion}" is declared in package.json but not installed, run npm install`
11311
+ : `"${packageName}" is installed in ${installedVersion} but package.json declares ${declaredVersion}, run npm install`,
11312
+ );
11313
+ },
11314
+ onInstalled: ({ packageName, declaredVersion }) => {
11315
+ logger.info(`"${packageName}@${declaredVersion}" is now installed`);
11316
+ reloadRequestEventEmitter.emit({
11317
+ cause: `${packageName}@${declaredVersion} installed`,
11318
+ reason: `a dependency became available in node_modules`,
11319
+ });
11320
+ },
11321
+ });
11322
+ serverStopCallbackSet.add(dependencyWatcher.stop);
10893
11323
 
10894
11324
  const devServerJsenvPluginStore = await createJsenvPluginStore([
10895
11325
  jsenvPluginServerEvents({ clientAutoreload }),
@@ -10924,6 +11354,10 @@ const startDevServer = async ({
10924
11354
 
10925
11355
  clientAutoreload,
10926
11356
  clientAutoreloadOnServerRestart,
11357
+ dependencyStatus: {
11358
+ dependencyProblemEventEmitter,
11359
+ getDependencyProblems: dependencyWatcher.getProblems,
11360
+ },
10927
11361
  cacheControl,
10928
11362
  ribbon,
10929
11363
  dropToOpen,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.3.1",
3
+ "version": "41.4.0",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -144,14 +144,14 @@
144
144
  "./dist/client/inline_content/inline_content.js",
145
145
  "./dist/client/directory_listing/jsenv_core_node_modules.js"
146
146
  ],
147
- "volta": {
148
- "node": "26.5.0",
149
- "npm": "11.17.0"
150
- },
151
147
  "allowScripts": {
152
148
  "unrs-resolver": true,
153
149
  "@playwright/browser-chromium": true,
154
150
  "@playwright/browser-firefox": true,
155
151
  "@playwright/browser-webkit": true
152
+ },
153
+ "volta": {
154
+ "node": "26.5.0",
155
+ "npm": "11.17.0"
156
156
  }
157
157
  }