@jsenv/core 41.4.17 → 41.4.19

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.
Files changed (30) hide show
  1. package/dist/build/build.js +693 -509
  2. package/dist/build/jsenv_core_packages.js +3 -187
  3. package/dist/client/directory_listing/js/directory_listing.js +23 -2
  4. package/dist/start_dev_server/start_dev_server.js +813 -625
  5. package/package.json +8 -8
  6. package/src/build/build.js +66 -15
  7. package/src/build/build_content_report.js +24 -15
  8. package/src/build/build_specifier_manager.js +2 -2
  9. package/src/dev/dev_server_plugins/dev_server_plugin_omega_error_handler.js +4 -1
  10. package/src/dev/dev_server_plugins/dev_server_plugin_serve_source_files.js +116 -68
  11. package/src/dev/start_dev_server.js +24 -4
  12. package/src/helpers/watch_source_files.js +25 -20
  13. package/src/kitchen/kitchen.js +6 -0
  14. package/src/kitchen/prepend_content.js +4 -4
  15. package/src/kitchen/url_graph/references.js +30 -13
  16. package/src/kitchen/url_graph/url_graph.js +0 -1
  17. package/src/kitchen/url_graph/url_info_transformations.js +16 -6
  18. package/src/plugins/import_meta_css/jsenv_plugin_import_meta_css.js +10 -40
  19. package/src/plugins/import_meta_hot/import_meta_hot_analysis.js +70 -0
  20. package/src/plugins/import_meta_hot/jsenv_plugin_import_meta_hot.js +35 -20
  21. package/src/plugins/import_meta_scenarios/jsenv_plugin_import_meta_scenarios.js +22 -56
  22. package/src/plugins/modulepreload/jsenv_plugin_modulepreload.js +128 -0
  23. package/src/plugins/plugins.js +3 -0
  24. package/src/plugins/protocol_file/client/directory_listing.jsx +27 -1
  25. package/src/plugins/protocol_file/jsenv_plugin_directory_listing.js +20 -0
  26. package/src/plugins/protocol_file/jsenv_plugin_fs_redirection.js +32 -15
  27. package/src/plugins/protocol_file/jsenv_plugin_protocol_file.js +11 -2
  28. package/src/plugins/reference_analysis/js/jsenv_plugin_js_reference_analysis.js +1 -2
  29. package/src/plugins/resolution_node_esm/node_esm_resolver.js +0 -5
  30. package/src/plugins/import_meta_hot/babel_plugin_metadata_import_meta_hot.js +0 -97
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.17",
3
+ "version": "41.4.19",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -72,13 +72,13 @@
72
72
  "test:snapshot_clear": "npx @jsenv/filesystem clear **/tests/**/side_effects/"
73
73
  },
74
74
  "dependencies": {
75
- "@jsenv/ast": "6.9.1",
76
- "@jsenv/js-module-fallback": "1.5.0",
77
- "@jsenv/plugin-bundling": "2.10.16",
78
- "@jsenv/plugin-minification": "1.7.8",
79
- "@jsenv/plugin-supervisor": "1.8.12",
80
- "@jsenv/plugin-transpilation": "1.6.0",
81
- "@jsenv/server": "17.6.1",
75
+ "@jsenv/ast": "6.9.2",
76
+ "@jsenv/js-module-fallback": "1.5.1",
77
+ "@jsenv/plugin-bundling": "2.10.18",
78
+ "@jsenv/plugin-minification": "1.7.10",
79
+ "@jsenv/plugin-supervisor": "1.8.13",
80
+ "@jsenv/plugin-transpilation": "1.6.1",
81
+ "@jsenv/server": "17.6.2",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
84
84
  },
@@ -38,10 +38,7 @@ import {
38
38
  UNICODE,
39
39
  } from "@jsenv/humanize";
40
40
  import { applyNodeEsmResolution } from "@jsenv/node-esm-resolution";
41
- import {
42
- startMonitoringCpuUsage,
43
- startMonitoringMemoryUsage,
44
- } from "@jsenv/os-metrics";
41
+ import { startMonitoringMemoryUsage } from "@jsenv/os-metrics";
45
42
  import { jsenvPluginBundling } from "@jsenv/plugin-bundling";
46
43
  import { jsenvPluginMinification } from "@jsenv/plugin-minification";
47
44
  import { jsenvPluginJsModuleFallback } from "@jsenv/plugin-transpilation";
@@ -395,13 +392,12 @@ entryPoints: {
395
392
  });
396
393
  }
397
394
 
398
- const cpuMonitoring = startMonitoringCpuUsage();
399
- operation.addEndCallback(cpuMonitoring.stop);
400
- const [processCpuUsageMonitoring] = cpuMonitoring;
395
+ // cpu is not sampled: cooking, rollup and terser hold the event loop for
396
+ // seconds at a time, a sampler only runs in the gaps and would report the
397
+ // idle time. What the build cost is process.cpuUsage() over its duration.
401
398
  const memoryMonitoring = startMonitoringMemoryUsage();
402
399
  const [processMemoryUsageMonitoring] = memoryMonitoring;
403
400
  const interval = setInterval(() => {
404
- processCpuUsageMonitoring.measure();
405
401
  processMemoryUsageMonitoring.measure();
406
402
  }, 500).unref();
407
403
  operation.addEndCallback(() => {
@@ -471,19 +467,24 @@ entryPoints: {
471
467
  content += "\n";
472
468
  return content;
473
469
  };
474
- const renderBuildEndLog = ({ duration, buildFileContents }) => {
470
+ const renderBuildEndLog = ({
471
+ duration,
472
+ buildFileContents,
473
+ cpuUsage,
474
+ phaseTimings,
475
+ }) => {
475
476
  // tell how many files are generated in build directory
476
477
  // tell the repartition?
477
478
  // this is not really useful for single build right?
478
479
 
479
- processCpuUsageMonitoring.end();
480
480
  processMemoryUsageMonitoring.end();
481
481
 
482
482
  return renderBuildDoneLog({
483
483
  entryPointArray,
484
484
  duration,
485
485
  buildFileContents,
486
- processCpuUsage: processCpuUsageMonitoring.info,
486
+ cpuUsage,
487
+ phaseTimings,
487
488
  processMemoryUsage: processMemoryUsageMonitoring.info,
488
489
  });
489
490
  };
@@ -558,13 +559,25 @@ entryPoints: {
558
559
  }, renderDynamicLog());
559
560
  };
560
561
  },
561
- onBuildEnd: ({ buildFileContents, duration }) => {
562
+ onBuildEnd: ({
563
+ buildFileContents,
564
+ duration,
565
+ cpuUsage,
566
+ phaseTimings,
567
+ }) => {
562
568
  clearInterval(interval);
563
569
  dynamicLog.update("");
564
570
  dynamicLog.destroy();
565
571
  dynamicLog = null;
566
572
  logger.info("");
567
- logger.info(renderBuildEndLog({ duration, buildFileContents }));
573
+ logger.info(
574
+ renderBuildEndLog({
575
+ duration,
576
+ buildFileContents,
577
+ cpuUsage,
578
+ phaseTimings,
579
+ }),
580
+ );
568
581
  },
569
582
  };
570
583
  };
@@ -591,8 +604,20 @@ entryPoints: {
591
604
  );
592
605
  };
593
606
  },
594
- onBuildEnd: ({ buildFileContents, duration }) => {
595
- logger.info(renderBuildEndLog({ duration, buildFileContents }));
607
+ onBuildEnd: ({
608
+ buildFileContents,
609
+ duration,
610
+ cpuUsage,
611
+ phaseTimings,
612
+ }) => {
613
+ logger.info(
614
+ renderBuildEndLog({
615
+ duration,
616
+ buildFileContents,
617
+ cpuUsage,
618
+ phaseTimings,
619
+ }),
620
+ );
596
621
  },
597
622
  };
598
623
  };
@@ -642,6 +667,7 @@ entryPoints: {
642
667
 
643
668
  const runBuild = async ({ signal }) => {
644
669
  const startDate = Date.now();
670
+ const cpuUsageAtStart = process.cpuUsage();
645
671
  const { onBuildEnd, onEntryPointBuildStart } = startBuildLogs();
646
672
 
647
673
  const buildUrlsGenerator = createBuildUrlsGenerator({
@@ -750,6 +776,7 @@ entryPoints: {
750
776
  entryBuildInfo.buildInlineContents = result.buildInlineContents;
751
777
  entryBuildInfo.buildManifest = result.buildManifest;
752
778
  entryBuildInfo.buildSideEffectFiles = result.buildSideEffectFiles;
779
+ entryBuildInfo.phaseTimings = result.phaseTimings;
753
780
  entryBuildInfo.duration = Date.now() - entryPointBuildStartMs;
754
781
  onEntryPointBuildEnd();
755
782
  })();
@@ -854,11 +881,19 @@ entryPoints: {
854
881
  }
855
882
  }
856
883
  }
884
+ // entry points are built concurrently, interleaved on the same thread:
885
+ // their phases overlap and only a single one can be told apart
886
+ const phaseTimings =
887
+ entryBuildInfoMap.size === 1
888
+ ? entryBuildInfoMap.values().next().value.phaseTimings
889
+ : null;
857
890
  onBuildEnd({
858
891
  buildFileContents,
859
892
  buildInlineContents,
860
893
  buildManifest,
861
894
  duration: Date.now() - startDate,
895
+ cpuUsage: process.cpuUsage(cpuUsageAtStart),
896
+ phaseTimings,
862
897
  });
863
898
  return {
864
899
  ...(returnBuildInlineContents ? { buildInlineContents } : {}),
@@ -1222,6 +1257,14 @@ const prepareEntryPointBuild = async (
1222
1257
  return {
1223
1258
  entryReference,
1224
1259
  buildEntryPoint: async ({ getOtherEntryBuildInfo }) => {
1260
+ const phaseTimings = {};
1261
+ let phaseStartMs = Date.now();
1262
+ const endPhase = (name) => {
1263
+ const nowMs = Date.now();
1264
+ phaseTimings[name] = nowMs - phaseStartMs;
1265
+ phaseStartMs = nowMs;
1266
+ };
1267
+
1225
1268
  craft: {
1226
1269
  _getOtherEntryBuildInfo = getOtherEntryBuildInfo;
1227
1270
  if (outDirectoryUrl) {
@@ -1229,6 +1272,7 @@ const prepareEntryPointBuild = async (
1229
1272
  }
1230
1273
  await rawRootUrlInfo.cookDependencies({ operation: buildOperation });
1231
1274
  }
1275
+ endPhase("craft");
1232
1276
 
1233
1277
  const finalKitchen = createKitchen({
1234
1278
  name: "shape",
@@ -1443,6 +1487,8 @@ const prepareEntryPointBuild = async (
1443
1487
  }
1444
1488
  }
1445
1489
 
1490
+ endPhase("bundle");
1491
+
1446
1492
  shape: {
1447
1493
  finalKitchen.context.buildStep = "shape";
1448
1494
  if (outDirectoryUrl) {
@@ -1462,6 +1508,8 @@ const prepareEntryPointBuild = async (
1462
1508
  });
1463
1509
  }
1464
1510
 
1511
+ endPhase("shape");
1512
+
1465
1513
  const buildSideEffectFiles = [];
1466
1514
  refine: {
1467
1515
  finalKitchen.context.buildStep = "refine";
@@ -1569,6 +1617,8 @@ const prepareEntryPointBuild = async (
1569
1617
  }
1570
1618
  }
1571
1619
  }
1620
+ endPhase("refine");
1621
+
1572
1622
  const {
1573
1623
  buildFileContents,
1574
1624
  buildFileVersions,
@@ -1588,6 +1638,7 @@ const prepareEntryPointBuild = async (
1588
1638
  buildInlineContents,
1589
1639
  buildManifest,
1590
1640
  buildSideEffectFiles,
1641
+ phaseTimings,
1591
1642
  };
1592
1643
  },
1593
1644
  };
@@ -19,11 +19,12 @@ ${createRepartitionMessage(buildContentReport, { indent })}
19
19
  --------------------`;
20
20
  };
21
21
 
22
- const humanizeProcessCpuUsage = (ratio) => {
23
- const percentageAsNumber = ratio * 100;
24
- const percentageAsNumberRounded = Math.round(percentageAsNumber);
25
- const percentage = `${percentageAsNumberRounded}%`;
26
- return percentage;
22
+ // a phase can be a few milliseconds long (refine, mostly): shown as such
23
+ const humanizePhaseDuration = (ms) => {
24
+ if (ms < 1000) {
25
+ return `${Math.round(ms)}ms`;
26
+ }
27
+ return humanizeDuration(ms, { short: true });
27
28
  };
28
29
  const humanizeProcessMemoryUsage = (value) => {
29
30
  return humanizeMemory(value, { short: true, decimals: 0 });
@@ -32,7 +33,8 @@ export const renderBuildDoneLog = ({
32
33
  entryPointArray,
33
34
  duration,
34
35
  buildFileContents,
35
- processCpuUsage,
36
+ cpuUsage,
37
+ phaseTimings,
36
38
  processMemoryUsage,
37
39
  }) => {
38
40
  const buildContentReport = createBuildContentReport(buildFileContents);
@@ -47,15 +49,10 @@ export const renderBuildDoneLog = ({
47
49
  title = `build done (${entryPointCount} entry points)`;
48
50
  }
49
51
 
50
- // cpu usage
51
- let cpuUsageLine = "cpu: ";
52
- cpuUsageLine += `${humanizeProcessCpuUsage(processCpuUsage.end)}`;
53
- cpuUsageLine += renderDetails({
54
- med: humanizeProcessCpuUsage(processCpuUsage.median),
55
- min: humanizeProcessCpuUsage(processCpuUsage.min),
56
- max: humanizeProcessCpuUsage(processCpuUsage.max),
57
- });
58
- lines.push(cpuUsageLine);
52
+ // cpu: the process cpu time over the build duration, 100% being one core
53
+ // busy the whole time (more when a native module works on other threads)
54
+ const cpuMs = (cpuUsage.user + cpuUsage.system) / 1000;
55
+ lines.push(`cpu: ${Math.round((cpuMs / duration) * 100)}%`);
59
56
 
60
57
  // memory usage
61
58
  let memoryUsageLine = "memory: ";
@@ -70,6 +67,16 @@ export const renderBuildDoneLog = ({
70
67
  // duration
71
68
  let durationLine = `duration: `;
72
69
  durationLine += humanizeDuration(duration, { short: true });
70
+ if (phaseTimings) {
71
+ durationLine += renderDetails(
72
+ Object.fromEntries(
73
+ Object.keys(phaseTimings).map((phaseName) => [
74
+ phaseName,
75
+ humanizePhaseDuration(phaseTimings[phaseName]),
76
+ ]),
77
+ ),
78
+ );
79
+ }
73
80
  lines.push(durationLine);
74
81
 
75
82
  // content
@@ -269,6 +276,8 @@ const createBuildContentReport = (buildFileContents) => {
269
276
  };
270
277
 
271
278
  const determineCategory = (buildRelativeUrl) => {
279
+ // a versioned url ("main.js?v=abc") is categorized by its file
280
+ buildRelativeUrl = buildRelativeUrl.replace(/[?#].*$/, "");
272
281
  if (buildRelativeUrl.endsWith(".map")) {
273
282
  return "sourcemap";
274
283
  }
@@ -801,7 +801,7 @@ export const createBuildSpecifierManager = ({
801
801
  }
802
802
  if (mayUsePlaceholder(urlInfo)) {
803
803
  const contentBeforeReplace = urlInfo.content;
804
- const { content, sourcemap } = placeholderAPI.replaceAll(
804
+ const replaceResult = placeholderAPI.replaceAll(
805
805
  contentBeforeReplace,
806
806
  (placeholder) => {
807
807
  const reference = placeholderToReferenceMap.get(placeholder);
@@ -809,7 +809,7 @@ export const createBuildSpecifierManager = ({
809
809
  return value;
810
810
  },
811
811
  );
812
- urlInfo.mutateContent({ content, sourcemap });
812
+ urlInfo.mutateContent(replaceResult);
813
813
  }
814
814
  },
815
815
  );
@@ -15,7 +15,10 @@ export const devServerPluginOmegaErrorHandler = () => {
15
15
  status: 403,
16
16
  };
17
17
  }
18
- return convertFileSystemErrorToResponseProperties(error);
18
+ // the dev server runs with canExposeSensitiveData: file paths are welcome
19
+ return convertFileSystemErrorToResponseProperties(error, {
20
+ canExposeSensitiveData: true,
21
+ });
19
22
  };
20
23
  const response = getResponseForError();
21
24
  if (!response) {
@@ -1,9 +1,8 @@
1
1
  import { bufferToEtag } from "@jsenv/filesystem";
2
2
  import { formatError } from "@jsenv/humanize";
3
3
  import { composeTwoResponses, fetchDirectory } from "@jsenv/server";
4
- import { URL_META } from "@jsenv/url-meta";
5
4
  import { normalizeUrl } from "@jsenv/urls";
6
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, statSync } from "node:fs";
7
6
 
8
7
  import { watchSourceFiles } from "../../helpers/watch_source_files.js";
9
8
  import { WEB_URL_CONVERTER } from "../../helpers/web_url_converter.js";
@@ -56,11 +55,10 @@ export const devServerPluginServeSourceFiles = ({
56
55
  if (existing) {
57
56
  return existing;
58
57
  }
59
- const watchAssociations = URL_META.resolveAssociations(
60
- { watch: stopWatchingSourceFiles.watchPatterns },
61
- sourceDirectoryUrl,
62
- );
63
58
  let kitchen;
59
+ // per url info, the file stat under which its content was last compared
60
+ // with the file on disk and found identical (see isValid)
61
+ const fileStatValidatedMap = new WeakMap();
64
62
  clientFileChangeEventEmitter.on(({ url, event }) => {
65
63
  const urlInfo = kitchen.graph.getUrlInfo(url);
66
64
  if (urlInfo) {
@@ -101,11 +99,6 @@ export const devServerPluginServeSourceFiles = ({
101
99
  packageDirectory,
102
100
  });
103
101
  kitchen.graph.urlInfoCreatedEventEmitter.on((urlInfoCreated) => {
104
- const { watch } = URL_META.applyAssociations({
105
- url: urlInfoCreated.url,
106
- associations: watchAssociations,
107
- });
108
- urlInfoCreated.isWatched = watch;
109
102
  // when an url depends on many others, we check all these (like package.json)
110
103
  urlInfoCreated.isValid = () => {
111
104
  const seenSet = new Set();
@@ -125,37 +118,64 @@ export const devServerPluginServeSourceFiles = ({
125
118
  // was compared using etag and it has changed
126
119
  return false;
127
120
  }
128
- // Watched files trust the watcher — except the ones marked
129
- // revalidateOnFileSystem (package.json files, see node_esm_resolver):
130
- // the watcher fires a beat AFTER a change, and what these files
131
- // decide (a package version, hence the ?v= the importer embeds) is
132
- // cached as immutable by the browser — a request racing the watcher
133
- // must not win a stale answer it would then keep forever.
134
- if (!urlInfo.isWatched || urlInfo.revalidateOnFileSystem) {
135
- // check the filesystem
136
- let fileContentAsBuffer;
121
+ // The content held in memory is the file as it was read; a request
122
+ // must never win a stale answer, whatever the watcher's latency,
123
+ // and the ?v= a package.json decides even ends up in the browser's
124
+ // immutable cache. So the file is checked on disk at every
125
+ // validation — cheaply: a stat, compared with the one taken when
126
+ // the content was read (or last found identical). Only a file
127
+ // whose stat moved is read and hashed again.
128
+ // Inline content (a <script> inside an html) has no file of its
129
+ // own: it is as fresh as the html holding it, checked by the caller.
130
+ if (!urlInfo.isInline) {
131
+ let fileStat;
137
132
  try {
138
- fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
139
- } catch (e) {
140
- if (e.code === "ENOENT") {
141
- urlInfo.onModified();
142
- return false;
143
- }
133
+ fileStat = statSync(new URL(urlInfo.url), {
134
+ throwIfNoEntry: false,
135
+ });
136
+ } catch {
144
137
  return false;
145
138
  }
146
- const fileContentEtag = bufferToEtag(fileContentAsBuffer);
147
- if (fileContentEtag !== urlInfo.originalContentEtag) {
139
+ if (!fileStat) {
148
140
  urlInfo.onModified();
149
- // restore content to be able to compare it again later
150
- urlInfo.kitchen.urlInfoTransformer.setContent(
151
- urlInfo,
152
- String(fileContentAsBuffer),
153
- {
154
- contentEtag: fileContentEtag,
155
- },
156
- );
157
141
  return false;
158
142
  }
143
+ const fileStatKnown =
144
+ fileStatValidatedMap.get(urlInfo) || urlInfo.data.fileStat;
145
+ const fileUnchanged =
146
+ fileStatKnown &&
147
+ fileStatKnown.mtimeMs === fileStat.mtimeMs &&
148
+ fileStatKnown.size === fileStat.size;
149
+ if (!fileUnchanged) {
150
+ let fileContentAsBuffer;
151
+ try {
152
+ fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
153
+ } catch (e) {
154
+ if (e.code === "ENOENT") {
155
+ urlInfo.onModified();
156
+ return false;
157
+ }
158
+ return false;
159
+ }
160
+ const fileContentEtag = bufferToEtag(fileContentAsBuffer);
161
+ if (fileContentEtag !== urlInfo.originalContentEtag) {
162
+ fileStatValidatedMap.delete(urlInfo);
163
+ urlInfo.onModified();
164
+ // restore content to be able to compare it again later
165
+ urlInfo.kitchen.urlInfoTransformer.setContent(
166
+ urlInfo,
167
+ String(fileContentAsBuffer),
168
+ {
169
+ contentEtag: fileContentEtag,
170
+ },
171
+ );
172
+ return false;
173
+ }
174
+ fileStatValidatedMap.set(urlInfo, {
175
+ mtimeMs: fileStat.mtimeMs,
176
+ size: fileStat.size,
177
+ });
178
+ }
159
179
  }
160
180
  for (const implicitUrl of urlInfo.implicitUrlSet) {
161
181
  const implicitUrlInfo = urlInfo.graph.getUrlInfo(implicitUrl);
@@ -271,6 +291,40 @@ export const devServerPluginServeSourceFiles = ({
271
291
  const ifNoneMatch = request.headers["if-none-match"];
272
292
  const inlineParentUrlInfo = urlInfo.findParentIfInline();
273
293
  const urlInfoTargetedByCache = inlineParentUrlInfo || urlInfo;
294
+ // The content held in memory is the response when it is finalized
295
+ // and still valid. Content can be defined while a cook is still in
296
+ // flight (a file watcher invalidation re-cooking in the background,
297
+ // for instance): at that point it holds the raw fetched content,
298
+ // transformations not applied yet. Serving that would send an html
299
+ // without any of the injected scripts. An inline url info (a
300
+ // <script> inside an html) is cooked again whenever the html
301
+ // containing it is cooked: its content is as fresh as the html's,
302
+ // so both must be valid.
303
+ const hasFreshContent = (urlInfo) =>
304
+ urlInfo.content !== undefined &&
305
+ urlInfo.contentFinalized &&
306
+ urlInfo.isValid();
307
+ const memoryContentIsFresh = () =>
308
+ inlineParentUrlInfo
309
+ ? hasFreshContent(urlInfo) && hasFreshContent(inlineParentUrlInfo)
310
+ : hasFreshContent(urlInfo);
311
+ // a 304 goes through the hooks too: its headers stand for the
312
+ // cached response's, they must say the same
313
+ const augmentResponse = (response) => {
314
+ const augmentResponseInfo = {
315
+ ...kitchen.context,
316
+ reference,
317
+ urlInfo,
318
+ };
319
+ kitchen.jsenvPluginsController.callHooks(
320
+ "augmentResponse",
321
+ augmentResponseInfo,
322
+ (returnValue) => {
323
+ response = composeTwoResponses(response, returnValue);
324
+ },
325
+ );
326
+ return response;
327
+ };
274
328
  const respondWithNotModified = () => {
275
329
  const headers = {
276
330
  "cache-control": `private,max-age=0,must-revalidate`,
@@ -280,23 +334,21 @@ export const devServerPluginServeSourceFiles = ({
280
334
  headers[key] = urlInfo.headers[key];
281
335
  }
282
336
  });
283
- return {
337
+ return augmentResponse({
284
338
  status: 304,
285
339
  headers,
286
- };
340
+ });
287
341
  };
288
342
 
289
343
  try {
290
- // an inline url info is cooked again every time the file containing it
291
- // is cooked, so its content is only known after cooking; its etag is
292
- // compared below, once cooked
293
- if (!urlInfo.error && ifNoneMatch && !inlineParentUrlInfo) {
344
+ if (!urlInfo.error && ifNoneMatch) {
294
345
  const [clientOriginalContentEtag, clientContentEtag] =
295
346
  ifNoneMatch.split("_");
296
347
  if (
297
- urlInfo.originalContentEtag === clientOriginalContentEtag &&
348
+ urlInfoTargetedByCache.originalContentEtag ===
349
+ clientOriginalContentEtag &&
298
350
  urlInfo.contentEtag === clientContentEtag &&
299
- urlInfo.isValid()
351
+ memoryContentIsFresh()
300
352
  ) {
301
353
  return respondWithNotModified();
302
354
  }
@@ -314,18 +366,9 @@ export const devServerPluginServeSourceFiles = ({
314
366
  // client etag to match).
315
367
  const servableFromMemory =
316
368
  !urlInfo.error &&
317
- !inlineParentUrlInfo &&
318
369
  !urlInfo.response &&
319
- urlInfo.content !== undefined &&
320
- // content can be defined while a cook is still in flight (a file
321
- // watcher invalidation re-cooking in the background, for
322
- // instance): at that point it holds the raw fetched content,
323
- // transformations not applied yet. Serving that would send an
324
- // html without any of the injected scripts. Only finalized
325
- // content is a complete response; anything else must go through
326
- // cook() below, which joins the pending cook (see debounceCook).
327
- urlInfo.contentFinalized &&
328
370
  !cacheIsDisabledInResponseHeader(urlInfo) &&
371
+ !cacheIsDisabledInResponseHeader(urlInfoTargetedByCache) &&
329
372
  // a "?hot" request exists to bypass every cache, this one
330
373
  // included: it must be cooked, because cooking is what rewrites
331
374
  // its references so "?hot" cascades to the modified files below
@@ -344,7 +387,7 @@ export const devServerPluginServeSourceFiles = ({
344
387
  // signal registry throwing on duplicate ids). Re-cooking under
345
388
  // the normal request rewrites the references clean.
346
389
  !urlInfo.contentCookedForHotRequest &&
347
- urlInfo.isValid();
390
+ memoryContentIsFresh();
348
391
  if (!servableFromMemory) {
349
392
  await urlInfo.cook({ request, reference });
350
393
  urlInfo.contentCookedForHotRequest =
@@ -402,19 +445,7 @@ export const devServerPluginServeSourceFiles = ({
402
445
  ? { "served from memory cache": null }
403
446
  : urlInfo.timing,
404
447
  };
405
- const augmentResponseInfo = {
406
- ...kitchen.context,
407
- reference,
408
- urlInfo,
409
- };
410
- kitchen.jsenvPluginsController.callHooks(
411
- "augmentResponse",
412
- augmentResponseInfo,
413
- (returnValue) => {
414
- response = composeTwoResponses(response, returnValue);
415
- },
416
- );
417
- return response;
448
+ return augmentResponse(response);
418
449
  } catch (error) {
419
450
  const originalError = error ? error.cause || error : error;
420
451
  if (originalError.asResponse) {
@@ -492,6 +523,14 @@ export const devServerPluginServeSourceFiles = ({
492
523
  "cache-control": "no-store",
493
524
  },
494
525
  };
526
+ } finally {
527
+ // What the request put on the url info context is for this
528
+ // request only: a cook happening later for another reason (the
529
+ // html holding an inline script is cooked again) must not see a
530
+ // stale "requestedUrl" and take the inline script for a direct
531
+ // request, which would make it pick the content of a previous
532
+ // reference (see jsenv:inline_content_fetcher).
533
+ forgetRequestFromContext(urlInfo.context);
495
534
  }
496
535
  },
497
536
  },
@@ -501,6 +540,15 @@ export const devServerPluginServeSourceFiles = ({
501
540
  return [devServerPluginRoutes, ...devServerJsenvPluginStore.allServerPlugins];
502
541
  };
503
542
 
543
+ const forgetRequestFromContext = (context) => {
544
+ // own properties only: what is inherited from the owner context stays
545
+ for (const key of ["request", "requestedUrl", "reference"]) {
546
+ if (Object.hasOwn(context, key)) {
547
+ delete context[key];
548
+ }
549
+ }
550
+ };
551
+
504
552
  const cacheIsDisabledInResponseHeader = (urlInfo) => {
505
553
  return (
506
554
  urlInfo.headers["cache-control"] === "no-store" ||
@@ -8,8 +8,13 @@ import {
8
8
  serverPluginCORS,
9
9
  startServer,
10
10
  } from "@jsenv/server";
11
- import { urlIsOrIsInsideOf, urlToRelativeUrl } from "@jsenv/urls";
11
+ import {
12
+ urlIsOrIsInsideOf,
13
+ urlToFileSystemPath,
14
+ urlToRelativeUrl,
15
+ } from "@jsenv/urls";
12
16
  import { existsSync } from "node:fs";
17
+ import { pathToFileURL } from "node:url";
13
18
 
14
19
  import { defaultRuntimeCompat } from "../build/build_params.js";
15
20
  import { createEventEmitter } from "../helpers/event_emitter.js";
@@ -50,6 +55,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
50
55
  * @param {boolean|object} [params.serverTiming={ minDuration: 0.5 }] - server-timing response headers; `minDuration` (ms) drops entries that took less (0 when run by the test plan, so tests see every entry).
51
56
  * @param {boolean|object} [params.ribbon=true] - The "ribbon" overlay marking the page as non-production. As an object: `text` (defaults to `"DEV"`), `color` (background, defaults to `"orange"`), `textColor` (defaults to dark or light depending on `color`), `href` (turns the ribbon into a link; only then does it capture clicks), `target` (defaults to `"_blank"`), `position` (`"top-right"` (default), `"top-left"`, `"bottom-right"`, `"bottom-left"` draw a diagonal corner ribbon; `"top"`, `"bottom"` draw a full width band), `htmlInclude` (defaults to `"/**\/*.html"`).
52
57
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
58
+ * @param {boolean} [params.modulepreload=false] - Send `Link: <url>; rel=modulepreload` response headers listing the static import graph of a page (as far as the graph knows it). Off by default, to enable once the server runs on http/2 or http/3: over http/1.1 the preloads take the 6 connections per origin ahead of the render-blocking scripts, and pages get slower (the why, with measures, in jsenv_plugin_modulepreload.js).
53
59
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
54
60
  * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
55
61
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
@@ -91,6 +97,7 @@ export const startDevServer = async ({
91
97
  sourceFilesConfig = {},
92
98
  clientAutoreload = true,
93
99
  clientAutoreloadOnServerRestart = true,
100
+ modulepreload = false,
94
101
  // server-timing response headers: devtools show how the time to answer is
95
102
  // spent (cook measures come from the kitchen, see urlInfo.timing). Entries
96
103
  // under minDuration are dropped so a human reads the measures that matter;
@@ -181,9 +188,12 @@ export const startDevServer = async ({
181
188
  }
182
189
 
183
190
  const logger = createLogger({ logLevel });
184
- const startDevServerTask = createTaskLog("start dev server", {
185
- disabled: !logger.levels.info,
186
- });
191
+ const startDevServerTask = createTaskLog(
192
+ `start dev server for ${humanizeSourceDirectory(sourceDirectoryUrl)}`,
193
+ {
194
+ disabled: !logger.levels.info,
195
+ },
196
+ );
187
197
 
188
198
  const serverStopCallbackSet = new Set();
189
199
  const serverStopAbortController = new AbortController();
@@ -262,6 +272,7 @@ export const startDevServer = async ({
262
272
 
263
273
  clientAutoreload,
264
274
  clientAutoreloadOnServerRestart,
275
+ modulepreload,
265
276
  dependencyStatus: {
266
277
  dependencyProblemEventEmitter,
267
278
  getDependencyProblems: dependencyWatcher.getProblems,
@@ -366,3 +377,12 @@ export const startDevServer = async ({
366
377
  kitchenCache,
367
378
  };
368
379
  };
380
+
381
+ // "./src/" when inside the current working directory, the full path otherwise
382
+ const humanizeSourceDirectory = (sourceDirectoryUrl) => {
383
+ const cwdUrl = pathToFileURL(`${process.cwd()}/`).href;
384
+ if (urlIsOrIsInsideOf(sourceDirectoryUrl, cwdUrl)) {
385
+ return `./${urlToRelativeUrl(sourceDirectoryUrl, cwdUrl)}`;
386
+ }
387
+ return urlToFileSystemPath(sourceDirectoryUrl);
388
+ };