@jsenv/core 41.4.17 → 41.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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";
@@ -3129,11 +3129,6 @@ const addRelationshipWithPackageJson = ({
3129
3129
  String(packageJsonContentAsBuffer),
3130
3130
  );
3131
3131
  }
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
3132
  };
3138
3133
 
3139
3134
  const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
@@ -3506,18 +3501,21 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
3506
3501
  const getDirectoryWatchPatterns = (
3507
3502
  directoryUrl,
3508
3503
  watchedDirectoryUrl,
3509
- { sourceFilesConfig },
3504
+ { sourceFilesConfig, defaultPatterns = true },
3510
3505
  ) => {
3511
3506
  const directoryUrlRelativeToWatchedDirectory = urlToRelativeUrl(
3512
3507
  directoryUrl,
3513
3508
  watchedDirectoryUrl,
3514
3509
  );
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
- };
3510
+ const watchPatterns = {};
3511
+ if (defaultPatterns) {
3512
+ Object.assign(watchPatterns, {
3513
+ [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
3514
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
3515
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
3516
+ [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
3517
+ });
3518
+ }
3521
3519
  for (const key of Object.keys(sourceFilesConfig)) {
3522
3520
  watchPatterns[`${directoryUrlRelativeToWatchedDirectory}${key}`] =
3523
3521
  sourceFilesConfig[key];
@@ -3538,11 +3536,12 @@ const watchSourceFiles = (
3538
3536
  // And jsenv should not consider these as source files and watch them (to not hurt performances)
3539
3537
  const watchPatterns = {};
3540
3538
  let watchedDirectoryUrl = "";
3541
- const addDirectoryToWatch = (directoryUrl) => {
3539
+ const addDirectoryToWatch = (directoryUrl, { defaultPatterns } = {}) => {
3542
3540
  Object.assign(
3543
3541
  watchPatterns,
3544
3542
  getDirectoryWatchPatterns(directoryUrl, watchedDirectoryUrl, {
3545
3543
  sourceFilesConfig,
3544
+ defaultPatterns,
3546
3545
  }),
3547
3546
  );
3548
3547
  };
@@ -3574,7 +3573,6 @@ const watchSourceFiles = (
3574
3573
  },
3575
3574
  },
3576
3575
  );
3577
- stopWatchingSourceFiles.watchPatterns = watchPatterns;
3578
3576
  return stopWatchingSourceFiles;
3579
3577
  };
3580
3578
 
@@ -3594,16 +3592,18 @@ const watchSourceFiles = (
3594
3592
  }
3595
3593
  watchedDirectoryUrl = packageDirectoryUrl;
3596
3594
  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
- }
3595
+ const workspaceDirectoryUrl = workspace.endsWith("*")
3596
+ ? new URL(workspace.slice(0, -1), packageDirectoryUrl)
3597
+ : new URL(workspace, packageDirectoryUrl);
3598
+ addDirectoryToWatch(workspaceDirectoryUrl, {
3599
+ // The source directory patterns already cover a workspace inside it.
3600
+ // Every pattern is tested against every file found while watching, so
3601
+ // the same patterns rooted at each workspace only multiply that work.
3602
+ defaultPatterns: !urlIsOrIsInsideOf(
3603
+ workspaceDirectoryUrl,
3604
+ sourceDirectoryUrl,
3605
+ ),
3606
+ });
3607
3607
  }
3608
3608
  // we are updating the root directory
3609
3609
  // we must make the patterns relative to source directory relative to the new root directory
@@ -3616,6 +3616,240 @@ const watchSourceFiles = (
3616
3616
  return watch();
3617
3617
  };
3618
3618
 
3619
+ const jsenvPluginFsRedirection = ({
3620
+ spa,
3621
+ directoryContentMagicName,
3622
+ magicExtensions = ["inherit", ".js"],
3623
+ magicDirectoryIndex = true,
3624
+ preserveSymlinks = false,
3625
+ }) => {
3626
+ return {
3627
+ name: "jsenv:fs_redirection",
3628
+ appliesDuring: "*",
3629
+ redirectReference: (reference) => {
3630
+ if (reference.url === "file:///") {
3631
+ return `ignore:file:///`;
3632
+ }
3633
+ if (reference.url === "file://") {
3634
+ return `ignore:file://`;
3635
+ }
3636
+ // ignore all new URL second arg
3637
+ if (reference.subtype === "new_url_second_arg") {
3638
+ if (reference.original) {
3639
+ return `ignore:${reference.original.specifier}`;
3640
+ }
3641
+ return `ignore:${reference.specifier}`;
3642
+ }
3643
+ // http, https, data, about, ...
3644
+ if (!reference.url.startsWith("file:")) {
3645
+ return null;
3646
+ }
3647
+ if (reference.original && !reference.original.url.startsWith("file:")) {
3648
+ return null;
3649
+ }
3650
+ if (reference.isInline) {
3651
+ return null;
3652
+ }
3653
+
3654
+ if (
3655
+ reference.specifierPathname.endsWith(`/${directoryContentMagicName}`)
3656
+ ) {
3657
+ const { rootDirectoryUrl } = reference.ownerUrlInfo.context;
3658
+ const directoryUrl = new URL(
3659
+ reference.specifierPathname
3660
+ .replace(`/${directoryContentMagicName}`, "/")
3661
+ .slice(1),
3662
+ rootDirectoryUrl,
3663
+ ).href;
3664
+ return directoryUrl;
3665
+ }
3666
+ // ignore "./" on new URL("./")
3667
+ // if (
3668
+ // reference.subtype === "new_url_first_arg" &&
3669
+ // reference.specifier === "./"
3670
+ // ) {
3671
+ // return `ignore:${reference.url}`;
3672
+ // }
3673
+ const urlObject = new URL(reference.url);
3674
+ let fsStat = readEntryStatSync(urlObject, { nullIfNotFound: true });
3675
+ reference.fsStat = fsStat;
3676
+ const { search, hash } = urlObject;
3677
+ urlObject.search = "";
3678
+ urlObject.hash = "";
3679
+ // must be read before applyFsStatEffectsOnUrlObject which forces the
3680
+ // trailing slash on directories
3681
+ const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
3682
+ applyFsStatEffectsOnUrlObject(urlObject, fsStat);
3683
+ const shouldApplyFilesystemMagicResolution =
3684
+ reference.type === "js_import";
3685
+ if (shouldApplyFilesystemMagicResolution) {
3686
+ const filesystemResolution = applyFileSystemMagicResolution(
3687
+ urlObject.href,
3688
+ {
3689
+ fileStat: fsStat,
3690
+ magicDirectoryIndex,
3691
+ magicExtensions: getExtensionsToTry(
3692
+ magicExtensions,
3693
+ reference.ownerUrlInfo.url,
3694
+ ),
3695
+ },
3696
+ );
3697
+ if (filesystemResolution.stat) {
3698
+ fsStat = filesystemResolution.stat;
3699
+ reference.fsStat = fsStat;
3700
+ urlObject.href = filesystemResolution.url;
3701
+ applyFsStatEffectsOnUrlObject(urlObject, fsStat);
3702
+ }
3703
+ }
3704
+ if (spa) {
3705
+ // for SPA we want to serve the root HTML file most of the time
3706
+ if (!fsStat) {
3707
+ if (urlToExtension(urlObject)) {
3708
+ // url has an extension, we assume it's a file request -> let 404 happen
3709
+ return null;
3710
+ }
3711
+ if (specifierUsesTrailingSlash) {
3712
+ // the trailing slash asks for a directory and there is none here
3713
+ // -> let 404 happen (same reasoning as the extension above)
3714
+ return null;
3715
+ }
3716
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
3717
+ if (spaFallbackUrl) {
3718
+ return spaFallbackUrl;
3719
+ }
3720
+ return null;
3721
+ }
3722
+ if (fsStat.isDirectory()) {
3723
+ // When requesting a directory, check if we have an HTML entry file for that directory
3724
+ const directoryEntryFileUrl = getDirectoryEntryFileUrl(urlObject);
3725
+ if (directoryEntryFileUrl) {
3726
+ reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
3727
+ return directoryEntryFileUrl;
3728
+ }
3729
+ if (!specifierUsesTrailingSlash) {
3730
+ // the trailing slash is what tells a directory apart from a route:
3731
+ // "/join/" is the directory, "/join" is a route owned by the SPA
3732
+ // even when "join/" exists in the source files.
3733
+ // Without this a source directory would shadow the route having
3734
+ // the same name and the SPA would be unreachable in dev while
3735
+ // being perfectly fine once built
3736
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
3737
+ if (spaFallbackUrl) {
3738
+ reference.fsStat = readEntryStatSync(spaFallbackUrl, {
3739
+ nullIfNotFound: true,
3740
+ });
3741
+ return spaFallbackUrl;
3742
+ }
3743
+ }
3744
+ }
3745
+ }
3746
+ if (!fsStat) {
3747
+ return null;
3748
+ }
3749
+ const urlBeforeSymlinkResolution = urlObject.href;
3750
+ if (preserveSymlinks) {
3751
+ return `${urlBeforeSymlinkResolution}${search}${hash}`;
3752
+ }
3753
+ const urlAfterSymlinkResolution = resolveSymlink(
3754
+ urlBeforeSymlinkResolution,
3755
+ );
3756
+ if (urlAfterSymlinkResolution !== urlBeforeSymlinkResolution) {
3757
+ reference.leadsToASymlink = true;
3758
+ // reference.baseUrl = urlBeforeSymlinkResolution;
3759
+ }
3760
+ const resolvedUrl = `${urlAfterSymlinkResolution}${search}${hash}`;
3761
+ return resolvedUrl;
3762
+ },
3763
+ };
3764
+ };
3765
+
3766
+ const applyFsStatEffectsOnUrlObject = (urlObject, fsStat) => {
3767
+ if (!fsStat) {
3768
+ return;
3769
+ }
3770
+ const { pathname } = urlObject;
3771
+ const pathnameUsesTrailingSlash = pathname.endsWith("/");
3772
+ // force trailing slash on directories
3773
+ if (fsStat.isDirectory()) {
3774
+ if (!pathnameUsesTrailingSlash) {
3775
+ urlObject.pathname = `${pathname}/`;
3776
+ }
3777
+ } else if (pathnameUsesTrailingSlash) {
3778
+ // otherwise remove trailing slash if any
3779
+ // a warning here? (because it's strange to reference a file with a trailing slash)
3780
+ urlObject.pathname = pathname.slice(0, -1);
3781
+ }
3782
+ };
3783
+
3784
+ const resolveSymlink = (fileUrl) => {
3785
+ const urlObject = new URL(fileUrl);
3786
+ const realpath = realpathSync(urlObject);
3787
+ const realUrlObject = pathToFileURL(realpath);
3788
+ if (urlObject.pathname.endsWith("/")) {
3789
+ realUrlObject.pathname += `/`;
3790
+ }
3791
+ return realUrlObject.href;
3792
+ };
3793
+
3794
+ const getDirectoryEntryFileUrl = (directoryUrl) => {
3795
+ const indexHtmlFileUrl = new URL(`index.html`, directoryUrl);
3796
+ if (existsSync(indexHtmlFileUrl)) {
3797
+ return indexHtmlFileUrl.href;
3798
+ }
3799
+ const filename = urlToFilename(directoryUrl);
3800
+ const htmlFileUrlCandidate = new URL(`${filename}.html`, directoryUrl);
3801
+ if (existsSync(htmlFileUrlCandidate)) {
3802
+ return htmlFileUrlCandidate.href;
3803
+ }
3804
+ return null;
3805
+ };
3806
+ const getSpaFallbackUrl = (reference) => {
3807
+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
3808
+ reference.ownerUrlInfo.context;
3809
+ if (!requestedUrl) {
3810
+ // the SPA fallback answers a request; during build there is none
3811
+ return null;
3812
+ }
3813
+ const spaFallbackFileUrls = listSpaFallbackFileUrls(requestedUrl, {
3814
+ rootDirectoryUrl,
3815
+ mainFilePath,
3816
+ });
3817
+ for (const spaFallbackFileUrl of spaFallbackFileUrls) {
3818
+ if (existsSync(new URL(spaFallbackFileUrl))) {
3819
+ return spaFallbackFileUrl;
3820
+ }
3821
+ }
3822
+ // none exists: the main file it is, and the 404 answering for it lists
3823
+ // what was tried (see directory listing)
3824
+ return new URL(mainFilePath, rootDirectoryUrl).href;
3825
+ };
3826
+ // The html files that can answer a route, closest first: the entry file of
3827
+ // the route's own directory ("index.html", then "<dirname>.html"), then of
3828
+ // each directory above it up to the server root, then the main file.
3829
+ const listSpaFallbackFileUrls = (
3830
+ requestedUrl,
3831
+ { rootDirectoryUrl, mainFilePath },
3832
+ ) => {
3833
+ const fileUrls = [];
3834
+ let directoryUrl = new URL("./", requestedUrl).href;
3835
+ while (urlIsOrIsInsideOf(directoryUrl, rootDirectoryUrl)) {
3836
+ fileUrls.push(new URL("index.html", directoryUrl).href);
3837
+ const filename = urlToFilename(directoryUrl);
3838
+ if (filename) {
3839
+ fileUrls.push(new URL(`${filename}.html`, directoryUrl).href);
3840
+ }
3841
+ if (directoryUrl === String(rootDirectoryUrl)) {
3842
+ break;
3843
+ }
3844
+ directoryUrl = new URL("../", directoryUrl).href;
3845
+ }
3846
+ const mainFileUrl = new URL(mainFilePath, rootDirectoryUrl).href;
3847
+ if (!fileUrls.includes(mainFileUrl)) {
3848
+ fileUrls.push(mainFileUrl);
3849
+ }
3850
+ return fileUrls;
3851
+ };
3852
+
3619
3853
  /*
3620
3854
  * NICE TO HAVE:
3621
3855
  *
@@ -3993,6 +4227,24 @@ const generateDirectoryListingInjection = (
3993
4227
  filePathExisting: `/${filePathExisting}`,
3994
4228
  filePathNotFound,
3995
4229
  });
4230
+ // a url without extension nor trailing slash is a route: in spa mode it
4231
+ // was answered with the closest html file, the 404 means none was found
4232
+ const urlNotFoundObject = new URL(urlNotFound);
4233
+ if (
4234
+ spa &&
4235
+ !urlToExtension(urlNotFoundObject) &&
4236
+ !urlNotFoundObject.pathname.endsWith("/")
4237
+ ) {
4238
+ enoentDetails.spaFallbackFilePaths = listSpaFallbackFileUrls(
4239
+ urlNotFound,
4240
+ { rootDirectoryUrl: serverRootDirectoryUrl, mainFilePath },
4241
+ ).map((fileUrl) =>
4242
+ FILE_AND_SERVER_URLS_CONVERTER.asServerUrl(
4243
+ fileUrl,
4244
+ serverRootDirectoryUrl,
4245
+ ),
4246
+ );
4247
+ }
3996
4248
  }
3997
4249
 
3998
4250
  return {
@@ -4064,235 +4316,18 @@ const getDirectoryContentItems = ({
4064
4316
  return items;
4065
4317
  };
4066
4318
 
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,
4319
+ const directoryContentMagicName = "...";
4320
+
4321
+ const jsenvPluginProtocolFile = ({
4322
+ spa = true,
4323
+ magicExtensions,
4324
+ magicDirectoryIndex,
4325
+ preserveSymlinks,
4326
+ directoryListing,
4327
+ rootDirectoryUrl,
4328
+ mainFilePath,
4329
+ packageDirectory,
4330
+ sourceFilesConfig,
4296
4331
  }) => {
4297
4332
  const listHtmlPages = createHtmlPageLister({ rootDirectoryUrl });
4298
4333
 
@@ -4434,7 +4469,16 @@ const jsenvPluginProtocolFile = ({
4434
4469
  }
4435
4470
  const serveFile = (url) => {
4436
4471
  const contentType = CONTENT_TYPE.fromUrlExtension(url);
4437
- const fileBuffer = readFileSync(new URL(url));
4472
+ const urlObject = new URL(url);
4473
+ // taken before the read: a write landing in between moves the
4474
+ // stat past this one, so the dev server sees the content as
4475
+ // outdated (see isValid in the dev server) rather than the reverse
4476
+ const fileStat = statSync(urlObject);
4477
+ const fileBuffer = readFileSync(urlObject);
4478
+ urlInfo.data.fileStat = {
4479
+ mtimeMs: fileStat.mtimeMs,
4480
+ size: fileStat.size,
4481
+ };
4438
4482
  const content = CONTENT_TYPE.isTextual(contentType)
4439
4483
  ? String(fileBuffer)
4440
4484
  : fileBuffer;
@@ -5934,7 +5978,7 @@ const jsenvPluginImportMetaScenarios = () => {
5934
5978
  name: "jsenv:import_meta_scenario",
5935
5979
  appliesDuring: "*",
5936
5980
  transformUrlContent: {
5937
- js_module: async (urlInfo) => {
5981
+ js_module: (urlInfo) => {
5938
5982
  // Do not scan node modules for import.meta.dev/import.meta.build
5939
5983
  // - node modules won't have this in their code
5940
5984
  // - ;or should use other an other technic as this one won't be available
@@ -5950,40 +5994,42 @@ const jsenvPluginImportMetaScenarios = () => {
5950
5994
  ) {
5951
5995
  return null;
5952
5996
  }
5953
- const { metadata } = await applyBabelPlugins({
5954
- babelPlugins: [babelPluginMetadataImportMetaScenarios],
5955
- input: urlInfo.content,
5956
- inputIsJsModule: true,
5957
- inputUrl: urlInfo.originalUrl,
5958
- outputUrl: urlInfo.generatedUrl,
5997
+ const importMetaScenarioNodes = { dev: [], build: [] };
5998
+ visitJsAst(urlInfo.contentAst, {
5999
+ MemberExpression: (node) => {
6000
+ const name = getImportMetaPropertyName(node);
6001
+ if (name === "dev" || name === "build") {
6002
+ importMetaScenarioNodes[name].push(node);
6003
+ }
6004
+ },
5959
6005
  });
5960
- const { dev = [], build = [] } = metadata.importMetaScenarios;
6006
+ const { dev, build } = importMetaScenarioNodes;
5961
6007
  const replacements = [];
5962
- const replace = (path, value) => {
5963
- replacements.push({ path, value });
6008
+ const replace = (node, value) => {
6009
+ replacements.push({ node, value });
5964
6010
  };
5965
6011
  if (urlInfo.context.build) {
5966
6012
  // during build ensure replacement for tree-shaking
5967
- dev.forEach((path) => {
5968
- replace(path, "undefined");
6013
+ dev.forEach((node) => {
6014
+ replace(node, "undefined");
5969
6015
  });
5970
- build.forEach((path) => {
5971
- replace(path, "true");
6016
+ build.forEach((node) => {
6017
+ replace(node, "true");
5972
6018
  });
5973
6019
  } else {
5974
6020
  // during dev we can let "import.meta.build" untouched
5975
6021
  // it will be evaluated to undefined.
5976
6022
  // Moreover it can be surprising to see some "undefined"
5977
6023
  // when source file contains "import.meta.build"
5978
- dev.forEach((path) => {
5979
- replace(path, "true");
6024
+ dev.forEach((node) => {
6025
+ replace(node, "true");
5980
6026
  });
5981
6027
  }
5982
6028
  const magicSource = createMagicSource(urlInfo.content);
5983
- replacements.forEach(({ path, value }) => {
6029
+ replacements.forEach(({ node, value }) => {
5984
6030
  magicSource.replace({
5985
- start: path.node.start,
5986
- end: path.node.end,
6031
+ start: node.start,
6032
+ end: node.end,
5987
6033
  replacement: value,
5988
6034
  });
5989
6035
  });
@@ -5993,42 +6039,6 @@ const jsenvPluginImportMetaScenarios = () => {
5993
6039
  };
5994
6040
  };
5995
6041
 
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
6042
  /*
6033
6043
  * Source code can contain the following
6034
6044
  * - __DEV__
@@ -6127,15 +6137,10 @@ const jsenvPluginImportMetaCss = () => {
6127
6137
  if (!urlInfo.content.includes("import.meta.css")) {
6128
6138
  return null;
6129
6139
  }
6130
- const { metadata } = await applyBabelPlugins({
6131
- babelPlugins: [babelPluginMetadataUsesImportMetaCss],
6132
- input: urlInfo.content,
6133
- inputIsJsModule: true,
6134
- inputUrl: urlInfo.originalUrl,
6135
- outputUrl: urlInfo.generatedUrl,
6140
+ const importMetaCssNode = visitJsAstUntil(urlInfo.contentAst, {
6141
+ MemberExpression: (node) => getImportMetaPropertyName(node) === "css",
6136
6142
  });
6137
- const { usesImportMetaCss } = metadata;
6138
- if (!usesImportMetaCss) {
6143
+ if (!importMetaCssNode) {
6139
6144
  return null;
6140
6145
  }
6141
6146
  if (urlInfo.context.build) {
@@ -6215,37 +6220,6 @@ const babelPluginRewriteImportMetaCssAssignment = (
6215
6220
  };
6216
6221
  };
6217
6222
 
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
6223
  const injectImportMetaCss = (
6250
6224
  urlInfo,
6251
6225
  { content, importFrom, importName, importAs, hot },
@@ -6288,104 +6262,6 @@ ${importVariableName}(import.meta);
6288
6262
  };
6289
6263
  };
6290
6264
 
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
6265
  // Some "smart" default applied to decide what should hot reload / fullreload:
6390
6266
  // By default:
6391
6267
  // - hot reload on <img src="./image.png" />
@@ -6533,6 +6409,75 @@ const htmlNodeCanHotReload = (node) => {
6533
6409
  ].includes(node.nodeName);
6534
6410
  };
6535
6411
 
6412
+ const analyzeImportMetaHot = (ast) => {
6413
+ const importMetaHotNodes = [];
6414
+ let hotDecline = false;
6415
+ let hotAcceptSelf = false;
6416
+ let hotAcceptSpecifiers = [];
6417
+ visitJsAst(ast, {
6418
+ MemberExpression: (node) => {
6419
+ if (getImportMetaPropertyName(node) === "hot") {
6420
+ importMetaHotNodes.push(node);
6421
+ }
6422
+ },
6423
+ CallExpression: (node) => {
6424
+ const methodName = getImportMetaHotMethodName(node);
6425
+ if (methodName === "accept") {
6426
+ const args = node.arguments;
6427
+ if (args.length === 0) {
6428
+ hotAcceptSelf = true;
6429
+ return;
6430
+ }
6431
+ const [firstArg] = args;
6432
+ if (isStringLiteral(firstArg)) {
6433
+ hotAcceptSpecifiers = [firstArg.value];
6434
+ return;
6435
+ }
6436
+ if (firstArg.type === "ArrayExpression") {
6437
+ hotAcceptSpecifiers = firstArg.elements.map((element) => {
6438
+ if (!isStringLiteral(element)) {
6439
+ throw new Error(
6440
+ `all array elements must be strings in "import.meta.hot.accept(array)"`,
6441
+ );
6442
+ }
6443
+ return element.value;
6444
+ });
6445
+ return;
6446
+ }
6447
+ // accept first arg can be "anything" such as
6448
+ // `const cb = () => {}; import.meta.hot.accept(cb)`
6449
+ hotAcceptSelf = true;
6450
+ return;
6451
+ }
6452
+ if (methodName === "decline") {
6453
+ hotDecline = true;
6454
+ }
6455
+ },
6456
+ });
6457
+ return {
6458
+ importMetaHotNodes,
6459
+ hotDecline,
6460
+ hotAcceptSelf,
6461
+ hotAcceptSpecifiers,
6462
+ };
6463
+ };
6464
+
6465
+ // "import.meta.hot.<method>(...)"
6466
+ const getImportMetaHotMethodName = (callNode) => {
6467
+ const { callee } = callNode;
6468
+ if (callee.type !== "MemberExpression" || callee.computed) {
6469
+ return null;
6470
+ }
6471
+ if (getImportMetaPropertyName(callee.object) !== "hot") {
6472
+ return null;
6473
+ }
6474
+ return callee.property.name;
6475
+ };
6476
+
6477
+ const isStringLiteral = (node) => {
6478
+ return node.type === "Literal" && typeof node.value === "string";
6479
+ };
6480
+
6536
6481
  const jsenvPluginImportMetaHot = () => {
6537
6482
  const importMetaHotClientFileUrl = import.meta
6538
6483
  .resolve("../client/import_meta_hot/import_meta_hot.js");
@@ -6581,7 +6526,7 @@ const jsenvPluginImportMetaHot = () => {
6581
6526
  cssUrlInfo.data.hotAcceptSelf = false;
6582
6527
  cssUrlInfo.data.hotAcceptDependencies = [];
6583
6528
  },
6584
- js_module: async (urlInfo) => {
6529
+ js_module: (urlInfo) => {
6585
6530
  // Do not scan node modules for import.meta.hot
6586
6531
  // - unlikely to be there
6587
6532
  // - we don't watch node modules (too expensive)
@@ -6593,27 +6538,22 @@ const jsenvPluginImportMetaHot = () => {
6593
6538
  if (!urlInfo.content.includes("import.meta.hot")) {
6594
6539
  return null;
6595
6540
  }
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
6541
  const {
6604
- importMetaHotPaths,
6542
+ importMetaHotNodes,
6605
6543
  hotDecline,
6606
6544
  hotAcceptSelf,
6607
- hotAcceptDependencies,
6608
- } = metadata;
6545
+ hotAcceptSpecifiers,
6546
+ } = analyzeImportMetaHot(urlInfo.contentAst);
6609
6547
  urlInfo.data.hotDecline = hotDecline;
6610
6548
  urlInfo.data.hotAcceptSelf = hotAcceptSelf;
6611
- urlInfo.data.hotAcceptDependencies = hotAcceptDependencies;
6612
- if (importMetaHotPaths.length === 0) {
6549
+ urlInfo.data.hotAcceptDependencies = hotAcceptSpecifiers.map(
6550
+ (specifier) => resolveHotAcceptSpecifier(urlInfo, specifier),
6551
+ );
6552
+ if (importMetaHotNodes.length === 0) {
6613
6553
  return null;
6614
6554
  }
6615
6555
  if (urlInfo.context.build) {
6616
- return removeImportMetaHots(urlInfo, importMetaHotPaths);
6556
+ return removeImportMetaHots(urlInfo, importMetaHotNodes);
6617
6557
  }
6618
6558
  return injectImportMetaHot(urlInfo, importMetaHotClientFileUrl);
6619
6559
  },
@@ -6621,12 +6561,32 @@ const jsenvPluginImportMetaHot = () => {
6621
6561
  };
6622
6562
  };
6623
6563
 
6624
- const removeImportMetaHots = (urlInfo, importMetaHotPaths) => {
6564
+ // The specifier given to import.meta.hot.accept() is one the file imports,
6565
+ // so its url is known from that import (autoreload compares urls). A
6566
+ // specifier the file does not import accepts nothing: it is kept as a plain
6567
+ // url, never resolved as a dependency (that could throw on a bare specifier).
6568
+ const resolveHotAcceptSpecifier = (urlInfo, specifier) => {
6569
+ for (const referenceToOther of urlInfo.referenceToOthersSet) {
6570
+ if (
6571
+ referenceToOther.type === "js_import" &&
6572
+ referenceToOther.specifier === specifier
6573
+ ) {
6574
+ return referenceToOther.url;
6575
+ }
6576
+ }
6577
+ try {
6578
+ return new URL(specifier, urlInfo.url).href;
6579
+ } catch {
6580
+ return specifier;
6581
+ }
6582
+ };
6583
+
6584
+ const removeImportMetaHots = (urlInfo, importMetaHotNodes) => {
6625
6585
  const magicSource = createMagicSource(urlInfo.content);
6626
- importMetaHotPaths.forEach((path) => {
6586
+ importMetaHotNodes.forEach((node) => {
6627
6587
  magicSource.replace({
6628
- start: path.node.start,
6629
- end: path.node.end,
6588
+ start: node.start,
6589
+ end: node.end,
6630
6590
  replacement: "undefined",
6631
6591
  });
6632
6592
  });
@@ -7155,6 +7115,192 @@ const jsenvPluginAutoreload = ({
7155
7115
  ];
7156
7116
  };
7157
7117
 
7118
+ const urlSpecifierEncoding = {
7119
+ encode: (reference) => {
7120
+ const { generatedSpecifier } = reference;
7121
+ if (generatedSpecifier.then) {
7122
+ return generatedSpecifier.then((value) => {
7123
+ reference.generatedSpecifier = value;
7124
+ return urlSpecifierEncoding.encode(reference);
7125
+ });
7126
+ }
7127
+ // allow plugin to return a function to bypas default formatting
7128
+ // (which is to use JSON.stringify when url is referenced inside js)
7129
+ if (typeof generatedSpecifier === "function") {
7130
+ return generatedSpecifier();
7131
+ }
7132
+ const formatter = formatters[reference.type];
7133
+ const value = formatter
7134
+ ? formatter.encode(generatedSpecifier)
7135
+ : generatedSpecifier;
7136
+ if (reference.escape) {
7137
+ return reference.escape(value);
7138
+ }
7139
+ return value;
7140
+ },
7141
+ decode: (reference) => {
7142
+ const formatter = formatters[reference.type];
7143
+ return formatter
7144
+ ? formatter.decode(reference.generatedSpecifier)
7145
+ : reference.generatedSpecifier;
7146
+ },
7147
+ };
7148
+ const formatters = {
7149
+ "js_import": { encode: JSON.stringify, decode: JSON.parse },
7150
+ "js_url": { encode: JSON.stringify, decode: JSON.parse },
7151
+ "css_@import": { encode: JSON.stringify, decode: JSON.stringify },
7152
+ // https://github.com/webpack-contrib/css-loader/pull/627/files
7153
+ "css_url": {
7154
+ encode: (url) => {
7155
+ // If url is already wrapped in quotes, remove them
7156
+ url = formatters.css_url.decode(url);
7157
+ // Should url be wrapped?
7158
+ // See https://drafts.csswg.org/css-values-3/#urls
7159
+ if (/["'() \t\n]/.test(url)) {
7160
+ return `"${url.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
7161
+ }
7162
+ return url;
7163
+ },
7164
+ decode: (url) => {
7165
+ const firstChar = url[0];
7166
+ const lastChar = url[url.length - 1];
7167
+ if (firstChar === `"` && lastChar === `"`) {
7168
+ return url.slice(1, -1);
7169
+ }
7170
+ if (firstChar === `'` && lastChar === `'`) {
7171
+ return url.slice(1, -1);
7172
+ }
7173
+ return url;
7174
+ },
7175
+ },
7176
+ };
7177
+
7178
+ /*
7179
+ * Tells the browser the static import graph of a page's module scripts, as
7180
+ * `Link: <url>; rel=modulepreload` response headers, so it can fetch every
7181
+ * module as soon as the page's headers arrive, without waiting to discover
7182
+ * them level by level (each level costing a round trip and, the first time,
7183
+ * a cook).
7184
+ *
7185
+ * Only what the graph already knows is listed: a module never cooked has no
7186
+ * known imports. The header is computed at each response from the graph as it
7187
+ * is then: a page gets its full list from its second load on, the first load
7188
+ * keeps the plain waterfall.
7189
+ *
7190
+ * Headers rather than <link> elements written into the page: the page content
7191
+ * stays what it is (served from memory, revalidated by etag) and the list can
7192
+ * grow without cooking the page again. And not references either: a reference
7193
+ * from the html to every module would make the html a dependent of each of
7194
+ * them, and a hot update propagating up the importers would reach the html
7195
+ * (which declines) instead of stopping at the module accepting it.
7196
+ *
7197
+ * OFF BY DEFAULT, to be enabled once the dev server speaks http/2 or http/3.
7198
+ * Over http/1.1 it makes pages slower — measured on a 500 modules page: first
7199
+ * paint 32ms -> 868ms, load 626ms -> 985ms. Knowing the urls earlier gives the
7200
+ * browser no extra resource: what it has is 6 connections per origin, and
7201
+ * every request goes through them in the order it was learned.
7202
+ * - Without the header, requests come in execution order: the parser asks
7203
+ * for the render-blocking supervisor script first and gets a connection at
7204
+ * once; transfer and execution overlap, a module executing and discovering
7205
+ * its imports while the others download. On localhost the server answers
7206
+ * from memory in 0.1ms, so the 6 connections are busy from the start.
7207
+ * - With the header, hundreds of requests take the 6 connections before the
7208
+ * parser asks for the supervisor script, which then waits for a free
7209
+ * connection behind 1MB+ transfers (520ms measured, 2s with 20ms of RTT).
7210
+ * Meanwhile nothing renders and nothing executes: transfer, then execution,
7211
+ * serialized. http/1.1 cannot reprioritize a queued request, and a
7212
+ * modulepreload has the same priority as a script.
7213
+ * - And there is nothing to hide: the discovery latency a preload removes is
7214
+ * ~1ms per graph level on localhost; with a real RTT a wide graph (more
7215
+ * than 6 modules pending at any time) keeps the 6 connections saturated
7216
+ * anyway, and the load takes requests / 6 * RTT whatever the order.
7217
+ * It pays off for a deep and narrow graph (connections idle, waiting for a
7218
+ * response to learn the next level), and over http/2 or http/3: no
7219
+ * connection limit, and the browser prioritizes the blocking script.
7220
+ */
7221
+
7222
+
7223
+ const jsenvPluginModulepreload = () => {
7224
+ return {
7225
+ name: "jsenv:modulepreload",
7226
+ appliesDuring: "dev",
7227
+ augmentResponse: ({ urlInfo }) => {
7228
+ if (urlInfo.type !== "html") {
7229
+ return null;
7230
+ }
7231
+ const hrefs = collectStaticImportHrefs(urlInfo);
7232
+ if (hrefs.length === 0) {
7233
+ return null;
7234
+ }
7235
+ return {
7236
+ headers: {
7237
+ link: hrefs.map((href) => `<${href}>; rel=modulepreload`).join(", "),
7238
+ },
7239
+ };
7240
+ },
7241
+ };
7242
+ };
7243
+
7244
+ const collectStaticImportHrefs = (htmlUrlInfo) => {
7245
+ const hrefSet = new Set();
7246
+ const visitedSet = new Set();
7247
+ const addHref = (reference) => {
7248
+ const specifier = urlSpecifierEncoding.decode(reference);
7249
+ if (typeof specifier !== "string") {
7250
+ return;
7251
+ }
7252
+ // "?hot" belongs to the request that cooked the importer, the page loading
7253
+ // now imports the url without it
7254
+ hrefSet.add(injectQueryParamsIntoSpecifier(specifier, { hot: undefined }));
7255
+ };
7256
+ const visitJsModule = (jsModuleUrlInfo) => {
7257
+ if (visitedSet.has(jsModuleUrlInfo)) {
7258
+ return;
7259
+ }
7260
+ visitedSet.add(jsModuleUrlInfo);
7261
+ for (const reference of jsModuleUrlInfo.referenceToOthersSet) {
7262
+ if (
7263
+ reference.type !== "js_import" ||
7264
+ reference.isWeak ||
7265
+ reference.isImplicit ||
7266
+ !STATIC_IMPORT_SUBTYPES.includes(reference.subtype) ||
7267
+ !reference.url.startsWith("file:")
7268
+ ) {
7269
+ continue;
7270
+ }
7271
+ const importedUrlInfo = reference.urlInfo;
7272
+ // never cooked: its content, hence its own imports, are unknown
7273
+ if (importedUrlInfo.type !== "js_module") {
7274
+ continue;
7275
+ }
7276
+ addHref(reference);
7277
+ visitJsModule(importedUrlInfo);
7278
+ }
7279
+ };
7280
+ for (const reference of htmlUrlInfo.referenceToOthersSet) {
7281
+ if (
7282
+ reference.type !== "script" ||
7283
+ reference.expectedType !== "js_module" ||
7284
+ // the scripts jsenv adds to every page are few, small and in memory
7285
+ reference.injected ||
7286
+ reference.isWeak
7287
+ ) {
7288
+ continue;
7289
+ }
7290
+ const scriptUrlInfo = reference.urlInfo;
7291
+ if (scriptUrlInfo.type !== "js_module") {
7292
+ continue;
7293
+ }
7294
+ if (!reference.isInline && reference.url.startsWith("file:")) {
7295
+ addHref(reference);
7296
+ }
7297
+ visitJsModule(scriptUrlInfo);
7298
+ }
7299
+ return Array.from(hrefSet);
7300
+ };
7301
+
7302
+ const STATIC_IMPORT_SUBTYPES = ["import_static", "export_named", "export_all"];
7303
+
7158
7304
  /*
7159
7305
  * Tells the browser about the dependencies declared in package.json that
7160
7306
  * node_modules does not match, so the page can say it is running with something
@@ -7796,6 +7942,7 @@ const getCorePlugins = ({
7796
7942
 
7797
7943
  clientAutoreload,
7798
7944
  clientAutoreloadOnServerRestart,
7945
+ modulepreload = false,
7799
7946
  dependencyStatus,
7800
7947
  cacheControl,
7801
7948
  scenarioPlaceholders = true,
@@ -7897,6 +8044,7 @@ const getCorePlugins = ({
7897
8044
  ...(clientAutoreload && clientAutoreload.enabled
7898
8045
  ? [jsenvPluginAutoreload(clientAutoreload)]
7899
8046
  : []),
8047
+ ...(modulepreload ? [jsenvPluginModulepreload()] : []),
7900
8048
  ...(dependencyStatus
7901
8049
  ? [jsenvPluginDependencyStatus(dependencyStatus)]
7902
8050
  : []),
@@ -8952,20 +9100,37 @@ const checkForDependencyRemovalEffects = (reference) => {
8952
9100
  };
8953
9101
 
8954
9102
  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,
9103
+ const { url, line, column, content } = urlSite;
9104
+ const trace = { url, line, column };
9105
+ // A file references many urls and an error on one of them is rare: the code
9106
+ // frame (and the message embedding it) is built the first time it is read,
9107
+ // not for every reference found.
9108
+ defineLazyProperty(trace, "codeFrame", () =>
9109
+ content ? generateContentFrame({ content, line, column }) : "",
9110
+ );
9111
+ defineLazyProperty(trace, "message", () => stringifyUrlSite(urlSite));
9112
+ return trace;
9113
+ };
9114
+
9115
+ const defineLazyProperty = (object, property, compute) => {
9116
+ const setValue = (value) => {
9117
+ Object.defineProperty(object, property, {
9118
+ enumerable: true,
9119
+ configurable: true,
9120
+ writable: true,
9121
+ value,
9122
+ });
8968
9123
  };
9124
+ Object.defineProperty(object, property, {
9125
+ enumerable: true,
9126
+ configurable: true,
9127
+ get: () => {
9128
+ const value = compute();
9129
+ setValue(value);
9130
+ return value;
9131
+ },
9132
+ set: setValue,
9133
+ });
8969
9134
  };
8970
9135
 
8971
9136
  const adjustUrlSite = (urlInfo, { url, line, column }) => {
@@ -9227,66 +9392,6 @@ GRAPH_VISITOR.forEachUrlInfoStronglyReferenced = (
9227
9392
  seen.clear();
9228
9393
  };
9229
9394
 
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
9395
  const createUrlGraph = ({
9291
9396
  rootDirectoryUrl,
9292
9397
  kitchen,
@@ -9464,7 +9569,6 @@ const createUrlInfo = (url, context) => {
9464
9569
  dereferencedTimestamp: 0,
9465
9570
  originalContentEtag: null,
9466
9571
  contentEtag: null,
9467
- isWatched: false,
9468
9572
  isValid: () => false,
9469
9573
  data: {}, // plugins can put whatever they want here
9470
9574
  referenceToOthersSet: new Set(),
@@ -11282,11 +11386,10 @@ const devServerPluginServeSourceFiles = ({
11282
11386
  if (existing) {
11283
11387
  return existing;
11284
11388
  }
11285
- const watchAssociations = URL_META.resolveAssociations(
11286
- { watch: stopWatchingSourceFiles.watchPatterns },
11287
- sourceDirectoryUrl,
11288
- );
11289
11389
  let kitchen;
11390
+ // per url info, the file stat under which its content was last compared
11391
+ // with the file on disk and found identical (see isValid)
11392
+ const fileStatValidatedMap = new WeakMap();
11290
11393
  clientFileChangeEventEmitter.on(({ url, event }) => {
11291
11394
  const urlInfo = kitchen.graph.getUrlInfo(url);
11292
11395
  if (urlInfo) {
@@ -11327,11 +11430,6 @@ const devServerPluginServeSourceFiles = ({
11327
11430
  packageDirectory,
11328
11431
  });
11329
11432
  kitchen.graph.urlInfoCreatedEventEmitter.on((urlInfoCreated) => {
11330
- const { watch } = URL_META.applyAssociations({
11331
- url: urlInfoCreated.url,
11332
- associations: watchAssociations,
11333
- });
11334
- urlInfoCreated.isWatched = watch;
11335
11433
  // when an url depends on many others, we check all these (like package.json)
11336
11434
  urlInfoCreated.isValid = () => {
11337
11435
  const seenSet = new Set();
@@ -11351,37 +11449,64 @@ const devServerPluginServeSourceFiles = ({
11351
11449
  // was compared using etag and it has changed
11352
11450
  return false;
11353
11451
  }
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;
11452
+ // The content held in memory is the file as it was read; a request
11453
+ // must never win a stale answer, whatever the watcher's latency,
11454
+ // and the ?v= a package.json decides even ends up in the browser's
11455
+ // immutable cache. So the file is checked on disk at every
11456
+ // validation — cheaply: a stat, compared with the one taken when
11457
+ // the content was read (or last found identical). Only a file
11458
+ // whose stat moved is read and hashed again.
11459
+ // Inline content (a <script> inside an html) has no file of its
11460
+ // own: it is as fresh as the html holding it, checked by the caller.
11461
+ if (!urlInfo.isInline) {
11462
+ let fileStat;
11363
11463
  try {
11364
- fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
11365
- } catch (e) {
11366
- if (e.code === "ENOENT") {
11367
- urlInfo.onModified();
11368
- return false;
11369
- }
11464
+ fileStat = statSync(new URL(urlInfo.url), {
11465
+ throwIfNoEntry: false,
11466
+ });
11467
+ } catch {
11370
11468
  return false;
11371
11469
  }
11372
- const fileContentEtag = bufferToEtag(fileContentAsBuffer);
11373
- if (fileContentEtag !== urlInfo.originalContentEtag) {
11470
+ if (!fileStat) {
11374
11471
  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
11472
  return false;
11384
11473
  }
11474
+ const fileStatKnown =
11475
+ fileStatValidatedMap.get(urlInfo) || urlInfo.data.fileStat;
11476
+ const fileUnchanged =
11477
+ fileStatKnown &&
11478
+ fileStatKnown.mtimeMs === fileStat.mtimeMs &&
11479
+ fileStatKnown.size === fileStat.size;
11480
+ if (!fileUnchanged) {
11481
+ let fileContentAsBuffer;
11482
+ try {
11483
+ fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
11484
+ } catch (e) {
11485
+ if (e.code === "ENOENT") {
11486
+ urlInfo.onModified();
11487
+ return false;
11488
+ }
11489
+ return false;
11490
+ }
11491
+ const fileContentEtag = bufferToEtag(fileContentAsBuffer);
11492
+ if (fileContentEtag !== urlInfo.originalContentEtag) {
11493
+ fileStatValidatedMap.delete(urlInfo);
11494
+ urlInfo.onModified();
11495
+ // restore content to be able to compare it again later
11496
+ urlInfo.kitchen.urlInfoTransformer.setContent(
11497
+ urlInfo,
11498
+ String(fileContentAsBuffer),
11499
+ {
11500
+ contentEtag: fileContentEtag,
11501
+ },
11502
+ );
11503
+ return false;
11504
+ }
11505
+ fileStatValidatedMap.set(urlInfo, {
11506
+ mtimeMs: fileStat.mtimeMs,
11507
+ size: fileStat.size,
11508
+ });
11509
+ }
11385
11510
  }
11386
11511
  for (const implicitUrl of urlInfo.implicitUrlSet) {
11387
11512
  const implicitUrlInfo = urlInfo.graph.getUrlInfo(implicitUrl);
@@ -11497,6 +11622,40 @@ const devServerPluginServeSourceFiles = ({
11497
11622
  const ifNoneMatch = request.headers["if-none-match"];
11498
11623
  const inlineParentUrlInfo = urlInfo.findParentIfInline();
11499
11624
  const urlInfoTargetedByCache = inlineParentUrlInfo || urlInfo;
11625
+ // The content held in memory is the response when it is finalized
11626
+ // and still valid. Content can be defined while a cook is still in
11627
+ // flight (a file watcher invalidation re-cooking in the background,
11628
+ // for instance): at that point it holds the raw fetched content,
11629
+ // transformations not applied yet. Serving that would send an html
11630
+ // without any of the injected scripts. An inline url info (a
11631
+ // <script> inside an html) is cooked again whenever the html
11632
+ // containing it is cooked: its content is as fresh as the html's,
11633
+ // so both must be valid.
11634
+ const hasFreshContent = (urlInfo) =>
11635
+ urlInfo.content !== undefined &&
11636
+ urlInfo.contentFinalized &&
11637
+ urlInfo.isValid();
11638
+ const memoryContentIsFresh = () =>
11639
+ inlineParentUrlInfo
11640
+ ? hasFreshContent(urlInfo) && hasFreshContent(inlineParentUrlInfo)
11641
+ : hasFreshContent(urlInfo);
11642
+ // a 304 goes through the hooks too: its headers stand for the
11643
+ // cached response's, they must say the same
11644
+ const augmentResponse = (response) => {
11645
+ const augmentResponseInfo = {
11646
+ ...kitchen.context,
11647
+ reference,
11648
+ urlInfo,
11649
+ };
11650
+ kitchen.jsenvPluginsController.callHooks(
11651
+ "augmentResponse",
11652
+ augmentResponseInfo,
11653
+ (returnValue) => {
11654
+ response = composeTwoResponses(response, returnValue);
11655
+ },
11656
+ );
11657
+ return response;
11658
+ };
11500
11659
  const respondWithNotModified = () => {
11501
11660
  const headers = {
11502
11661
  "cache-control": `private,max-age=0,must-revalidate`,
@@ -11506,23 +11665,21 @@ const devServerPluginServeSourceFiles = ({
11506
11665
  headers[key] = urlInfo.headers[key];
11507
11666
  }
11508
11667
  });
11509
- return {
11668
+ return augmentResponse({
11510
11669
  status: 304,
11511
11670
  headers,
11512
- };
11671
+ });
11513
11672
  };
11514
11673
 
11515
11674
  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) {
11675
+ if (!urlInfo.error && ifNoneMatch) {
11520
11676
  const [clientOriginalContentEtag, clientContentEtag] =
11521
11677
  ifNoneMatch.split("_");
11522
11678
  if (
11523
- urlInfo.originalContentEtag === clientOriginalContentEtag &&
11679
+ urlInfoTargetedByCache.originalContentEtag ===
11680
+ clientOriginalContentEtag &&
11524
11681
  urlInfo.contentEtag === clientContentEtag &&
11525
- urlInfo.isValid()
11682
+ memoryContentIsFresh()
11526
11683
  ) {
11527
11684
  return respondWithNotModified();
11528
11685
  }
@@ -11540,18 +11697,9 @@ const devServerPluginServeSourceFiles = ({
11540
11697
  // client etag to match).
11541
11698
  const servableFromMemory =
11542
11699
  !urlInfo.error &&
11543
- !inlineParentUrlInfo &&
11544
11700
  !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
11701
  !cacheIsDisabledInResponseHeader(urlInfo) &&
11702
+ !cacheIsDisabledInResponseHeader(urlInfoTargetedByCache) &&
11555
11703
  // a "?hot" request exists to bypass every cache, this one
11556
11704
  // included: it must be cooked, because cooking is what rewrites
11557
11705
  // its references so "?hot" cascades to the modified files below
@@ -11570,7 +11718,7 @@ const devServerPluginServeSourceFiles = ({
11570
11718
  // signal registry throwing on duplicate ids). Re-cooking under
11571
11719
  // the normal request rewrites the references clean.
11572
11720
  !urlInfo.contentCookedForHotRequest &&
11573
- urlInfo.isValid();
11721
+ memoryContentIsFresh();
11574
11722
  if (!servableFromMemory) {
11575
11723
  await urlInfo.cook({ request, reference });
11576
11724
  urlInfo.contentCookedForHotRequest =
@@ -11628,19 +11776,7 @@ const devServerPluginServeSourceFiles = ({
11628
11776
  ? { "served from memory cache": null }
11629
11777
  : urlInfo.timing,
11630
11778
  };
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;
11779
+ return augmentResponse(response);
11644
11780
  } catch (error) {
11645
11781
  const originalError = error ? error.cause || error : error;
11646
11782
  if (originalError.asResponse) {
@@ -11718,6 +11854,14 @@ const devServerPluginServeSourceFiles = ({
11718
11854
  "cache-control": "no-store",
11719
11855
  },
11720
11856
  };
11857
+ } finally {
11858
+ // What the request put on the url info context is for this
11859
+ // request only: a cook happening later for another reason (the
11860
+ // html holding an inline script is cooked again) must not see a
11861
+ // stale "requestedUrl" and take the inline script for a direct
11862
+ // request, which would make it pick the content of a previous
11863
+ // reference (see jsenv:inline_content_fetcher).
11864
+ forgetRequestFromContext(urlInfo.context);
11721
11865
  }
11722
11866
  },
11723
11867
  },
@@ -11727,6 +11871,15 @@ const devServerPluginServeSourceFiles = ({
11727
11871
  return [devServerPluginRoutes, ...devServerJsenvPluginStore.allServerPlugins];
11728
11872
  };
11729
11873
 
11874
+ const forgetRequestFromContext = (context) => {
11875
+ // own properties only: what is inherited from the owner context stays
11876
+ for (const key of ["request", "requestedUrl", "reference"]) {
11877
+ if (Object.hasOwn(context, key)) {
11878
+ delete context[key];
11879
+ }
11880
+ }
11881
+ };
11882
+
11730
11883
  const cacheIsDisabledInResponseHeader = (urlInfo) => {
11731
11884
  return (
11732
11885
  urlInfo.headers["cache-control"] === "no-store" ||
@@ -11758,6 +11911,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11758
11911
  * @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
11912
  * @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
11913
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11914
+ * @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
11915
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
11762
11916
  * @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
11917
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
@@ -11799,6 +11953,7 @@ const startDevServer = async ({
11799
11953
  sourceFilesConfig = {},
11800
11954
  clientAutoreload = true,
11801
11955
  clientAutoreloadOnServerRestart = true,
11956
+ modulepreload = false,
11802
11957
  // server-timing response headers: devtools show how the time to answer is
11803
11958
  // spent (cook measures come from the kitchen, see urlInfo.timing). Entries
11804
11959
  // under minDuration are dropped so a human reads the measures that matter;
@@ -11888,9 +12043,12 @@ const startDevServer = async ({
11888
12043
  }
11889
12044
 
11890
12045
  const logger = createLogger({ logLevel });
11891
- const startDevServerTask = createTaskLog("start dev server", {
11892
- disabled: !logger.levels.info,
11893
- });
12046
+ const startDevServerTask = createTaskLog(
12047
+ `start dev server for ${humanizeSourceDirectory(sourceDirectoryUrl)}`,
12048
+ {
12049
+ disabled: !logger.levels.info,
12050
+ },
12051
+ );
11894
12052
 
11895
12053
  const serverStopCallbackSet = new Set();
11896
12054
  const serverStopAbortController = new AbortController();
@@ -11969,6 +12127,7 @@ const startDevServer = async ({
11969
12127
 
11970
12128
  clientAutoreload,
11971
12129
  clientAutoreloadOnServerRestart,
12130
+ modulepreload,
11972
12131
  dependencyStatus: {
11973
12132
  dependencyProblemEventEmitter,
11974
12133
  getDependencyProblems: dependencyWatcher.getProblems,
@@ -12074,4 +12233,13 @@ const startDevServer = async ({
12074
12233
  };
12075
12234
  };
12076
12235
 
12236
+ // "./src/" when inside the current working directory, the full path otherwise
12237
+ const humanizeSourceDirectory = (sourceDirectoryUrl) => {
12238
+ const cwdUrl = pathToFileURL(`${process.cwd()}/`).href;
12239
+ if (urlIsOrIsInsideOf(sourceDirectoryUrl, cwdUrl)) {
12240
+ return `./${urlToRelativeUrl(sourceDirectoryUrl, cwdUrl)}`;
12241
+ }
12242
+ return urlToFileSystemPath(sourceDirectoryUrl);
12243
+ };
12244
+
12077
12245
  export { startDevServer };