@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
@@ -1,12 +1,12 @@
1
- import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
- import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
3
- import { urlToRelativeUrl, registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, 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";
1
+ import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, fetchDirectory, composeTwoResponses, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
+ import { existsSync, readFileSync, realpathSync, readdirSync, lstatSync, statSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ import { urlToRelativeUrl, registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, registerDirectoryLifecycle, readEntryStatSync, applyFileSystemMagicResolution, getExtensionsToTry, urlToFilename, asUrlWithoutSearch, ensurePathnameTrailingSlash, compareFileUrls, 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
5
  import { createPluginsController } from "@jsenv/server/src/plugins_controller.js";
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
+ import { parseHtml, injectJsenvScript, stringifyHtmlAst, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, applyBabelPlugins, visitJsAst, getImportMetaPropertyName, visitJsAstUntil, analyzeLinkNode, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, generateUrlForInlineContent, parseJsWithAcorn } from "@jsenv/ast";
6
7
  import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
7
8
  import { jsenvPluginTranspilation } from "@jsenv/plugin-transpilation";
8
9
  import { createMagicSource, composeTwoSourcemaps, generateSourcemapFileUrl, generateSourcemapDataUrl, SOURCEMAP } from "@jsenv/sourcemap";
9
- import { pathToFileURL } from "node:url";
10
10
  import { bundleJsModules } from "@jsenv/plugin-bundling";
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { convertFileSystemErrorToResponseProperties } from "@jsenv/server/src/plugins/filesystem/filesystem_error_to_response.js";
@@ -2467,8 +2467,7 @@ const parseAndTransformJsReferences = async (
2467
2467
  for (const sequentialAction of sequentialActions) {
2468
2468
  await sequentialAction();
2469
2469
  }
2470
- const { content, sourcemap } = magicSource.toContentAndSourcemap();
2471
- return { content, sourcemap };
2470
+ return magicSource.toContentAndSourcemap();
2472
2471
  };
2473
2472
 
2474
2473
  const jsenvPluginReferenceExpectedTypes = () => {
@@ -3129,11 +3128,6 @@ const addRelationshipWithPackageJson = ({
3129
3128
  String(packageJsonContentAsBuffer),
3130
3129
  );
3131
3130
  }
3132
- // Checked on disk at every validation rather than trusted to the watcher:
3133
- // what this file decides (the package version, hence the ?v= importers
3134
- // embed) ends up in the browser's immutable cache, so a request racing the
3135
- // watcher must never be answered from a stale package.json.
3136
- packageJsonReference.urlInfo.revalidateOnFileSystem = true;
3137
3131
  };
3138
3132
 
3139
3133
  const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
@@ -3506,18 +3500,21 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
3506
3500
  const getDirectoryWatchPatterns = (
3507
3501
  directoryUrl,
3508
3502
  watchedDirectoryUrl,
3509
- { sourceFilesConfig },
3503
+ { sourceFilesConfig, defaultPatterns = true },
3510
3504
  ) => {
3511
3505
  const directoryUrlRelativeToWatchedDirectory = urlToRelativeUrl(
3512
3506
  directoryUrl,
3513
3507
  watchedDirectoryUrl,
3514
3508
  );
3515
- const watchPatterns = {
3516
- [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
3517
- [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
3518
- [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
3519
- [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
3520
- };
3509
+ const watchPatterns = {};
3510
+ if (defaultPatterns) {
3511
+ Object.assign(watchPatterns, {
3512
+ [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
3513
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
3514
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
3515
+ [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
3516
+ });
3517
+ }
3521
3518
  for (const key of Object.keys(sourceFilesConfig)) {
3522
3519
  watchPatterns[`${directoryUrlRelativeToWatchedDirectory}${key}`] =
3523
3520
  sourceFilesConfig[key];
@@ -3538,11 +3535,12 @@ const watchSourceFiles = (
3538
3535
  // And jsenv should not consider these as source files and watch them (to not hurt performances)
3539
3536
  const watchPatterns = {};
3540
3537
  let watchedDirectoryUrl = "";
3541
- const addDirectoryToWatch = (directoryUrl) => {
3538
+ const addDirectoryToWatch = (directoryUrl, { defaultPatterns } = {}) => {
3542
3539
  Object.assign(
3543
3540
  watchPatterns,
3544
3541
  getDirectoryWatchPatterns(directoryUrl, watchedDirectoryUrl, {
3545
3542
  sourceFilesConfig,
3543
+ defaultPatterns,
3546
3544
  }),
3547
3545
  );
3548
3546
  };
@@ -3574,7 +3572,6 @@ const watchSourceFiles = (
3574
3572
  },
3575
3573
  },
3576
3574
  );
3577
- stopWatchingSourceFiles.watchPatterns = watchPatterns;
3578
3575
  return stopWatchingSourceFiles;
3579
3576
  };
3580
3577
 
@@ -3594,16 +3591,18 @@ const watchSourceFiles = (
3594
3591
  }
3595
3592
  watchedDirectoryUrl = packageDirectoryUrl;
3596
3593
  for (const workspace of workspaces) {
3597
- if (workspace.endsWith("*")) {
3598
- const workspaceDirectoryUrl = new URL(
3599
- workspace.slice(0, -1),
3600
- packageDirectoryUrl,
3601
- );
3602
- addDirectoryToWatch(workspaceDirectoryUrl);
3603
- } else {
3604
- const workspaceRelativeUrl = new URL(workspace, packageDirectoryUrl);
3605
- addDirectoryToWatch(workspaceRelativeUrl);
3606
- }
3594
+ const workspaceDirectoryUrl = workspace.endsWith("*")
3595
+ ? new URL(workspace.slice(0, -1), packageDirectoryUrl)
3596
+ : new URL(workspace, packageDirectoryUrl);
3597
+ addDirectoryToWatch(workspaceDirectoryUrl, {
3598
+ // The source directory patterns already cover a workspace inside it.
3599
+ // Every pattern is tested against every file found while watching, so
3600
+ // the same patterns rooted at each workspace only multiply that work.
3601
+ defaultPatterns: !urlIsOrIsInsideOf(
3602
+ workspaceDirectoryUrl,
3603
+ sourceDirectoryUrl,
3604
+ ),
3605
+ });
3607
3606
  }
3608
3607
  // we are updating the root directory
3609
3608
  // we must make the patterns relative to source directory relative to the new root directory
@@ -3616,6 +3615,240 @@ const watchSourceFiles = (
3616
3615
  return watch();
3617
3616
  };
3618
3617
 
3618
+ const jsenvPluginFsRedirection = ({
3619
+ spa,
3620
+ directoryContentMagicName,
3621
+ magicExtensions = ["inherit", ".js"],
3622
+ magicDirectoryIndex = true,
3623
+ preserveSymlinks = false,
3624
+ }) => {
3625
+ return {
3626
+ name: "jsenv:fs_redirection",
3627
+ appliesDuring: "*",
3628
+ redirectReference: (reference) => {
3629
+ if (reference.url === "file:///") {
3630
+ return `ignore:file:///`;
3631
+ }
3632
+ if (reference.url === "file://") {
3633
+ return `ignore:file://`;
3634
+ }
3635
+ // ignore all new URL second arg
3636
+ if (reference.subtype === "new_url_second_arg") {
3637
+ if (reference.original) {
3638
+ return `ignore:${reference.original.specifier}`;
3639
+ }
3640
+ return `ignore:${reference.specifier}`;
3641
+ }
3642
+ // http, https, data, about, ...
3643
+ if (!reference.url.startsWith("file:")) {
3644
+ return null;
3645
+ }
3646
+ if (reference.original && !reference.original.url.startsWith("file:")) {
3647
+ return null;
3648
+ }
3649
+ if (reference.isInline) {
3650
+ return null;
3651
+ }
3652
+
3653
+ if (
3654
+ reference.specifierPathname.endsWith(`/${directoryContentMagicName}`)
3655
+ ) {
3656
+ const { rootDirectoryUrl } = reference.ownerUrlInfo.context;
3657
+ const directoryUrl = new URL(
3658
+ reference.specifierPathname
3659
+ .replace(`/${directoryContentMagicName}`, "/")
3660
+ .slice(1),
3661
+ rootDirectoryUrl,
3662
+ ).href;
3663
+ return directoryUrl;
3664
+ }
3665
+ // ignore "./" on new URL("./")
3666
+ // if (
3667
+ // reference.subtype === "new_url_first_arg" &&
3668
+ // reference.specifier === "./"
3669
+ // ) {
3670
+ // return `ignore:${reference.url}`;
3671
+ // }
3672
+ const urlObject = new URL(reference.url);
3673
+ let fsStat = readEntryStatSync(urlObject, { nullIfNotFound: true });
3674
+ reference.fsStat = fsStat;
3675
+ const { search, hash } = urlObject;
3676
+ urlObject.search = "";
3677
+ urlObject.hash = "";
3678
+ // must be read before applyFsStatEffectsOnUrlObject which forces the
3679
+ // trailing slash on directories
3680
+ const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
3681
+ applyFsStatEffectsOnUrlObject(urlObject, fsStat);
3682
+ const shouldApplyFilesystemMagicResolution =
3683
+ reference.type === "js_import";
3684
+ if (shouldApplyFilesystemMagicResolution) {
3685
+ const filesystemResolution = applyFileSystemMagicResolution(
3686
+ urlObject.href,
3687
+ {
3688
+ fileStat: fsStat,
3689
+ magicDirectoryIndex,
3690
+ magicExtensions: getExtensionsToTry(
3691
+ magicExtensions,
3692
+ reference.ownerUrlInfo.url,
3693
+ ),
3694
+ },
3695
+ );
3696
+ if (filesystemResolution.stat) {
3697
+ fsStat = filesystemResolution.stat;
3698
+ reference.fsStat = fsStat;
3699
+ urlObject.href = filesystemResolution.url;
3700
+ applyFsStatEffectsOnUrlObject(urlObject, fsStat);
3701
+ }
3702
+ }
3703
+ if (spa) {
3704
+ // for SPA we want to serve the root HTML file most of the time
3705
+ if (!fsStat) {
3706
+ if (urlToExtension(urlObject)) {
3707
+ // url has an extension, we assume it's a file request -> let 404 happen
3708
+ return null;
3709
+ }
3710
+ if (specifierUsesTrailingSlash) {
3711
+ // the trailing slash asks for a directory and there is none here
3712
+ // -> let 404 happen (same reasoning as the extension above)
3713
+ return null;
3714
+ }
3715
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
3716
+ if (spaFallbackUrl) {
3717
+ return spaFallbackUrl;
3718
+ }
3719
+ return null;
3720
+ }
3721
+ if (fsStat.isDirectory()) {
3722
+ // When requesting a directory, check if we have an HTML entry file for that directory
3723
+ const directoryEntryFileUrl = getDirectoryEntryFileUrl(urlObject);
3724
+ if (directoryEntryFileUrl) {
3725
+ reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
3726
+ return directoryEntryFileUrl;
3727
+ }
3728
+ if (!specifierUsesTrailingSlash) {
3729
+ // the trailing slash is what tells a directory apart from a route:
3730
+ // "/join/" is the directory, "/join" is a route owned by the SPA
3731
+ // even when "join/" exists in the source files.
3732
+ // Without this a source directory would shadow the route having
3733
+ // the same name and the SPA would be unreachable in dev while
3734
+ // being perfectly fine once built
3735
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
3736
+ if (spaFallbackUrl) {
3737
+ reference.fsStat = readEntryStatSync(spaFallbackUrl, {
3738
+ nullIfNotFound: true,
3739
+ });
3740
+ return spaFallbackUrl;
3741
+ }
3742
+ }
3743
+ }
3744
+ }
3745
+ if (!fsStat) {
3746
+ return null;
3747
+ }
3748
+ const urlBeforeSymlinkResolution = urlObject.href;
3749
+ if (preserveSymlinks) {
3750
+ return `${urlBeforeSymlinkResolution}${search}${hash}`;
3751
+ }
3752
+ const urlAfterSymlinkResolution = resolveSymlink(
3753
+ urlBeforeSymlinkResolution,
3754
+ );
3755
+ if (urlAfterSymlinkResolution !== urlBeforeSymlinkResolution) {
3756
+ reference.leadsToASymlink = true;
3757
+ // reference.baseUrl = urlBeforeSymlinkResolution;
3758
+ }
3759
+ const resolvedUrl = `${urlAfterSymlinkResolution}${search}${hash}`;
3760
+ return resolvedUrl;
3761
+ },
3762
+ };
3763
+ };
3764
+
3765
+ const applyFsStatEffectsOnUrlObject = (urlObject, fsStat) => {
3766
+ if (!fsStat) {
3767
+ return;
3768
+ }
3769
+ const { pathname } = urlObject;
3770
+ const pathnameUsesTrailingSlash = pathname.endsWith("/");
3771
+ // force trailing slash on directories
3772
+ if (fsStat.isDirectory()) {
3773
+ if (!pathnameUsesTrailingSlash) {
3774
+ urlObject.pathname = `${pathname}/`;
3775
+ }
3776
+ } else if (pathnameUsesTrailingSlash) {
3777
+ // otherwise remove trailing slash if any
3778
+ // a warning here? (because it's strange to reference a file with a trailing slash)
3779
+ urlObject.pathname = pathname.slice(0, -1);
3780
+ }
3781
+ };
3782
+
3783
+ const resolveSymlink = (fileUrl) => {
3784
+ const urlObject = new URL(fileUrl);
3785
+ const realpath = realpathSync(urlObject);
3786
+ const realUrlObject = pathToFileURL(realpath);
3787
+ if (urlObject.pathname.endsWith("/")) {
3788
+ realUrlObject.pathname += `/`;
3789
+ }
3790
+ return realUrlObject.href;
3791
+ };
3792
+
3793
+ const getDirectoryEntryFileUrl = (directoryUrl) => {
3794
+ const indexHtmlFileUrl = new URL(`index.html`, directoryUrl);
3795
+ if (existsSync(indexHtmlFileUrl)) {
3796
+ return indexHtmlFileUrl.href;
3797
+ }
3798
+ const filename = urlToFilename(directoryUrl);
3799
+ const htmlFileUrlCandidate = new URL(`${filename}.html`, directoryUrl);
3800
+ if (existsSync(htmlFileUrlCandidate)) {
3801
+ return htmlFileUrlCandidate.href;
3802
+ }
3803
+ return null;
3804
+ };
3805
+ const getSpaFallbackUrl = (reference) => {
3806
+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
3807
+ reference.ownerUrlInfo.context;
3808
+ if (!requestedUrl) {
3809
+ // the SPA fallback answers a request; during build there is none
3810
+ return null;
3811
+ }
3812
+ const spaFallbackFileUrls = listSpaFallbackFileUrls(requestedUrl, {
3813
+ rootDirectoryUrl,
3814
+ mainFilePath,
3815
+ });
3816
+ for (const spaFallbackFileUrl of spaFallbackFileUrls) {
3817
+ if (existsSync(new URL(spaFallbackFileUrl))) {
3818
+ return spaFallbackFileUrl;
3819
+ }
3820
+ }
3821
+ // none exists: the main file it is, and the 404 answering for it lists
3822
+ // what was tried (see directory listing)
3823
+ return new URL(mainFilePath, rootDirectoryUrl).href;
3824
+ };
3825
+ // The html files that can answer a route, closest first: the entry file of
3826
+ // the route's own directory ("index.html", then "<dirname>.html"), then of
3827
+ // each directory above it up to the server root, then the main file.
3828
+ const listSpaFallbackFileUrls = (
3829
+ requestedUrl,
3830
+ { rootDirectoryUrl, mainFilePath },
3831
+ ) => {
3832
+ const fileUrls = [];
3833
+ let directoryUrl = new URL("./", requestedUrl).href;
3834
+ while (urlIsOrIsInsideOf(directoryUrl, rootDirectoryUrl)) {
3835
+ fileUrls.push(new URL("index.html", directoryUrl).href);
3836
+ const filename = urlToFilename(directoryUrl);
3837
+ if (filename) {
3838
+ fileUrls.push(new URL(`${filename}.html`, directoryUrl).href);
3839
+ }
3840
+ if (directoryUrl === String(rootDirectoryUrl)) {
3841
+ break;
3842
+ }
3843
+ directoryUrl = new URL("../", directoryUrl).href;
3844
+ }
3845
+ const mainFileUrl = new URL(mainFilePath, rootDirectoryUrl).href;
3846
+ if (!fileUrls.includes(mainFileUrl)) {
3847
+ fileUrls.push(mainFileUrl);
3848
+ }
3849
+ return fileUrls;
3850
+ };
3851
+
3619
3852
  /*
3620
3853
  * NICE TO HAVE:
3621
3854
  *
@@ -3993,6 +4226,24 @@ const generateDirectoryListingInjection = (
3993
4226
  filePathExisting: `/${filePathExisting}`,
3994
4227
  filePathNotFound,
3995
4228
  });
4229
+ // a url without extension nor trailing slash is a route: in spa mode it
4230
+ // was answered with the closest html file, the 404 means none was found
4231
+ const urlNotFoundObject = new URL(urlNotFound);
4232
+ if (
4233
+ spa &&
4234
+ !urlToExtension(urlNotFoundObject) &&
4235
+ !urlNotFoundObject.pathname.endsWith("/")
4236
+ ) {
4237
+ enoentDetails.spaFallbackFilePaths = listSpaFallbackFileUrls(
4238
+ urlNotFound,
4239
+ { rootDirectoryUrl: serverRootDirectoryUrl, mainFilePath },
4240
+ ).map((fileUrl) =>
4241
+ FILE_AND_SERVER_URLS_CONVERTER.asServerUrl(
4242
+ fileUrl,
4243
+ serverRootDirectoryUrl,
4244
+ ),
4245
+ );
4246
+ }
3996
4247
  }
3997
4248
 
3998
4249
  return {
@@ -4064,235 +4315,18 @@ const getDirectoryContentItems = ({
4064
4315
  return items;
4065
4316
  };
4066
4317
 
4067
- const jsenvPluginFsRedirection = ({
4068
- spa,
4069
- directoryContentMagicName,
4070
- magicExtensions = ["inherit", ".js"],
4071
- magicDirectoryIndex = true,
4072
- preserveSymlinks = false,
4073
- }) => {
4074
- return {
4075
- name: "jsenv:fs_redirection",
4076
- appliesDuring: "*",
4077
- redirectReference: (reference) => {
4078
- if (reference.url === "file:///") {
4079
- return `ignore:file:///`;
4080
- }
4081
- if (reference.url === "file://") {
4082
- return `ignore:file://`;
4083
- }
4084
- // ignore all new URL second arg
4085
- if (reference.subtype === "new_url_second_arg") {
4086
- if (reference.original) {
4087
- return `ignore:${reference.original.specifier}`;
4088
- }
4089
- return `ignore:${reference.specifier}`;
4090
- }
4091
- // http, https, data, about, ...
4092
- if (!reference.url.startsWith("file:")) {
4093
- return null;
4094
- }
4095
- if (reference.original && !reference.original.url.startsWith("file:")) {
4096
- return null;
4097
- }
4098
- if (reference.isInline) {
4099
- return null;
4100
- }
4101
-
4102
- if (
4103
- reference.specifierPathname.endsWith(`/${directoryContentMagicName}`)
4104
- ) {
4105
- const { rootDirectoryUrl } = reference.ownerUrlInfo.context;
4106
- const directoryUrl = new URL(
4107
- reference.specifierPathname
4108
- .replace(`/${directoryContentMagicName}`, "/")
4109
- .slice(1),
4110
- rootDirectoryUrl,
4111
- ).href;
4112
- return directoryUrl;
4113
- }
4114
- // ignore "./" on new URL("./")
4115
- // if (
4116
- // reference.subtype === "new_url_first_arg" &&
4117
- // reference.specifier === "./"
4118
- // ) {
4119
- // return `ignore:${reference.url}`;
4120
- // }
4121
- const urlObject = new URL(reference.url);
4122
- let fsStat = readEntryStatSync(urlObject, { nullIfNotFound: true });
4123
- reference.fsStat = fsStat;
4124
- const { search, hash } = urlObject;
4125
- urlObject.search = "";
4126
- urlObject.hash = "";
4127
- // must be read before applyFsStatEffectsOnUrlObject which forces the
4128
- // trailing slash on directories
4129
- const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
4130
- applyFsStatEffectsOnUrlObject(urlObject, fsStat);
4131
- const shouldApplyFilesystemMagicResolution =
4132
- reference.type === "js_import";
4133
- if (shouldApplyFilesystemMagicResolution) {
4134
- const filesystemResolution = applyFileSystemMagicResolution(
4135
- urlObject.href,
4136
- {
4137
- fileStat: fsStat,
4138
- magicDirectoryIndex,
4139
- magicExtensions: getExtensionsToTry(
4140
- magicExtensions,
4141
- reference.ownerUrlInfo.url,
4142
- ),
4143
- },
4144
- );
4145
- if (filesystemResolution.stat) {
4146
- fsStat = filesystemResolution.stat;
4147
- reference.fsStat = fsStat;
4148
- urlObject.href = filesystemResolution.url;
4149
- applyFsStatEffectsOnUrlObject(urlObject, fsStat);
4150
- }
4151
- }
4152
- if (spa) {
4153
- // for SPA we want to serve the root HTML file most of the time
4154
- if (!fsStat) {
4155
- if (urlToExtension(urlObject)) {
4156
- // url has an extension, we assume it's a file request -> let 404 happen
4157
- return null;
4158
- }
4159
- if (specifierUsesTrailingSlash) {
4160
- // the trailing slash asks for a directory and there is none here
4161
- // -> let 404 happen (same reasoning as the extension above)
4162
- return null;
4163
- }
4164
- const spaFallbackUrl = getSpaFallbackUrl(reference);
4165
- if (spaFallbackUrl) {
4166
- return spaFallbackUrl;
4167
- }
4168
- return null;
4169
- }
4170
- if (fsStat.isDirectory()) {
4171
- // When requesting a directory, check if we have an HTML entry file for that directory
4172
- const directoryEntryFileUrl = getDirectoryEntryFileUrl(urlObject);
4173
- if (directoryEntryFileUrl) {
4174
- reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
4175
- return directoryEntryFileUrl;
4176
- }
4177
- if (!specifierUsesTrailingSlash) {
4178
- // the trailing slash is what tells a directory apart from a route:
4179
- // "/join/" is the directory, "/join" is a route owned by the SPA
4180
- // even when "join/" exists in the source files.
4181
- // Without this a source directory would shadow the route having
4182
- // the same name and the SPA would be unreachable in dev while
4183
- // being perfectly fine once built
4184
- const spaFallbackUrl = getSpaFallbackUrl(reference);
4185
- if (spaFallbackUrl) {
4186
- reference.fsStat = readEntryStatSync(spaFallbackUrl, {
4187
- nullIfNotFound: true,
4188
- });
4189
- return spaFallbackUrl;
4190
- }
4191
- }
4192
- }
4193
- }
4194
- if (!fsStat) {
4195
- return null;
4196
- }
4197
- const urlBeforeSymlinkResolution = urlObject.href;
4198
- if (preserveSymlinks) {
4199
- return `${urlBeforeSymlinkResolution}${search}${hash}`;
4200
- }
4201
- const urlAfterSymlinkResolution = resolveSymlink(
4202
- urlBeforeSymlinkResolution,
4203
- );
4204
- if (urlAfterSymlinkResolution !== urlBeforeSymlinkResolution) {
4205
- reference.leadsToASymlink = true;
4206
- // reference.baseUrl = urlBeforeSymlinkResolution;
4207
- }
4208
- const resolvedUrl = `${urlAfterSymlinkResolution}${search}${hash}`;
4209
- return resolvedUrl;
4210
- },
4211
- };
4212
- };
4213
-
4214
- const applyFsStatEffectsOnUrlObject = (urlObject, fsStat) => {
4215
- if (!fsStat) {
4216
- return;
4217
- }
4218
- const { pathname } = urlObject;
4219
- const pathnameUsesTrailingSlash = pathname.endsWith("/");
4220
- // force trailing slash on directories
4221
- if (fsStat.isDirectory()) {
4222
- if (!pathnameUsesTrailingSlash) {
4223
- urlObject.pathname = `${pathname}/`;
4224
- }
4225
- } else if (pathnameUsesTrailingSlash) {
4226
- // otherwise remove trailing slash if any
4227
- // a warning here? (because it's strange to reference a file with a trailing slash)
4228
- urlObject.pathname = pathname.slice(0, -1);
4229
- }
4230
- };
4231
-
4232
- const resolveSymlink = (fileUrl) => {
4233
- const urlObject = new URL(fileUrl);
4234
- const realpath = realpathSync(urlObject);
4235
- const realUrlObject = pathToFileURL(realpath);
4236
- if (urlObject.pathname.endsWith("/")) {
4237
- realUrlObject.pathname += `/`;
4238
- }
4239
- return realUrlObject.href;
4240
- };
4241
-
4242
- const getDirectoryEntryFileUrl = (directoryUrl) => {
4243
- const indexHtmlFileUrl = new URL(`index.html`, directoryUrl);
4244
- if (existsSync(indexHtmlFileUrl)) {
4245
- return indexHtmlFileUrl.href;
4246
- }
4247
- const filename = urlToFilename(directoryUrl);
4248
- const htmlFileUrlCandidate = new URL(`${filename}.html`, directoryUrl);
4249
- if (existsSync(htmlFileUrlCandidate)) {
4250
- return htmlFileUrlCandidate.href;
4251
- }
4252
- return null;
4253
- };
4254
- const getSpaFallbackUrl = (reference) => {
4255
- const { requestedUrl, rootDirectoryUrl, mainFilePath } =
4256
- reference.ownerUrlInfo.context;
4257
- if (!requestedUrl) {
4258
- // the SPA fallback answers a request; during build there is none
4259
- return null;
4260
- }
4261
- const closestHtmlRootFile = getClosestHtmlRootFile(
4262
- requestedUrl,
4263
- rootDirectoryUrl,
4264
- );
4265
- if (closestHtmlRootFile) {
4266
- return closestHtmlRootFile;
4267
- }
4268
- return String(new URL(mainFilePath, rootDirectoryUrl));
4269
- };
4270
- const getClosestHtmlRootFile = (requestedUrl, serverRootDirectoryUrl) => {
4271
- let directoryUrl = new URL("./", requestedUrl);
4272
- while (true) {
4273
- const directoryEntryFileUrl = getDirectoryEntryFileUrl(directoryUrl);
4274
- if (directoryEntryFileUrl) {
4275
- return directoryEntryFileUrl;
4276
- }
4277
- if (!urlIsOrIsInsideOf(directoryUrl, serverRootDirectoryUrl)) {
4278
- return null;
4279
- }
4280
- directoryUrl = new URL("../", directoryUrl);
4281
- }
4282
- };
4283
-
4284
- const directoryContentMagicName = "...";
4285
-
4286
- const jsenvPluginProtocolFile = ({
4287
- spa = true,
4288
- magicExtensions,
4289
- magicDirectoryIndex,
4290
- preserveSymlinks,
4291
- directoryListing,
4292
- rootDirectoryUrl,
4293
- mainFilePath,
4294
- packageDirectory,
4295
- sourceFilesConfig,
4318
+ const directoryContentMagicName = "...";
4319
+
4320
+ const jsenvPluginProtocolFile = ({
4321
+ spa = true,
4322
+ magicExtensions,
4323
+ magicDirectoryIndex,
4324
+ preserveSymlinks,
4325
+ directoryListing,
4326
+ rootDirectoryUrl,
4327
+ mainFilePath,
4328
+ packageDirectory,
4329
+ sourceFilesConfig,
4296
4330
  }) => {
4297
4331
  const listHtmlPages = createHtmlPageLister({ rootDirectoryUrl });
4298
4332
 
@@ -4434,7 +4468,16 @@ const jsenvPluginProtocolFile = ({
4434
4468
  }
4435
4469
  const serveFile = (url) => {
4436
4470
  const contentType = CONTENT_TYPE.fromUrlExtension(url);
4437
- const fileBuffer = readFileSync(new URL(url));
4471
+ const urlObject = new URL(url);
4472
+ // taken before the read: a write landing in between moves the
4473
+ // stat past this one, so the dev server sees the content as
4474
+ // outdated (see isValid in the dev server) rather than the reverse
4475
+ const fileStat = statSync(urlObject);
4476
+ const fileBuffer = readFileSync(urlObject);
4477
+ urlInfo.data.fileStat = {
4478
+ mtimeMs: fileStat.mtimeMs,
4479
+ size: fileStat.size,
4480
+ };
4438
4481
  const content = CONTENT_TYPE.isTextual(contentType)
4439
4482
  ? String(fileBuffer)
4440
4483
  : fileBuffer;
@@ -5934,7 +5977,7 @@ const jsenvPluginImportMetaScenarios = () => {
5934
5977
  name: "jsenv:import_meta_scenario",
5935
5978
  appliesDuring: "*",
5936
5979
  transformUrlContent: {
5937
- js_module: async (urlInfo) => {
5980
+ js_module: (urlInfo) => {
5938
5981
  // Do not scan node modules for import.meta.dev/import.meta.build
5939
5982
  // - node modules won't have this in their code
5940
5983
  // - ;or should use other an other technic as this one won't be available
@@ -5950,40 +5993,42 @@ const jsenvPluginImportMetaScenarios = () => {
5950
5993
  ) {
5951
5994
  return null;
5952
5995
  }
5953
- const { metadata } = await applyBabelPlugins({
5954
- babelPlugins: [babelPluginMetadataImportMetaScenarios],
5955
- input: urlInfo.content,
5956
- inputIsJsModule: true,
5957
- inputUrl: urlInfo.originalUrl,
5958
- outputUrl: urlInfo.generatedUrl,
5996
+ const importMetaScenarioNodes = { dev: [], build: [] };
5997
+ visitJsAst(urlInfo.contentAst, {
5998
+ MemberExpression: (node) => {
5999
+ const name = getImportMetaPropertyName(node);
6000
+ if (name === "dev" || name === "build") {
6001
+ importMetaScenarioNodes[name].push(node);
6002
+ }
6003
+ },
5959
6004
  });
5960
- const { dev = [], build = [] } = metadata.importMetaScenarios;
6005
+ const { dev, build } = importMetaScenarioNodes;
5961
6006
  const replacements = [];
5962
- const replace = (path, value) => {
5963
- replacements.push({ path, value });
6007
+ const replace = (node, value) => {
6008
+ replacements.push({ node, value });
5964
6009
  };
5965
6010
  if (urlInfo.context.build) {
5966
6011
  // during build ensure replacement for tree-shaking
5967
- dev.forEach((path) => {
5968
- replace(path, "undefined");
6012
+ dev.forEach((node) => {
6013
+ replace(node, "undefined");
5969
6014
  });
5970
- build.forEach((path) => {
5971
- replace(path, "true");
6015
+ build.forEach((node) => {
6016
+ replace(node, "true");
5972
6017
  });
5973
6018
  } else {
5974
6019
  // during dev we can let "import.meta.build" untouched
5975
6020
  // it will be evaluated to undefined.
5976
6021
  // Moreover it can be surprising to see some "undefined"
5977
6022
  // when source file contains "import.meta.build"
5978
- dev.forEach((path) => {
5979
- replace(path, "true");
6023
+ dev.forEach((node) => {
6024
+ replace(node, "true");
5980
6025
  });
5981
6026
  }
5982
6027
  const magicSource = createMagicSource(urlInfo.content);
5983
- replacements.forEach(({ path, value }) => {
6028
+ replacements.forEach(({ node, value }) => {
5984
6029
  magicSource.replace({
5985
- start: path.node.start,
5986
- end: path.node.end,
6030
+ start: node.start,
6031
+ end: node.end,
5987
6032
  replacement: value,
5988
6033
  });
5989
6034
  });
@@ -5993,42 +6038,6 @@ const jsenvPluginImportMetaScenarios = () => {
5993
6038
  };
5994
6039
  };
5995
6040
 
5996
- const babelPluginMetadataImportMetaScenarios = () => {
5997
- return {
5998
- name: "metadata-import-meta-scenarios",
5999
- visitor: {
6000
- Program(programPath, state) {
6001
- const importMetas = {};
6002
- programPath.traverse({
6003
- MemberExpression(path) {
6004
- const { node } = path;
6005
- const { object } = node;
6006
- if (object.type !== "MetaProperty") {
6007
- return;
6008
- }
6009
- const { property: objectProperty } = object;
6010
- if (objectProperty.name !== "meta") {
6011
- return;
6012
- }
6013
- const { property } = node;
6014
- const { name } = property;
6015
- const importMetaPaths = importMetas[name];
6016
- if (importMetaPaths) {
6017
- importMetaPaths.push(path);
6018
- } else {
6019
- importMetas[name] = [path];
6020
- }
6021
- },
6022
- });
6023
- state.file.metadata.importMetaScenarios = {
6024
- dev: importMetas.dev,
6025
- build: importMetas.build,
6026
- };
6027
- },
6028
- },
6029
- };
6030
- };
6031
-
6032
6041
  /*
6033
6042
  * Source code can contain the following
6034
6043
  * - __DEV__
@@ -6127,15 +6136,10 @@ const jsenvPluginImportMetaCss = () => {
6127
6136
  if (!urlInfo.content.includes("import.meta.css")) {
6128
6137
  return null;
6129
6138
  }
6130
- const { metadata } = await applyBabelPlugins({
6131
- babelPlugins: [babelPluginMetadataUsesImportMetaCss],
6132
- input: urlInfo.content,
6133
- inputIsJsModule: true,
6134
- inputUrl: urlInfo.originalUrl,
6135
- outputUrl: urlInfo.generatedUrl,
6139
+ const importMetaCssNode = visitJsAstUntil(urlInfo.contentAst, {
6140
+ MemberExpression: (node) => getImportMetaPropertyName(node) === "css",
6136
6141
  });
6137
- const { usesImportMetaCss } = metadata;
6138
- if (!usesImportMetaCss) {
6142
+ if (!importMetaCssNode) {
6139
6143
  return null;
6140
6144
  }
6141
6145
  if (urlInfo.context.build) {
@@ -6156,6 +6160,8 @@ const jsenvPluginImportMetaCss = () => {
6156
6160
  inputIsJsModule: true,
6157
6161
  inputUrl: urlInfo.originalUrl,
6158
6162
  outputUrl: urlInfo.generatedUrl,
6163
+ // the map would be dropped: the content sent back carries none
6164
+ options: { sourceMaps: false },
6159
6165
  });
6160
6166
  if (code === urlInfo.content) {
6161
6167
  // all assignments were already in array form (pre-built file) — nothing to do
@@ -6215,37 +6221,6 @@ const babelPluginRewriteImportMetaCssAssignment = (
6215
6221
  };
6216
6222
  };
6217
6223
 
6218
- const babelPluginMetadataUsesImportMetaCss = () => {
6219
- return {
6220
- name: "metadata-uses-import-meta-css",
6221
- visitor: {
6222
- Program(programPath, state) {
6223
- let usesImportMetaCss = false;
6224
- programPath.traverse({
6225
- MemberExpression(path) {
6226
- const { node } = path;
6227
- const { object } = node;
6228
- if (object.type !== "MetaProperty") {
6229
- return;
6230
- }
6231
- const { property: objectProperty } = object;
6232
- if (objectProperty.name !== "meta") {
6233
- return;
6234
- }
6235
- const { property } = node;
6236
- const { name } = property;
6237
- if (name === "css") {
6238
- usesImportMetaCss = true;
6239
- path.stop();
6240
- }
6241
- },
6242
- });
6243
- state.file.metadata.usesImportMetaCss = usesImportMetaCss;
6244
- },
6245
- },
6246
- };
6247
- };
6248
-
6249
6224
  const injectImportMetaCss = (
6250
6225
  urlInfo,
6251
6226
  { content, importFrom, importName, importAs, hot },
@@ -6288,104 +6263,6 @@ ${importVariableName}(import.meta);
6288
6263
  };
6289
6264
  };
6290
6265
 
6291
- // https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#toc-stages-of-babel
6292
- // https://github.com/cfware/babel-plugin-bundled-import-meta/blob/master/index.js
6293
- // https://github.com/babel/babel/blob/f4edf62f6beeab8ae9f2b7f0b82f1b3b12a581af/packages/babel-helper-module-imports/src/index.js#L7
6294
-
6295
- const babelPluginMetadataImportMetaHot = () => {
6296
- return {
6297
- name: "metadata-import-meta-hot",
6298
- visitor: {
6299
- Program(programPath, state) {
6300
- Object.assign(
6301
- state.file.metadata,
6302
- collectImportMetaHotProperties(programPath),
6303
- );
6304
- },
6305
- },
6306
- };
6307
- };
6308
- const collectImportMetaHotProperties = (programPath) => {
6309
- const importMetaHotPaths = [];
6310
- let hotDecline = false;
6311
- let hotAcceptSelf = false;
6312
- let hotAcceptDependencies = [];
6313
- programPath.traverse({
6314
- MemberExpression(path) {
6315
- const { node } = path;
6316
- const { object } = node;
6317
- if (object.type !== "MetaProperty") {
6318
- return;
6319
- }
6320
- const { property: objectProperty } = object;
6321
- if (objectProperty.name !== "meta") {
6322
- return;
6323
- }
6324
- const { property } = node;
6325
- const { name } = property;
6326
- if (name === "hot") {
6327
- importMetaHotPaths.push(path);
6328
- }
6329
- },
6330
- CallExpression(path) {
6331
- if (isImportMetaHotMethodCall(path, "accept")) {
6332
- const callNode = path.node;
6333
- const args = callNode.arguments;
6334
- if (args.length === 0) {
6335
- hotAcceptSelf = true;
6336
- return;
6337
- }
6338
- const firstArg = args[0];
6339
- if (firstArg.type === "StringLiteral") {
6340
- hotAcceptDependencies = [
6341
- {
6342
- specifierPath: path.get("arguments")[0],
6343
- },
6344
- ];
6345
- return;
6346
- }
6347
- if (firstArg.type === "ArrayExpression") {
6348
- const firstArgPath = path.get("arguments")[0];
6349
- hotAcceptDependencies = firstArg.elements.map((arrayNode, index) => {
6350
- if (arrayNode.type !== "StringLiteral") {
6351
- throw new Error(
6352
- `all array elements must be strings in "import.meta.hot.accept(array)"`,
6353
- );
6354
- }
6355
- return {
6356
- specifierPath: firstArgPath.get(String(index)),
6357
- };
6358
- });
6359
- return;
6360
- }
6361
- // accept first arg can be "anything" such as
6362
- // `const cb = () => {}; import.meta.accept(cb)`
6363
- hotAcceptSelf = true;
6364
- }
6365
- if (isImportMetaHotMethodCall(path, "decline")) {
6366
- hotDecline = true;
6367
- }
6368
- },
6369
- });
6370
- return {
6371
- importMetaHotPaths,
6372
- hotDecline,
6373
- hotAcceptSelf,
6374
- hotAcceptDependencies,
6375
- };
6376
- };
6377
- const isImportMetaHotMethodCall = (path, methodName) => {
6378
- const { property, object } = path.node.callee;
6379
- return (
6380
- property &&
6381
- property.name === methodName &&
6382
- object &&
6383
- object.property &&
6384
- object.property.name === "hot" &&
6385
- object.object.type === "MetaProperty"
6386
- );
6387
- };
6388
-
6389
6266
  // Some "smart" default applied to decide what should hot reload / fullreload:
6390
6267
  // By default:
6391
6268
  // - hot reload on <img src="./image.png" />
@@ -6533,6 +6410,75 @@ const htmlNodeCanHotReload = (node) => {
6533
6410
  ].includes(node.nodeName);
6534
6411
  };
6535
6412
 
6413
+ const analyzeImportMetaHot = (ast) => {
6414
+ const importMetaHotNodes = [];
6415
+ let hotDecline = false;
6416
+ let hotAcceptSelf = false;
6417
+ let hotAcceptSpecifiers = [];
6418
+ visitJsAst(ast, {
6419
+ MemberExpression: (node) => {
6420
+ if (getImportMetaPropertyName(node) === "hot") {
6421
+ importMetaHotNodes.push(node);
6422
+ }
6423
+ },
6424
+ CallExpression: (node) => {
6425
+ const methodName = getImportMetaHotMethodName(node);
6426
+ if (methodName === "accept") {
6427
+ const args = node.arguments;
6428
+ if (args.length === 0) {
6429
+ hotAcceptSelf = true;
6430
+ return;
6431
+ }
6432
+ const [firstArg] = args;
6433
+ if (isStringLiteral(firstArg)) {
6434
+ hotAcceptSpecifiers = [firstArg.value];
6435
+ return;
6436
+ }
6437
+ if (firstArg.type === "ArrayExpression") {
6438
+ hotAcceptSpecifiers = firstArg.elements.map((element) => {
6439
+ if (!isStringLiteral(element)) {
6440
+ throw new Error(
6441
+ `all array elements must be strings in "import.meta.hot.accept(array)"`,
6442
+ );
6443
+ }
6444
+ return element.value;
6445
+ });
6446
+ return;
6447
+ }
6448
+ // accept first arg can be "anything" such as
6449
+ // `const cb = () => {}; import.meta.hot.accept(cb)`
6450
+ hotAcceptSelf = true;
6451
+ return;
6452
+ }
6453
+ if (methodName === "decline") {
6454
+ hotDecline = true;
6455
+ }
6456
+ },
6457
+ });
6458
+ return {
6459
+ importMetaHotNodes,
6460
+ hotDecline,
6461
+ hotAcceptSelf,
6462
+ hotAcceptSpecifiers,
6463
+ };
6464
+ };
6465
+
6466
+ // "import.meta.hot.<method>(...)"
6467
+ const getImportMetaHotMethodName = (callNode) => {
6468
+ const { callee } = callNode;
6469
+ if (callee.type !== "MemberExpression" || callee.computed) {
6470
+ return null;
6471
+ }
6472
+ if (getImportMetaPropertyName(callee.object) !== "hot") {
6473
+ return null;
6474
+ }
6475
+ return callee.property.name;
6476
+ };
6477
+
6478
+ const isStringLiteral = (node) => {
6479
+ return node.type === "Literal" && typeof node.value === "string";
6480
+ };
6481
+
6536
6482
  const jsenvPluginImportMetaHot = () => {
6537
6483
  const importMetaHotClientFileUrl = import.meta
6538
6484
  .resolve("../client/import_meta_hot/import_meta_hot.js");
@@ -6581,7 +6527,7 @@ const jsenvPluginImportMetaHot = () => {
6581
6527
  cssUrlInfo.data.hotAcceptSelf = false;
6582
6528
  cssUrlInfo.data.hotAcceptDependencies = [];
6583
6529
  },
6584
- js_module: async (urlInfo) => {
6530
+ js_module: (urlInfo) => {
6585
6531
  // Do not scan node modules for import.meta.hot
6586
6532
  // - unlikely to be there
6587
6533
  // - we don't watch node modules (too expensive)
@@ -6593,27 +6539,22 @@ const jsenvPluginImportMetaHot = () => {
6593
6539
  if (!urlInfo.content.includes("import.meta.hot")) {
6594
6540
  return null;
6595
6541
  }
6596
- const { metadata } = await applyBabelPlugins({
6597
- babelPlugins: [babelPluginMetadataImportMetaHot],
6598
- input: urlInfo.content,
6599
- inputIsJsModule: true,
6600
- inputUrl: urlInfo.originalUrl,
6601
- outputUrl: urlInfo.generatedUrl,
6602
- });
6603
6542
  const {
6604
- importMetaHotPaths,
6543
+ importMetaHotNodes,
6605
6544
  hotDecline,
6606
6545
  hotAcceptSelf,
6607
- hotAcceptDependencies,
6608
- } = metadata;
6546
+ hotAcceptSpecifiers,
6547
+ } = analyzeImportMetaHot(urlInfo.contentAst);
6609
6548
  urlInfo.data.hotDecline = hotDecline;
6610
6549
  urlInfo.data.hotAcceptSelf = hotAcceptSelf;
6611
- urlInfo.data.hotAcceptDependencies = hotAcceptDependencies;
6612
- if (importMetaHotPaths.length === 0) {
6550
+ urlInfo.data.hotAcceptDependencies = hotAcceptSpecifiers.map(
6551
+ (specifier) => resolveHotAcceptSpecifier(urlInfo, specifier),
6552
+ );
6553
+ if (importMetaHotNodes.length === 0) {
6613
6554
  return null;
6614
6555
  }
6615
6556
  if (urlInfo.context.build) {
6616
- return removeImportMetaHots(urlInfo, importMetaHotPaths);
6557
+ return removeImportMetaHots(urlInfo, importMetaHotNodes);
6617
6558
  }
6618
6559
  return injectImportMetaHot(urlInfo, importMetaHotClientFileUrl);
6619
6560
  },
@@ -6621,12 +6562,32 @@ const jsenvPluginImportMetaHot = () => {
6621
6562
  };
6622
6563
  };
6623
6564
 
6624
- const removeImportMetaHots = (urlInfo, importMetaHotPaths) => {
6565
+ // The specifier given to import.meta.hot.accept() is one the file imports,
6566
+ // so its url is known from that import (autoreload compares urls). A
6567
+ // specifier the file does not import accepts nothing: it is kept as a plain
6568
+ // url, never resolved as a dependency (that could throw on a bare specifier).
6569
+ const resolveHotAcceptSpecifier = (urlInfo, specifier) => {
6570
+ for (const referenceToOther of urlInfo.referenceToOthersSet) {
6571
+ if (
6572
+ referenceToOther.type === "js_import" &&
6573
+ referenceToOther.specifier === specifier
6574
+ ) {
6575
+ return referenceToOther.url;
6576
+ }
6577
+ }
6578
+ try {
6579
+ return new URL(specifier, urlInfo.url).href;
6580
+ } catch {
6581
+ return specifier;
6582
+ }
6583
+ };
6584
+
6585
+ const removeImportMetaHots = (urlInfo, importMetaHotNodes) => {
6625
6586
  const magicSource = createMagicSource(urlInfo.content);
6626
- importMetaHotPaths.forEach((path) => {
6587
+ importMetaHotNodes.forEach((node) => {
6627
6588
  magicSource.replace({
6628
- start: path.node.start,
6629
- end: path.node.end,
6589
+ start: node.start,
6590
+ end: node.end,
6630
6591
  replacement: "undefined",
6631
6592
  });
6632
6593
  });
@@ -7155,6 +7116,192 @@ const jsenvPluginAutoreload = ({
7155
7116
  ];
7156
7117
  };
7157
7118
 
7119
+ const urlSpecifierEncoding = {
7120
+ encode: (reference) => {
7121
+ const { generatedSpecifier } = reference;
7122
+ if (generatedSpecifier.then) {
7123
+ return generatedSpecifier.then((value) => {
7124
+ reference.generatedSpecifier = value;
7125
+ return urlSpecifierEncoding.encode(reference);
7126
+ });
7127
+ }
7128
+ // allow plugin to return a function to bypas default formatting
7129
+ // (which is to use JSON.stringify when url is referenced inside js)
7130
+ if (typeof generatedSpecifier === "function") {
7131
+ return generatedSpecifier();
7132
+ }
7133
+ const formatter = formatters[reference.type];
7134
+ const value = formatter
7135
+ ? formatter.encode(generatedSpecifier)
7136
+ : generatedSpecifier;
7137
+ if (reference.escape) {
7138
+ return reference.escape(value);
7139
+ }
7140
+ return value;
7141
+ },
7142
+ decode: (reference) => {
7143
+ const formatter = formatters[reference.type];
7144
+ return formatter
7145
+ ? formatter.decode(reference.generatedSpecifier)
7146
+ : reference.generatedSpecifier;
7147
+ },
7148
+ };
7149
+ const formatters = {
7150
+ "js_import": { encode: JSON.stringify, decode: JSON.parse },
7151
+ "js_url": { encode: JSON.stringify, decode: JSON.parse },
7152
+ "css_@import": { encode: JSON.stringify, decode: JSON.stringify },
7153
+ // https://github.com/webpack-contrib/css-loader/pull/627/files
7154
+ "css_url": {
7155
+ encode: (url) => {
7156
+ // If url is already wrapped in quotes, remove them
7157
+ url = formatters.css_url.decode(url);
7158
+ // Should url be wrapped?
7159
+ // See https://drafts.csswg.org/css-values-3/#urls
7160
+ if (/["'() \t\n]/.test(url)) {
7161
+ return `"${url.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
7162
+ }
7163
+ return url;
7164
+ },
7165
+ decode: (url) => {
7166
+ const firstChar = url[0];
7167
+ const lastChar = url[url.length - 1];
7168
+ if (firstChar === `"` && lastChar === `"`) {
7169
+ return url.slice(1, -1);
7170
+ }
7171
+ if (firstChar === `'` && lastChar === `'`) {
7172
+ return url.slice(1, -1);
7173
+ }
7174
+ return url;
7175
+ },
7176
+ },
7177
+ };
7178
+
7179
+ /*
7180
+ * Tells the browser the static import graph of a page's module scripts, as
7181
+ * `Link: <url>; rel=modulepreload` response headers, so it can fetch every
7182
+ * module as soon as the page's headers arrive, without waiting to discover
7183
+ * them level by level (each level costing a round trip and, the first time,
7184
+ * a cook).
7185
+ *
7186
+ * Only what the graph already knows is listed: a module never cooked has no
7187
+ * known imports. The header is computed at each response from the graph as it
7188
+ * is then: a page gets its full list from its second load on, the first load
7189
+ * keeps the plain waterfall.
7190
+ *
7191
+ * Headers rather than <link> elements written into the page: the page content
7192
+ * stays what it is (served from memory, revalidated by etag) and the list can
7193
+ * grow without cooking the page again. And not references either: a reference
7194
+ * from the html to every module would make the html a dependent of each of
7195
+ * them, and a hot update propagating up the importers would reach the html
7196
+ * (which declines) instead of stopping at the module accepting it.
7197
+ *
7198
+ * OFF BY DEFAULT, to be enabled once the dev server speaks http/2 or http/3.
7199
+ * Over http/1.1 it makes pages slower — measured on a 500 modules page: first
7200
+ * paint 32ms -> 868ms, load 626ms -> 985ms. Knowing the urls earlier gives the
7201
+ * browser no extra resource: what it has is 6 connections per origin, and
7202
+ * every request goes through them in the order it was learned.
7203
+ * - Without the header, requests come in execution order: the parser asks
7204
+ * for the render-blocking supervisor script first and gets a connection at
7205
+ * once; transfer and execution overlap, a module executing and discovering
7206
+ * its imports while the others download. On localhost the server answers
7207
+ * from memory in 0.1ms, so the 6 connections are busy from the start.
7208
+ * - With the header, hundreds of requests take the 6 connections before the
7209
+ * parser asks for the supervisor script, which then waits for a free
7210
+ * connection behind 1MB+ transfers (520ms measured, 2s with 20ms of RTT).
7211
+ * Meanwhile nothing renders and nothing executes: transfer, then execution,
7212
+ * serialized. http/1.1 cannot reprioritize a queued request, and a
7213
+ * modulepreload has the same priority as a script.
7214
+ * - And there is nothing to hide: the discovery latency a preload removes is
7215
+ * ~1ms per graph level on localhost; with a real RTT a wide graph (more
7216
+ * than 6 modules pending at any time) keeps the 6 connections saturated
7217
+ * anyway, and the load takes requests / 6 * RTT whatever the order.
7218
+ * It pays off for a deep and narrow graph (connections idle, waiting for a
7219
+ * response to learn the next level), and over http/2 or http/3: no
7220
+ * connection limit, and the browser prioritizes the blocking script.
7221
+ */
7222
+
7223
+
7224
+ const jsenvPluginModulepreload = () => {
7225
+ return {
7226
+ name: "jsenv:modulepreload",
7227
+ appliesDuring: "dev",
7228
+ augmentResponse: ({ urlInfo }) => {
7229
+ if (urlInfo.type !== "html") {
7230
+ return null;
7231
+ }
7232
+ const hrefs = collectStaticImportHrefs(urlInfo);
7233
+ if (hrefs.length === 0) {
7234
+ return null;
7235
+ }
7236
+ return {
7237
+ headers: {
7238
+ link: hrefs.map((href) => `<${href}>; rel=modulepreload`).join(", "),
7239
+ },
7240
+ };
7241
+ },
7242
+ };
7243
+ };
7244
+
7245
+ const collectStaticImportHrefs = (htmlUrlInfo) => {
7246
+ const hrefSet = new Set();
7247
+ const visitedSet = new Set();
7248
+ const addHref = (reference) => {
7249
+ const specifier = urlSpecifierEncoding.decode(reference);
7250
+ if (typeof specifier !== "string") {
7251
+ return;
7252
+ }
7253
+ // "?hot" belongs to the request that cooked the importer, the page loading
7254
+ // now imports the url without it
7255
+ hrefSet.add(injectQueryParamsIntoSpecifier(specifier, { hot: undefined }));
7256
+ };
7257
+ const visitJsModule = (jsModuleUrlInfo) => {
7258
+ if (visitedSet.has(jsModuleUrlInfo)) {
7259
+ return;
7260
+ }
7261
+ visitedSet.add(jsModuleUrlInfo);
7262
+ for (const reference of jsModuleUrlInfo.referenceToOthersSet) {
7263
+ if (
7264
+ reference.type !== "js_import" ||
7265
+ reference.isWeak ||
7266
+ reference.isImplicit ||
7267
+ !STATIC_IMPORT_SUBTYPES.includes(reference.subtype) ||
7268
+ !reference.url.startsWith("file:")
7269
+ ) {
7270
+ continue;
7271
+ }
7272
+ const importedUrlInfo = reference.urlInfo;
7273
+ // never cooked: its content, hence its own imports, are unknown
7274
+ if (importedUrlInfo.type !== "js_module") {
7275
+ continue;
7276
+ }
7277
+ addHref(reference);
7278
+ visitJsModule(importedUrlInfo);
7279
+ }
7280
+ };
7281
+ for (const reference of htmlUrlInfo.referenceToOthersSet) {
7282
+ if (
7283
+ reference.type !== "script" ||
7284
+ reference.expectedType !== "js_module" ||
7285
+ // the scripts jsenv adds to every page are few, small and in memory
7286
+ reference.injected ||
7287
+ reference.isWeak
7288
+ ) {
7289
+ continue;
7290
+ }
7291
+ const scriptUrlInfo = reference.urlInfo;
7292
+ if (scriptUrlInfo.type !== "js_module") {
7293
+ continue;
7294
+ }
7295
+ if (!reference.isInline && reference.url.startsWith("file:")) {
7296
+ addHref(reference);
7297
+ }
7298
+ visitJsModule(scriptUrlInfo);
7299
+ }
7300
+ return Array.from(hrefSet);
7301
+ };
7302
+
7303
+ const STATIC_IMPORT_SUBTYPES = ["import_static", "export_named", "export_all"];
7304
+
7158
7305
  /*
7159
7306
  * Tells the browser about the dependencies declared in package.json that
7160
7307
  * node_modules does not match, so the page can say it is running with something
@@ -7796,6 +7943,7 @@ const getCorePlugins = ({
7796
7943
 
7797
7944
  clientAutoreload,
7798
7945
  clientAutoreloadOnServerRestart,
7946
+ modulepreload = false,
7799
7947
  dependencyStatus,
7800
7948
  cacheControl,
7801
7949
  scenarioPlaceholders = true,
@@ -7897,6 +8045,7 @@ const getCorePlugins = ({
7897
8045
  ...(clientAutoreload && clientAutoreload.enabled
7898
8046
  ? [jsenvPluginAutoreload(clientAutoreload)]
7899
8047
  : []),
8048
+ ...(modulepreload ? [jsenvPluginModulepreload()] : []),
7900
8049
  ...(dependencyStatus
7901
8050
  ? [jsenvPluginDependencyStatus(dependencyStatus)]
7902
8051
  : []),
@@ -8093,7 +8242,10 @@ const devServerPluginOmegaErrorHandler = () => {
8093
8242
  status: 403,
8094
8243
  };
8095
8244
  }
8096
- return convertFileSystemErrorToResponseProperties(error);
8245
+ // the dev server runs with canExposeSensitiveData: file paths are welcome
8246
+ return convertFileSystemErrorToResponseProperties(error, {
8247
+ canExposeSensitiveData: true,
8248
+ });
8097
8249
  };
8098
8250
  const response = getResponseForError();
8099
8251
  if (!response) {
@@ -8279,10 +8431,9 @@ const prependJsClassicInJsClassic = (jsUrlInfo, urlInfoToPrepend) => {
8279
8431
  const magicSource = createMagicSource(jsUrlInfo.content);
8280
8432
  magicSource.prepend(`${urlInfoToPrepend.content}\n\n`);
8281
8433
  const magicResult = magicSource.toContentAndSourcemap();
8282
- const sourcemap = composeTwoSourcemaps(
8283
- jsUrlInfo.sourcemap,
8284
- magicResult.sourcemap,
8285
- );
8434
+ const sourcemap = jsUrlInfo.context.sourcemapsEnabled
8435
+ ? composeTwoSourcemaps(jsUrlInfo.sourcemap, magicResult.sourcemap)
8436
+ : null;
8286
8437
  jsUrlInfo.mutateContent({
8287
8438
  content: magicResult.content,
8288
8439
  sourcemap,
@@ -8300,6 +8451,7 @@ const prependJsClassicInJsModule = async (jsUrlInfo, urlInfoToPrepend) => {
8300
8451
  input: jsUrlInfo.content,
8301
8452
  inputIsJsModule: true,
8302
8453
  inputUrl: jsUrlInfo.originalUrl,
8454
+ options: { sourceMaps: jsUrlInfo.context.sourcemapsEnabled },
8303
8455
  });
8304
8456
  jsUrlInfo.mutateContent({
8305
8457
  content: code,
@@ -8952,20 +9104,37 @@ const checkForDependencyRemovalEffects = (reference) => {
8952
9104
  };
8953
9105
 
8954
9106
  const traceFromUrlSite = (urlSite) => {
8955
- const codeFrame = urlSite.content
8956
- ? generateContentFrame({
8957
- content: urlSite.content,
8958
- line: urlSite.line,
8959
- column: urlSite.column,
8960
- })
8961
- : "";
8962
- return {
8963
- codeFrame,
8964
- message: stringifyUrlSite(urlSite),
8965
- url: urlSite.url,
8966
- line: urlSite.line,
8967
- column: urlSite.column,
9107
+ const { url, line, column, content } = urlSite;
9108
+ const trace = { url, line, column };
9109
+ // A file references many urls and an error on one of them is rare: the code
9110
+ // frame (and the message embedding it) is built the first time it is read,
9111
+ // not for every reference found.
9112
+ defineLazyProperty(trace, "codeFrame", () =>
9113
+ content ? generateContentFrame({ content, line, column }) : "",
9114
+ );
9115
+ defineLazyProperty(trace, "message", () => stringifyUrlSite(urlSite));
9116
+ return trace;
9117
+ };
9118
+
9119
+ const defineLazyProperty = (object, property, compute) => {
9120
+ const setValue = (value) => {
9121
+ Object.defineProperty(object, property, {
9122
+ enumerable: true,
9123
+ configurable: true,
9124
+ writable: true,
9125
+ value,
9126
+ });
8968
9127
  };
9128
+ Object.defineProperty(object, property, {
9129
+ enumerable: true,
9130
+ configurable: true,
9131
+ get: () => {
9132
+ const value = compute();
9133
+ setValue(value);
9134
+ return value;
9135
+ },
9136
+ set: setValue,
9137
+ });
8969
9138
  };
8970
9139
 
8971
9140
  const adjustUrlSite = (urlInfo, { url, line, column }) => {
@@ -9227,66 +9396,6 @@ GRAPH_VISITOR.forEachUrlInfoStronglyReferenced = (
9227
9396
  seen.clear();
9228
9397
  };
9229
9398
 
9230
- const urlSpecifierEncoding = {
9231
- encode: (reference) => {
9232
- const { generatedSpecifier } = reference;
9233
- if (generatedSpecifier.then) {
9234
- return generatedSpecifier.then((value) => {
9235
- reference.generatedSpecifier = value;
9236
- return urlSpecifierEncoding.encode(reference);
9237
- });
9238
- }
9239
- // allow plugin to return a function to bypas default formatting
9240
- // (which is to use JSON.stringify when url is referenced inside js)
9241
- if (typeof generatedSpecifier === "function") {
9242
- return generatedSpecifier();
9243
- }
9244
- const formatter = formatters[reference.type];
9245
- const value = formatter
9246
- ? formatter.encode(generatedSpecifier)
9247
- : generatedSpecifier;
9248
- if (reference.escape) {
9249
- return reference.escape(value);
9250
- }
9251
- return value;
9252
- },
9253
- decode: (reference) => {
9254
- const formatter = formatters[reference.type];
9255
- return formatter
9256
- ? formatter.decode(reference.generatedSpecifier)
9257
- : reference.generatedSpecifier;
9258
- },
9259
- };
9260
- const formatters = {
9261
- "js_import": { encode: JSON.stringify, decode: JSON.parse },
9262
- "js_url": { encode: JSON.stringify, decode: JSON.parse },
9263
- "css_@import": { encode: JSON.stringify, decode: JSON.stringify },
9264
- // https://github.com/webpack-contrib/css-loader/pull/627/files
9265
- "css_url": {
9266
- encode: (url) => {
9267
- // If url is already wrapped in quotes, remove them
9268
- url = formatters.css_url.decode(url);
9269
- // Should url be wrapped?
9270
- // See https://drafts.csswg.org/css-values-3/#urls
9271
- if (/["'() \t\n]/.test(url)) {
9272
- return `"${url.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
9273
- }
9274
- return url;
9275
- },
9276
- decode: (url) => {
9277
- const firstChar = url[0];
9278
- const lastChar = url[url.length - 1];
9279
- if (firstChar === `"` && lastChar === `"`) {
9280
- return url.slice(1, -1);
9281
- }
9282
- if (firstChar === `'` && lastChar === `'`) {
9283
- return url.slice(1, -1);
9284
- }
9285
- return url;
9286
- },
9287
- },
9288
- };
9289
-
9290
9399
  const createUrlGraph = ({
9291
9400
  rootDirectoryUrl,
9292
9401
  kitchen,
@@ -9464,7 +9573,6 @@ const createUrlInfo = (url, context) => {
9464
9573
  dereferencedTimestamp: 0,
9465
9574
  originalContentEtag: null,
9466
9575
  contentEtag: null,
9467
- isWatched: false,
9468
9576
  isValid: () => false,
9469
9577
  data: {}, // plugins can put whatever they want here
9470
9578
  referenceToOthersSet: new Set(),
@@ -10068,7 +10176,6 @@ const createUrlInfoTransformer = ({
10068
10176
  contentAst, // undefined most of the time
10069
10177
  contentEtag, // in practice always undefined
10070
10178
  contentLength,
10071
- sourcemap,
10072
10179
  sourcemapIsWrong,
10073
10180
  contentInjections,
10074
10181
  } = transformations;
@@ -10095,11 +10202,14 @@ const createUrlInfoTransformer = ({
10095
10202
  contentLength,
10096
10203
  });
10097
10204
  }
10098
- if (
10099
- sourcemap &&
10100
- mayHaveSourcemap(urlInfo) &&
10101
- shouldHandleSourcemap(urlInfo)
10102
- ) {
10205
+ // "sourcemap" is read last, and only when it will be used: a plugin can
10206
+ // hand it back as a getter that generates the map on first read, so that
10207
+ // nothing is generated for a kitchen that throws sourcemaps away
10208
+ const sourcemap =
10209
+ mayHaveSourcemap(urlInfo) && shouldHandleSourcemap(urlInfo)
10210
+ ? transformations.sourcemap
10211
+ : null;
10212
+ if (sourcemap) {
10103
10213
  const sourcemapNormalized = normalizeSourcemap(urlInfo, sourcemap);
10104
10214
  let currentSourcemap = urlInfo.sourcemap;
10105
10215
  const finalSourcemap = composeTwoSourcemaps(
@@ -10132,6 +10242,14 @@ const createUrlInfoTransformer = ({
10132
10242
  writeInsideOutDirectory(urlInfo);
10133
10243
  };
10134
10244
 
10245
+ // Written synchronously on purpose, and measured: async is the tempting
10246
+ // choice, but here it loses. A cold load cooks hundreds of files; their
10247
+ // synchronous writes block the event loop ~160ms in total, while
10248
+ // asynchronous writes queue in the threadpool behind tens of MB of content
10249
+ // and sourcemaps, and a response that waits for its own write then waits
10250
+ // ~36ms on average (18s summed over 500 responses). Not waiting is not an
10251
+ // option either: the last write lands after the test that cooked it has
10252
+ // ended, and the side-effect snapshots lose it.
10135
10253
  const writeInsideOutDirectory = (urlInfo) => {
10136
10254
  // writing result inside ".jsenv" directory (debug purposes)
10137
10255
  if (!outDirectoryUrl) {
@@ -10451,6 +10569,12 @@ const createKitchen = ({
10451
10569
  INJECTIONS,
10452
10570
  getPluginMeta: null,
10453
10571
  sourcemaps,
10572
+ // a plugin producing a sourcemap can skip the work when it would be
10573
+ // thrown away (see shouldHandleSourcemap in url_info_transformations.js)
10574
+ sourcemapsEnabled:
10575
+ sourcemaps === "inline" ||
10576
+ sourcemaps === "file" ||
10577
+ sourcemaps === "programmatic",
10454
10578
  outDirectoryUrl,
10455
10579
  },
10456
10580
  resolve: (specifier, importer = rootDirectoryUrl) => {
@@ -11282,11 +11406,10 @@ const devServerPluginServeSourceFiles = ({
11282
11406
  if (existing) {
11283
11407
  return existing;
11284
11408
  }
11285
- const watchAssociations = URL_META.resolveAssociations(
11286
- { watch: stopWatchingSourceFiles.watchPatterns },
11287
- sourceDirectoryUrl,
11288
- );
11289
11409
  let kitchen;
11410
+ // per url info, the file stat under which its content was last compared
11411
+ // with the file on disk and found identical (see isValid)
11412
+ const fileStatValidatedMap = new WeakMap();
11290
11413
  clientFileChangeEventEmitter.on(({ url, event }) => {
11291
11414
  const urlInfo = kitchen.graph.getUrlInfo(url);
11292
11415
  if (urlInfo) {
@@ -11327,11 +11450,6 @@ const devServerPluginServeSourceFiles = ({
11327
11450
  packageDirectory,
11328
11451
  });
11329
11452
  kitchen.graph.urlInfoCreatedEventEmitter.on((urlInfoCreated) => {
11330
- const { watch } = URL_META.applyAssociations({
11331
- url: urlInfoCreated.url,
11332
- associations: watchAssociations,
11333
- });
11334
- urlInfoCreated.isWatched = watch;
11335
11453
  // when an url depends on many others, we check all these (like package.json)
11336
11454
  urlInfoCreated.isValid = () => {
11337
11455
  const seenSet = new Set();
@@ -11351,37 +11469,64 @@ const devServerPluginServeSourceFiles = ({
11351
11469
  // was compared using etag and it has changed
11352
11470
  return false;
11353
11471
  }
11354
- // Watched files trust the watcher — except the ones marked
11355
- // revalidateOnFileSystem (package.json files, see node_esm_resolver):
11356
- // the watcher fires a beat AFTER a change, and what these files
11357
- // decide (a package version, hence the ?v= the importer embeds) is
11358
- // cached as immutable by the browser — a request racing the watcher
11359
- // must not win a stale answer it would then keep forever.
11360
- if (!urlInfo.isWatched || urlInfo.revalidateOnFileSystem) {
11361
- // check the filesystem
11362
- let fileContentAsBuffer;
11472
+ // The content held in memory is the file as it was read; a request
11473
+ // must never win a stale answer, whatever the watcher's latency,
11474
+ // and the ?v= a package.json decides even ends up in the browser's
11475
+ // immutable cache. So the file is checked on disk at every
11476
+ // validation — cheaply: a stat, compared with the one taken when
11477
+ // the content was read (or last found identical). Only a file
11478
+ // whose stat moved is read and hashed again.
11479
+ // Inline content (a <script> inside an html) has no file of its
11480
+ // own: it is as fresh as the html holding it, checked by the caller.
11481
+ if (!urlInfo.isInline) {
11482
+ let fileStat;
11363
11483
  try {
11364
- fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
11365
- } catch (e) {
11366
- if (e.code === "ENOENT") {
11367
- urlInfo.onModified();
11368
- return false;
11369
- }
11484
+ fileStat = statSync(new URL(urlInfo.url), {
11485
+ throwIfNoEntry: false,
11486
+ });
11487
+ } catch {
11370
11488
  return false;
11371
11489
  }
11372
- const fileContentEtag = bufferToEtag(fileContentAsBuffer);
11373
- if (fileContentEtag !== urlInfo.originalContentEtag) {
11490
+ if (!fileStat) {
11374
11491
  urlInfo.onModified();
11375
- // restore content to be able to compare it again later
11376
- urlInfo.kitchen.urlInfoTransformer.setContent(
11377
- urlInfo,
11378
- String(fileContentAsBuffer),
11379
- {
11380
- contentEtag: fileContentEtag,
11381
- },
11382
- );
11383
11492
  return false;
11384
11493
  }
11494
+ const fileStatKnown =
11495
+ fileStatValidatedMap.get(urlInfo) || urlInfo.data.fileStat;
11496
+ const fileUnchanged =
11497
+ fileStatKnown &&
11498
+ fileStatKnown.mtimeMs === fileStat.mtimeMs &&
11499
+ fileStatKnown.size === fileStat.size;
11500
+ if (!fileUnchanged) {
11501
+ let fileContentAsBuffer;
11502
+ try {
11503
+ fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
11504
+ } catch (e) {
11505
+ if (e.code === "ENOENT") {
11506
+ urlInfo.onModified();
11507
+ return false;
11508
+ }
11509
+ return false;
11510
+ }
11511
+ const fileContentEtag = bufferToEtag(fileContentAsBuffer);
11512
+ if (fileContentEtag !== urlInfo.originalContentEtag) {
11513
+ fileStatValidatedMap.delete(urlInfo);
11514
+ urlInfo.onModified();
11515
+ // restore content to be able to compare it again later
11516
+ urlInfo.kitchen.urlInfoTransformer.setContent(
11517
+ urlInfo,
11518
+ String(fileContentAsBuffer),
11519
+ {
11520
+ contentEtag: fileContentEtag,
11521
+ },
11522
+ );
11523
+ return false;
11524
+ }
11525
+ fileStatValidatedMap.set(urlInfo, {
11526
+ mtimeMs: fileStat.mtimeMs,
11527
+ size: fileStat.size,
11528
+ });
11529
+ }
11385
11530
  }
11386
11531
  for (const implicitUrl of urlInfo.implicitUrlSet) {
11387
11532
  const implicitUrlInfo = urlInfo.graph.getUrlInfo(implicitUrl);
@@ -11497,6 +11642,40 @@ const devServerPluginServeSourceFiles = ({
11497
11642
  const ifNoneMatch = request.headers["if-none-match"];
11498
11643
  const inlineParentUrlInfo = urlInfo.findParentIfInline();
11499
11644
  const urlInfoTargetedByCache = inlineParentUrlInfo || urlInfo;
11645
+ // The content held in memory is the response when it is finalized
11646
+ // and still valid. Content can be defined while a cook is still in
11647
+ // flight (a file watcher invalidation re-cooking in the background,
11648
+ // for instance): at that point it holds the raw fetched content,
11649
+ // transformations not applied yet. Serving that would send an html
11650
+ // without any of the injected scripts. An inline url info (a
11651
+ // <script> inside an html) is cooked again whenever the html
11652
+ // containing it is cooked: its content is as fresh as the html's,
11653
+ // so both must be valid.
11654
+ const hasFreshContent = (urlInfo) =>
11655
+ urlInfo.content !== undefined &&
11656
+ urlInfo.contentFinalized &&
11657
+ urlInfo.isValid();
11658
+ const memoryContentIsFresh = () =>
11659
+ inlineParentUrlInfo
11660
+ ? hasFreshContent(urlInfo) && hasFreshContent(inlineParentUrlInfo)
11661
+ : hasFreshContent(urlInfo);
11662
+ // a 304 goes through the hooks too: its headers stand for the
11663
+ // cached response's, they must say the same
11664
+ const augmentResponse = (response) => {
11665
+ const augmentResponseInfo = {
11666
+ ...kitchen.context,
11667
+ reference,
11668
+ urlInfo,
11669
+ };
11670
+ kitchen.jsenvPluginsController.callHooks(
11671
+ "augmentResponse",
11672
+ augmentResponseInfo,
11673
+ (returnValue) => {
11674
+ response = composeTwoResponses(response, returnValue);
11675
+ },
11676
+ );
11677
+ return response;
11678
+ };
11500
11679
  const respondWithNotModified = () => {
11501
11680
  const headers = {
11502
11681
  "cache-control": `private,max-age=0,must-revalidate`,
@@ -11506,23 +11685,21 @@ const devServerPluginServeSourceFiles = ({
11506
11685
  headers[key] = urlInfo.headers[key];
11507
11686
  }
11508
11687
  });
11509
- return {
11688
+ return augmentResponse({
11510
11689
  status: 304,
11511
11690
  headers,
11512
- };
11691
+ });
11513
11692
  };
11514
11693
 
11515
11694
  try {
11516
- // an inline url info is cooked again every time the file containing it
11517
- // is cooked, so its content is only known after cooking; its etag is
11518
- // compared below, once cooked
11519
- if (!urlInfo.error && ifNoneMatch && !inlineParentUrlInfo) {
11695
+ if (!urlInfo.error && ifNoneMatch) {
11520
11696
  const [clientOriginalContentEtag, clientContentEtag] =
11521
11697
  ifNoneMatch.split("_");
11522
11698
  if (
11523
- urlInfo.originalContentEtag === clientOriginalContentEtag &&
11699
+ urlInfoTargetedByCache.originalContentEtag ===
11700
+ clientOriginalContentEtag &&
11524
11701
  urlInfo.contentEtag === clientContentEtag &&
11525
- urlInfo.isValid()
11702
+ memoryContentIsFresh()
11526
11703
  ) {
11527
11704
  return respondWithNotModified();
11528
11705
  }
@@ -11540,18 +11717,9 @@ const devServerPluginServeSourceFiles = ({
11540
11717
  // client etag to match).
11541
11718
  const servableFromMemory =
11542
11719
  !urlInfo.error &&
11543
- !inlineParentUrlInfo &&
11544
11720
  !urlInfo.response &&
11545
- urlInfo.content !== undefined &&
11546
- // content can be defined while a cook is still in flight (a file
11547
- // watcher invalidation re-cooking in the background, for
11548
- // instance): at that point it holds the raw fetched content,
11549
- // transformations not applied yet. Serving that would send an
11550
- // html without any of the injected scripts. Only finalized
11551
- // content is a complete response; anything else must go through
11552
- // cook() below, which joins the pending cook (see debounceCook).
11553
- urlInfo.contentFinalized &&
11554
11721
  !cacheIsDisabledInResponseHeader(urlInfo) &&
11722
+ !cacheIsDisabledInResponseHeader(urlInfoTargetedByCache) &&
11555
11723
  // a "?hot" request exists to bypass every cache, this one
11556
11724
  // included: it must be cooked, because cooking is what rewrites
11557
11725
  // its references so "?hot" cascades to the modified files below
@@ -11570,7 +11738,7 @@ const devServerPluginServeSourceFiles = ({
11570
11738
  // signal registry throwing on duplicate ids). Re-cooking under
11571
11739
  // the normal request rewrites the references clean.
11572
11740
  !urlInfo.contentCookedForHotRequest &&
11573
- urlInfo.isValid();
11741
+ memoryContentIsFresh();
11574
11742
  if (!servableFromMemory) {
11575
11743
  await urlInfo.cook({ request, reference });
11576
11744
  urlInfo.contentCookedForHotRequest =
@@ -11628,19 +11796,7 @@ const devServerPluginServeSourceFiles = ({
11628
11796
  ? { "served from memory cache": null }
11629
11797
  : urlInfo.timing,
11630
11798
  };
11631
- const augmentResponseInfo = {
11632
- ...kitchen.context,
11633
- reference,
11634
- urlInfo,
11635
- };
11636
- kitchen.jsenvPluginsController.callHooks(
11637
- "augmentResponse",
11638
- augmentResponseInfo,
11639
- (returnValue) => {
11640
- response = composeTwoResponses(response, returnValue);
11641
- },
11642
- );
11643
- return response;
11799
+ return augmentResponse(response);
11644
11800
  } catch (error) {
11645
11801
  const originalError = error ? error.cause || error : error;
11646
11802
  if (originalError.asResponse) {
@@ -11718,6 +11874,14 @@ const devServerPluginServeSourceFiles = ({
11718
11874
  "cache-control": "no-store",
11719
11875
  },
11720
11876
  };
11877
+ } finally {
11878
+ // What the request put on the url info context is for this
11879
+ // request only: a cook happening later for another reason (the
11880
+ // html holding an inline script is cooked again) must not see a
11881
+ // stale "requestedUrl" and take the inline script for a direct
11882
+ // request, which would make it pick the content of a previous
11883
+ // reference (see jsenv:inline_content_fetcher).
11884
+ forgetRequestFromContext(urlInfo.context);
11721
11885
  }
11722
11886
  },
11723
11887
  },
@@ -11727,6 +11891,15 @@ const devServerPluginServeSourceFiles = ({
11727
11891
  return [devServerPluginRoutes, ...devServerJsenvPluginStore.allServerPlugins];
11728
11892
  };
11729
11893
 
11894
+ const forgetRequestFromContext = (context) => {
11895
+ // own properties only: what is inherited from the owner context stays
11896
+ for (const key of ["request", "requestedUrl", "reference"]) {
11897
+ if (Object.hasOwn(context, key)) {
11898
+ delete context[key];
11899
+ }
11900
+ }
11901
+ };
11902
+
11730
11903
  const cacheIsDisabledInResponseHeader = (urlInfo) => {
11731
11904
  return (
11732
11905
  urlInfo.headers["cache-control"] === "no-store" ||
@@ -11758,6 +11931,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11758
11931
  * @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).
11759
11932
  * @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"`).
11760
11933
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11934
+ * @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).
11761
11935
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
11762
11936
  * @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`.
11763
11937
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
@@ -11799,6 +11973,7 @@ const startDevServer = async ({
11799
11973
  sourceFilesConfig = {},
11800
11974
  clientAutoreload = true,
11801
11975
  clientAutoreloadOnServerRestart = true,
11976
+ modulepreload = false,
11802
11977
  // server-timing response headers: devtools show how the time to answer is
11803
11978
  // spent (cook measures come from the kitchen, see urlInfo.timing). Entries
11804
11979
  // under minDuration are dropped so a human reads the measures that matter;
@@ -11888,9 +12063,12 @@ const startDevServer = async ({
11888
12063
  }
11889
12064
 
11890
12065
  const logger = createLogger({ logLevel });
11891
- const startDevServerTask = createTaskLog("start dev server", {
11892
- disabled: !logger.levels.info,
11893
- });
12066
+ const startDevServerTask = createTaskLog(
12067
+ `start dev server for ${humanizeSourceDirectory(sourceDirectoryUrl)}`,
12068
+ {
12069
+ disabled: !logger.levels.info,
12070
+ },
12071
+ );
11894
12072
 
11895
12073
  const serverStopCallbackSet = new Set();
11896
12074
  const serverStopAbortController = new AbortController();
@@ -11969,6 +12147,7 @@ const startDevServer = async ({
11969
12147
 
11970
12148
  clientAutoreload,
11971
12149
  clientAutoreloadOnServerRestart,
12150
+ modulepreload,
11972
12151
  dependencyStatus: {
11973
12152
  dependencyProblemEventEmitter,
11974
12153
  getDependencyProblems: dependencyWatcher.getProblems,
@@ -12074,4 +12253,13 @@ const startDevServer = async ({
12074
12253
  };
12075
12254
  };
12076
12255
 
12256
+ // "./src/" when inside the current working directory, the full path otherwise
12257
+ const humanizeSourceDirectory = (sourceDirectoryUrl) => {
12258
+ const cwdUrl = pathToFileURL(`${process.cwd()}/`).href;
12259
+ if (urlIsOrIsInsideOf(sourceDirectoryUrl, cwdUrl)) {
12260
+ return `./${urlToRelativeUrl(sourceDirectoryUrl, cwdUrl)}`;
12261
+ }
12262
+ return urlToFileSystemPath(sourceDirectoryUrl);
12263
+ };
12264
+
12077
12265
  export { startDevServer };