@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.
@@ -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,
@@ -6798,6 +7180,16 @@ const jsenvPluginWorkspaceBundle = ({ packageDirectory }) => {
6798
7180
  // root package, we don't want to bundle
6799
7181
  return null;
6800
7182
  }
7183
+ if (reference.type !== "js_import") {
7184
+ // Only consolidate ES imports of a workspace package into its single
7185
+ // bundle. Other reference kinds are their own entry points — most
7186
+ // importantly an HTML <script src> pointing at a package file (jsenv
7187
+ // injects its own client scripts that way: server events, the client
7188
+ // monitoring reporter, custom-elements-redefine…). Redirecting those to
7189
+ // the package main would run the wrong module, and for @jsenv/core the
7190
+ // main is node-only code (node:url) that cannot be served to a browser.
7191
+ return null;
7192
+ }
6801
7193
  // we make sure we target the bundle version of the package
6802
7194
  // otherwise we might execute some parts of the package code multiple times.
6803
7195
  // so we need to redirect the potential reference to non entry point to the package main entry point
@@ -6903,6 +7295,7 @@ const getCorePlugins = ({
6903
7295
 
6904
7296
  clientAutoreload,
6905
7297
  clientAutoreloadOnServerRestart,
7298
+ dependencyStatus,
6906
7299
  cacheControl,
6907
7300
  scenarioPlaceholders = true,
6908
7301
  ribbon = true,
@@ -7001,6 +7394,9 @@ const getCorePlugins = ({
7001
7394
  ...(clientAutoreload && clientAutoreload.enabled
7002
7395
  ? [jsenvPluginAutoreload(clientAutoreload)]
7003
7396
  : []),
7397
+ ...(dependencyStatus
7398
+ ? [jsenvPluginDependencyStatus(dependencyStatus)]
7399
+ : []),
7004
7400
  ...(cacheControl ? [jsenvPluginCacheControl(cacheControl)] : []),
7005
7401
  ...(ribbon ? [jsenvPluginRibbon({ rootDirectoryUrl, ...ribbon })] : []),
7006
7402
  ...(dropToOpen ? [jsenvPluginDropToOpen()] : []),
@@ -10551,31 +10947,36 @@ const devServerPluginServeSourceFiles = ({
10551
10947
  }
10552
10948
  const urlInfo = reference.urlInfo;
10553
10949
  const ifNoneMatch = request.headers["if-none-match"];
10554
- const urlInfoTargetedByCache =
10555
- 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
+ };
10556
10966
 
10557
10967
  try {
10558
- 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) {
10559
10972
  const [clientOriginalContentEtag, clientContentEtag] =
10560
10973
  ifNoneMatch.split("_");
10561
10974
  if (
10562
- urlInfoTargetedByCache.originalContentEtag ===
10563
- clientOriginalContentEtag &&
10564
- urlInfoTargetedByCache.contentEtag === clientContentEtag &&
10565
- urlInfoTargetedByCache.isValid()
10975
+ urlInfo.originalContentEtag === clientOriginalContentEtag &&
10976
+ urlInfo.contentEtag === clientContentEtag &&
10977
+ urlInfo.isValid()
10566
10978
  ) {
10567
- const headers = {
10568
- "cache-control": `private,max-age=0,must-revalidate`,
10569
- };
10570
- Object.keys(urlInfo.headers).forEach((key) => {
10571
- if (key !== "content-length") {
10572
- headers[key] = urlInfo.headers[key];
10573
- }
10574
- });
10575
- return {
10576
- status: 304,
10577
- headers,
10578
- };
10979
+ return respondWithNotModified();
10579
10980
  }
10580
10981
  }
10581
10982
  await urlInfo.cook({ request, reference });
@@ -10583,6 +10984,19 @@ const devServerPluginServeSourceFiles = ({
10583
10984
  if (response) {
10584
10985
  return response;
10585
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
+ }
10586
11000
  response = {
10587
11001
  url: reference.url,
10588
11002
  status: 200,
@@ -10599,7 +11013,7 @@ const devServerPluginServeSourceFiles = ({
10599
11013
  : {
10600
11014
  "cache-control": `private,max-age=0,must-revalidate`,
10601
11015
  // it's safe to use "_" separator because etag is encoded with base64 (see https://stackoverflow.com/a/13195197)
10602
- "eTag": `${urlInfoTargetedByCache.originalContentEtag}_${urlInfoTargetedByCache.contentEtag}`,
11016
+ eTag,
10603
11017
  }),
10604
11018
  ...urlInfo.headers,
10605
11019
  "content-type": urlInfo.contentType,
@@ -10678,7 +11092,10 @@ const devServerPluginServeSourceFiles = ({
10678
11092
  statusText: originalError.reason,
10679
11093
  };
10680
11094
  }
10681
- 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") {
10682
11099
  return {
10683
11100
  url: reference.url,
10684
11101
  status: 404,
@@ -10873,13 +11290,36 @@ const startDevServer = async ({
10873
11290
  const packageDirectory = createPackageDirectory({ sourceDirectoryUrl });
10874
11291
  const clientFileChangeEventEmitter = createEventEmitter();
10875
11292
  const clientFileDereferencedEventEmitter = createEventEmitter();
11293
+ const reloadRequestEventEmitter = createEventEmitter();
10876
11294
  clientAutoreload = {
10877
11295
  enabled: true,
10878
11296
  clientServerEventsConfig: {},
10879
11297
  clientFileChangeEventEmitter,
10880
11298
  clientFileDereferencedEventEmitter,
11299
+ reloadRequestEventEmitter,
10881
11300
  ...clientAutoreload,
10882
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);
10883
11323
 
10884
11324
  const devServerJsenvPluginStore = await createJsenvPluginStore([
10885
11325
  jsenvPluginServerEvents({ clientAutoreload }),
@@ -10914,6 +11354,10 @@ const startDevServer = async ({
10914
11354
 
10915
11355
  clientAutoreload,
10916
11356
  clientAutoreloadOnServerRestart,
11357
+ dependencyStatus: {
11358
+ dependencyProblemEventEmitter,
11359
+ getDependencyProblems: dependencyWatcher.getProblems,
11360
+ },
10917
11361
  cacheControl,
10918
11362
  ribbon,
10919
11363
  dropToOpen,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.3.0",
3
+ "version": "41.4.0",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -78,7 +78,7 @@
78
78
  "@jsenv/plugin-minification": "1.7.5",
79
79
  "@jsenv/plugin-supervisor": "1.8.7",
80
80
  "@jsenv/plugin-transpilation": "1.5.78",
81
- "@jsenv/server": "17.4.0",
81
+ "@jsenv/server": "17.4.1",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
84
84
  },
@@ -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
  }