@jsenv/core 41.4.16 → 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,10 +1,10 @@
1
- import { parseHtml, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, stringifyHtmlAst, applyBabelPlugins, generateUrlForInlineContent, injectJsenvScript, parseJsWithAcorn, visitHtmlNodes, analyzeScriptNode, getHtmlNodeText, getHtmlNodeAttribute, getHtmlNodePosition, getUrlForContentInsideHtml, setHtmlNodeAttributes, setHtmlNodeText, parseCssUrls, getHtmlNodeAttributePosition, parseSrcSet, removeHtmlNodeText, parseJsUrls, getUrlForContentInsideJs, analyzeLinkNode, findHtmlNode, removeHtmlNode, insertHtmlNodeAfter } from "@jsenv/ast";
1
+ import { parseHtml, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, stringifyHtmlAst, applyBabelPlugins, generateUrlForInlineContent, injectJsenvScript, parseJsWithAcorn, visitHtmlNodes, analyzeScriptNode, getHtmlNodeText, getHtmlNodeAttribute, getHtmlNodePosition, getUrlForContentInsideHtml, setHtmlNodeAttributes, setHtmlNodeText, parseCssUrls, getHtmlNodeAttributePosition, parseSrcSet, removeHtmlNodeText, parseJsUrls, getUrlForContentInsideJs, visitJsAst, getImportMetaPropertyName, visitJsAstUntil, analyzeLinkNode, findHtmlNode, removeHtmlNode, insertHtmlNodeAfter } from "@jsenv/ast";
2
2
  import { bundleJsModules, jsenvPluginBundling } from "@jsenv/plugin-bundling";
3
3
  import { jsenvPluginMinification } from "@jsenv/plugin-minification";
4
4
  import { jsenvPluginTranspilation, jsenvPluginJsModuleFallback } from "@jsenv/plugin-transpilation";
5
5
  import { memoryUsage } from "node:process";
6
- import { readFileSync, existsSync, readdirSync, lstatSync, realpathSync } from "node:fs";
7
- import { lookupPackageDirectory, registerDirectoryLifecycle, urlToRelativeUrl, createDetailedMessage, stringifyUrlSite, generateContentFrame, validateResponseIntegrity, urlIsOrIsInsideOf, ensureWindowsDriveLetter, setUrlFilename, moveUrl, getCallerPosition, urlToBasename, urlToExtension, asSpecifierWithoutSearch, asUrlWithoutSearch, injectQueryParamsIntoSpecifier, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, urlToFileSystemPath, writeFileSync, createLogger, URL_META, applyNodeEsmResolution, normalizeUrl, ANSI, RUNTIME_COMPAT, CONTENT_TYPE, readPackageAtOrNull, urlToFilename, DATA_URL, errorToHTML, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, readCustomConditionsFromProcessArgs, collectFiles, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, isSpecifierForNodeBuiltin, injectQueryParams, renderDetails, humanizeDuration, humanizeFileSize, renderTable, renderBigSection, distributePercentages, humanizeMemory, comparePathnames, UNICODE, escapeRegexpSpecialChars, injectQueryParamIntoSpecifierWithoutEncoding, renderUrlOrRelativeUrlFilename, assertAndNormalizeDirectoryUrl, Abort, raceProcessTeardownEvents, startMonitoringCpuUsage, startMonitoringMemoryUsage, inferRuntimeCompatFromClosestPackage, browserDefaultRuntimeCompat, nodeDefaultRuntimeCompat, clearDirectorySync, createTaskLog, createLookupPackageDirectory, ensureEmptyDirectory, updateJsonFileSync, createDynamicLog } from "./jsenv_core_packages.js";
6
+ import { readFileSync, existsSync, realpathSync, readdirSync, lstatSync, statSync } from "node:fs";
7
+ import { lookupPackageDirectory, urlIsOrIsInsideOf, registerDirectoryLifecycle, urlToRelativeUrl, createDetailedMessage, stringifyUrlSite, generateContentFrame, validateResponseIntegrity, ensureWindowsDriveLetter, setUrlFilename, moveUrl, getCallerPosition, urlToBasename, urlToExtension, asSpecifierWithoutSearch, asUrlWithoutSearch, injectQueryParamsIntoSpecifier, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, urlToFileSystemPath, writeFileSync, createLogger, URL_META, applyNodeEsmResolution, normalizeUrl, ANSI, RUNTIME_COMPAT, CONTENT_TYPE, readPackageAtOrNull, urlToFilename, DATA_URL, errorToHTML, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, readCustomConditionsFromProcessArgs, collectFiles, readEntryStatSync, applyFileSystemMagicResolution, getExtensionsToTry, ensurePathnameTrailingSlash, compareFileUrls, setUrlExtension, isSpecifierForNodeBuiltin, injectQueryParams, renderDetails, humanizeDuration, humanizeFileSize, renderTable, renderBigSection, distributePercentages, humanizeMemory, comparePathnames, UNICODE, escapeRegexpSpecialChars, injectQueryParamIntoSpecifierWithoutEncoding, renderUrlOrRelativeUrlFilename, assertAndNormalizeDirectoryUrl, Abort, raceProcessTeardownEvents, startMonitoringCpuUsage, startMonitoringMemoryUsage, inferRuntimeCompatFromClosestPackage, browserDefaultRuntimeCompat, nodeDefaultRuntimeCompat, clearDirectorySync, createTaskLog, createLookupPackageDirectory, ensureEmptyDirectory, updateJsonFileSync, createDynamicLog } from "./jsenv_core_packages.js";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { generateSourcemapFileUrl, createMagicSource, composeTwoSourcemaps, generateSourcemapDataUrl, SOURCEMAP } from "@jsenv/sourcemap";
10
10
  import { createPluginsController } from "@jsenv/server/src/plugins_controller.js";
@@ -20,18 +20,21 @@ import "node:util";
20
20
  const getDirectoryWatchPatterns = (
21
21
  directoryUrl,
22
22
  watchedDirectoryUrl,
23
- { sourceFilesConfig },
23
+ { sourceFilesConfig, defaultPatterns = true },
24
24
  ) => {
25
25
  const directoryUrlRelativeToWatchedDirectory = urlToRelativeUrl(
26
26
  directoryUrl,
27
27
  watchedDirectoryUrl,
28
28
  );
29
- const watchPatterns = {
30
- [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
31
- [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
32
- [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
33
- [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
34
- };
29
+ const watchPatterns = {};
30
+ if (defaultPatterns) {
31
+ Object.assign(watchPatterns, {
32
+ [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
33
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
34
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
35
+ [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
36
+ });
37
+ }
35
38
  for (const key of Object.keys(sourceFilesConfig)) {
36
39
  watchPatterns[`${directoryUrlRelativeToWatchedDirectory}${key}`] =
37
40
  sourceFilesConfig[key];
@@ -52,11 +55,12 @@ const watchSourceFiles = (
52
55
  // And jsenv should not consider these as source files and watch them (to not hurt performances)
53
56
  const watchPatterns = {};
54
57
  let watchedDirectoryUrl = "";
55
- const addDirectoryToWatch = (directoryUrl) => {
58
+ const addDirectoryToWatch = (directoryUrl, { defaultPatterns } = {}) => {
56
59
  Object.assign(
57
60
  watchPatterns,
58
61
  getDirectoryWatchPatterns(directoryUrl, watchedDirectoryUrl, {
59
62
  sourceFilesConfig,
63
+ defaultPatterns,
60
64
  }),
61
65
  );
62
66
  };
@@ -88,7 +92,6 @@ const watchSourceFiles = (
88
92
  },
89
93
  },
90
94
  );
91
- stopWatchingSourceFiles.watchPatterns = watchPatterns;
92
95
  return stopWatchingSourceFiles;
93
96
  };
94
97
 
@@ -108,16 +111,18 @@ const watchSourceFiles = (
108
111
  }
109
112
  watchedDirectoryUrl = packageDirectoryUrl;
110
113
  for (const workspace of workspaces) {
111
- if (workspace.endsWith("*")) {
112
- const workspaceDirectoryUrl = new URL(
113
- workspace.slice(0, -1),
114
- packageDirectoryUrl,
115
- );
116
- addDirectoryToWatch(workspaceDirectoryUrl);
117
- } else {
118
- const workspaceRelativeUrl = new URL(workspace, packageDirectoryUrl);
119
- addDirectoryToWatch(workspaceRelativeUrl);
120
- }
114
+ const workspaceDirectoryUrl = workspace.endsWith("*")
115
+ ? new URL(workspace.slice(0, -1), packageDirectoryUrl)
116
+ : new URL(workspace, packageDirectoryUrl);
117
+ addDirectoryToWatch(workspaceDirectoryUrl, {
118
+ // The source directory patterns already cover a workspace inside it.
119
+ // Every pattern is tested against every file found while watching, so
120
+ // the same patterns rooted at each workspace only multiply that work.
121
+ defaultPatterns: !urlIsOrIsInsideOf(
122
+ workspaceDirectoryUrl,
123
+ sourceDirectoryUrl,
124
+ ),
125
+ });
121
126
  }
122
127
  // we are updating the root directory
123
128
  // we must make the patterns relative to source directory relative to the new root directory
@@ -1578,20 +1583,37 @@ const checkForDependencyRemovalEffects = (reference) => {
1578
1583
  };
1579
1584
 
1580
1585
  const traceFromUrlSite = (urlSite) => {
1581
- const codeFrame = urlSite.content
1582
- ? generateContentFrame({
1583
- content: urlSite.content,
1584
- line: urlSite.line,
1585
- column: urlSite.column,
1586
- })
1587
- : "";
1588
- return {
1589
- codeFrame,
1590
- message: stringifyUrlSite(urlSite),
1591
- url: urlSite.url,
1592
- line: urlSite.line,
1593
- column: urlSite.column,
1586
+ const { url, line, column, content } = urlSite;
1587
+ const trace = { url, line, column };
1588
+ // A file references many urls and an error on one of them is rare: the code
1589
+ // frame (and the message embedding it) is built the first time it is read,
1590
+ // not for every reference found.
1591
+ defineLazyProperty(trace, "codeFrame", () =>
1592
+ content ? generateContentFrame({ content, line, column }) : "",
1593
+ );
1594
+ defineLazyProperty(trace, "message", () => stringifyUrlSite(urlSite));
1595
+ return trace;
1596
+ };
1597
+
1598
+ const defineLazyProperty = (object, property, compute) => {
1599
+ const setValue = (value) => {
1600
+ Object.defineProperty(object, property, {
1601
+ enumerable: true,
1602
+ configurable: true,
1603
+ writable: true,
1604
+ value,
1605
+ });
1594
1606
  };
1607
+ Object.defineProperty(object, property, {
1608
+ enumerable: true,
1609
+ configurable: true,
1610
+ get: () => {
1611
+ const value = compute();
1612
+ setValue(value);
1613
+ return value;
1614
+ },
1615
+ set: setValue,
1616
+ });
1595
1617
  };
1596
1618
 
1597
1619
  const adjustUrlSite = (urlInfo, { url, line, column }) => {
@@ -2090,7 +2112,6 @@ const createUrlInfo = (url, context) => {
2090
2112
  dereferencedTimestamp: 0,
2091
2113
  originalContentEtag: null,
2092
2114
  contentEtag: null,
2093
- isWatched: false,
2094
2115
  isValid: () => false,
2095
2116
  data: {}, // plugins can put whatever they want here
2096
2117
  referenceToOthersSet: new Set(),
@@ -6413,11 +6434,6 @@ const addRelationshipWithPackageJson = ({
6413
6434
  String(packageJsonContentAsBuffer),
6414
6435
  );
6415
6436
  }
6416
- // Checked on disk at every validation rather than trusted to the watcher:
6417
- // what this file decides (the package version, hence the ?v= importers
6418
- // embed) ends up in the browser's immutable cache, so a request racing the
6419
- // watcher must never be answered from a stale package.json.
6420
- packageJsonReference.urlInfo.revalidateOnFileSystem = true;
6421
6437
  };
6422
6438
 
6423
6439
  const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
@@ -6759,6 +6775,240 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
6759
6775
  };
6760
6776
  };
6761
6777
 
6778
+ const jsenvPluginFsRedirection = ({
6779
+ spa,
6780
+ directoryContentMagicName,
6781
+ magicExtensions = ["inherit", ".js"],
6782
+ magicDirectoryIndex = true,
6783
+ preserveSymlinks = false,
6784
+ }) => {
6785
+ return {
6786
+ name: "jsenv:fs_redirection",
6787
+ appliesDuring: "*",
6788
+ redirectReference: (reference) => {
6789
+ if (reference.url === "file:///") {
6790
+ return `ignore:file:///`;
6791
+ }
6792
+ if (reference.url === "file://") {
6793
+ return `ignore:file://`;
6794
+ }
6795
+ // ignore all new URL second arg
6796
+ if (reference.subtype === "new_url_second_arg") {
6797
+ if (reference.original) {
6798
+ return `ignore:${reference.original.specifier}`;
6799
+ }
6800
+ return `ignore:${reference.specifier}`;
6801
+ }
6802
+ // http, https, data, about, ...
6803
+ if (!reference.url.startsWith("file:")) {
6804
+ return null;
6805
+ }
6806
+ if (reference.original && !reference.original.url.startsWith("file:")) {
6807
+ return null;
6808
+ }
6809
+ if (reference.isInline) {
6810
+ return null;
6811
+ }
6812
+
6813
+ if (
6814
+ reference.specifierPathname.endsWith(`/${directoryContentMagicName}`)
6815
+ ) {
6816
+ const { rootDirectoryUrl } = reference.ownerUrlInfo.context;
6817
+ const directoryUrl = new URL(
6818
+ reference.specifierPathname
6819
+ .replace(`/${directoryContentMagicName}`, "/")
6820
+ .slice(1),
6821
+ rootDirectoryUrl,
6822
+ ).href;
6823
+ return directoryUrl;
6824
+ }
6825
+ // ignore "./" on new URL("./")
6826
+ // if (
6827
+ // reference.subtype === "new_url_first_arg" &&
6828
+ // reference.specifier === "./"
6829
+ // ) {
6830
+ // return `ignore:${reference.url}`;
6831
+ // }
6832
+ const urlObject = new URL(reference.url);
6833
+ let fsStat = readEntryStatSync(urlObject, { nullIfNotFound: true });
6834
+ reference.fsStat = fsStat;
6835
+ const { search, hash } = urlObject;
6836
+ urlObject.search = "";
6837
+ urlObject.hash = "";
6838
+ // must be read before applyFsStatEffectsOnUrlObject which forces the
6839
+ // trailing slash on directories
6840
+ const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
6841
+ applyFsStatEffectsOnUrlObject(urlObject, fsStat);
6842
+ const shouldApplyFilesystemMagicResolution =
6843
+ reference.type === "js_import";
6844
+ if (shouldApplyFilesystemMagicResolution) {
6845
+ const filesystemResolution = applyFileSystemMagicResolution(
6846
+ urlObject.href,
6847
+ {
6848
+ fileStat: fsStat,
6849
+ magicDirectoryIndex,
6850
+ magicExtensions: getExtensionsToTry(
6851
+ magicExtensions,
6852
+ reference.ownerUrlInfo.url,
6853
+ ),
6854
+ },
6855
+ );
6856
+ if (filesystemResolution.stat) {
6857
+ fsStat = filesystemResolution.stat;
6858
+ reference.fsStat = fsStat;
6859
+ urlObject.href = filesystemResolution.url;
6860
+ applyFsStatEffectsOnUrlObject(urlObject, fsStat);
6861
+ }
6862
+ }
6863
+ if (spa) {
6864
+ // for SPA we want to serve the root HTML file most of the time
6865
+ if (!fsStat) {
6866
+ if (urlToExtension(urlObject)) {
6867
+ // url has an extension, we assume it's a file request -> let 404 happen
6868
+ return null;
6869
+ }
6870
+ if (specifierUsesTrailingSlash) {
6871
+ // the trailing slash asks for a directory and there is none here
6872
+ // -> let 404 happen (same reasoning as the extension above)
6873
+ return null;
6874
+ }
6875
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
6876
+ if (spaFallbackUrl) {
6877
+ return spaFallbackUrl;
6878
+ }
6879
+ return null;
6880
+ }
6881
+ if (fsStat.isDirectory()) {
6882
+ // When requesting a directory, check if we have an HTML entry file for that directory
6883
+ const directoryEntryFileUrl = getDirectoryEntryFileUrl(urlObject);
6884
+ if (directoryEntryFileUrl) {
6885
+ reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
6886
+ return directoryEntryFileUrl;
6887
+ }
6888
+ if (!specifierUsesTrailingSlash) {
6889
+ // the trailing slash is what tells a directory apart from a route:
6890
+ // "/join/" is the directory, "/join" is a route owned by the SPA
6891
+ // even when "join/" exists in the source files.
6892
+ // Without this a source directory would shadow the route having
6893
+ // the same name and the SPA would be unreachable in dev while
6894
+ // being perfectly fine once built
6895
+ const spaFallbackUrl = getSpaFallbackUrl(reference);
6896
+ if (spaFallbackUrl) {
6897
+ reference.fsStat = readEntryStatSync(spaFallbackUrl, {
6898
+ nullIfNotFound: true,
6899
+ });
6900
+ return spaFallbackUrl;
6901
+ }
6902
+ }
6903
+ }
6904
+ }
6905
+ if (!fsStat) {
6906
+ return null;
6907
+ }
6908
+ const urlBeforeSymlinkResolution = urlObject.href;
6909
+ if (preserveSymlinks) {
6910
+ return `${urlBeforeSymlinkResolution}${search}${hash}`;
6911
+ }
6912
+ const urlAfterSymlinkResolution = resolveSymlink(
6913
+ urlBeforeSymlinkResolution,
6914
+ );
6915
+ if (urlAfterSymlinkResolution !== urlBeforeSymlinkResolution) {
6916
+ reference.leadsToASymlink = true;
6917
+ // reference.baseUrl = urlBeforeSymlinkResolution;
6918
+ }
6919
+ const resolvedUrl = `${urlAfterSymlinkResolution}${search}${hash}`;
6920
+ return resolvedUrl;
6921
+ },
6922
+ };
6923
+ };
6924
+
6925
+ const applyFsStatEffectsOnUrlObject = (urlObject, fsStat) => {
6926
+ if (!fsStat) {
6927
+ return;
6928
+ }
6929
+ const { pathname } = urlObject;
6930
+ const pathnameUsesTrailingSlash = pathname.endsWith("/");
6931
+ // force trailing slash on directories
6932
+ if (fsStat.isDirectory()) {
6933
+ if (!pathnameUsesTrailingSlash) {
6934
+ urlObject.pathname = `${pathname}/`;
6935
+ }
6936
+ } else if (pathnameUsesTrailingSlash) {
6937
+ // otherwise remove trailing slash if any
6938
+ // a warning here? (because it's strange to reference a file with a trailing slash)
6939
+ urlObject.pathname = pathname.slice(0, -1);
6940
+ }
6941
+ };
6942
+
6943
+ const resolveSymlink = (fileUrl) => {
6944
+ const urlObject = new URL(fileUrl);
6945
+ const realpath = realpathSync(urlObject);
6946
+ const realUrlObject = pathToFileURL(realpath);
6947
+ if (urlObject.pathname.endsWith("/")) {
6948
+ realUrlObject.pathname += `/`;
6949
+ }
6950
+ return realUrlObject.href;
6951
+ };
6952
+
6953
+ const getDirectoryEntryFileUrl = (directoryUrl) => {
6954
+ const indexHtmlFileUrl = new URL(`index.html`, directoryUrl);
6955
+ if (existsSync(indexHtmlFileUrl)) {
6956
+ return indexHtmlFileUrl.href;
6957
+ }
6958
+ const filename = urlToFilename(directoryUrl);
6959
+ const htmlFileUrlCandidate = new URL(`${filename}.html`, directoryUrl);
6960
+ if (existsSync(htmlFileUrlCandidate)) {
6961
+ return htmlFileUrlCandidate.href;
6962
+ }
6963
+ return null;
6964
+ };
6965
+ const getSpaFallbackUrl = (reference) => {
6966
+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
6967
+ reference.ownerUrlInfo.context;
6968
+ if (!requestedUrl) {
6969
+ // the SPA fallback answers a request; during build there is none
6970
+ return null;
6971
+ }
6972
+ const spaFallbackFileUrls = listSpaFallbackFileUrls(requestedUrl, {
6973
+ rootDirectoryUrl,
6974
+ mainFilePath,
6975
+ });
6976
+ for (const spaFallbackFileUrl of spaFallbackFileUrls) {
6977
+ if (existsSync(new URL(spaFallbackFileUrl))) {
6978
+ return spaFallbackFileUrl;
6979
+ }
6980
+ }
6981
+ // none exists: the main file it is, and the 404 answering for it lists
6982
+ // what was tried (see directory listing)
6983
+ return new URL(mainFilePath, rootDirectoryUrl).href;
6984
+ };
6985
+ // The html files that can answer a route, closest first: the entry file of
6986
+ // the route's own directory ("index.html", then "<dirname>.html"), then of
6987
+ // each directory above it up to the server root, then the main file.
6988
+ const listSpaFallbackFileUrls = (
6989
+ requestedUrl,
6990
+ { rootDirectoryUrl, mainFilePath },
6991
+ ) => {
6992
+ const fileUrls = [];
6993
+ let directoryUrl = new URL("./", requestedUrl).href;
6994
+ while (urlIsOrIsInsideOf(directoryUrl, rootDirectoryUrl)) {
6995
+ fileUrls.push(new URL("index.html", directoryUrl).href);
6996
+ const filename = urlToFilename(directoryUrl);
6997
+ if (filename) {
6998
+ fileUrls.push(new URL(`${filename}.html`, directoryUrl).href);
6999
+ }
7000
+ if (directoryUrl === String(rootDirectoryUrl)) {
7001
+ break;
7002
+ }
7003
+ directoryUrl = new URL("../", directoryUrl).href;
7004
+ }
7005
+ const mainFileUrl = new URL(mainFilePath, rootDirectoryUrl).href;
7006
+ if (!fileUrls.includes(mainFileUrl)) {
7007
+ fileUrls.push(mainFileUrl);
7008
+ }
7009
+ return fileUrls;
7010
+ };
7011
+
6762
7012
  /*
6763
7013
  * NICE TO HAVE:
6764
7014
  *
@@ -7136,6 +7386,24 @@ const generateDirectoryListingInjection = (
7136
7386
  filePathExisting: `/${filePathExisting}`,
7137
7387
  filePathNotFound,
7138
7388
  });
7389
+ // a url without extension nor trailing slash is a route: in spa mode it
7390
+ // was answered with the closest html file, the 404 means none was found
7391
+ const urlNotFoundObject = new URL(urlNotFound);
7392
+ if (
7393
+ spa &&
7394
+ !urlToExtension(urlNotFoundObject) &&
7395
+ !urlNotFoundObject.pathname.endsWith("/")
7396
+ ) {
7397
+ enoentDetails.spaFallbackFilePaths = listSpaFallbackFileUrls(
7398
+ urlNotFound,
7399
+ { rootDirectoryUrl: serverRootDirectoryUrl, mainFilePath },
7400
+ ).map((fileUrl) =>
7401
+ FILE_AND_SERVER_URLS_CONVERTER.asServerUrl(
7402
+ fileUrl,
7403
+ serverRootDirectoryUrl,
7404
+ ),
7405
+ );
7406
+ }
7139
7407
  }
7140
7408
 
7141
7409
  return {
@@ -7207,223 +7475,6 @@ const getDirectoryContentItems = ({
7207
7475
  return items;
7208
7476
  };
7209
7477
 
7210
- const jsenvPluginFsRedirection = ({
7211
- spa,
7212
- directoryContentMagicName,
7213
- magicExtensions = ["inherit", ".js"],
7214
- magicDirectoryIndex = true,
7215
- preserveSymlinks = false,
7216
- }) => {
7217
- return {
7218
- name: "jsenv:fs_redirection",
7219
- appliesDuring: "*",
7220
- redirectReference: (reference) => {
7221
- if (reference.url === "file:///") {
7222
- return `ignore:file:///`;
7223
- }
7224
- if (reference.url === "file://") {
7225
- return `ignore:file://`;
7226
- }
7227
- // ignore all new URL second arg
7228
- if (reference.subtype === "new_url_second_arg") {
7229
- if (reference.original) {
7230
- return `ignore:${reference.original.specifier}`;
7231
- }
7232
- return `ignore:${reference.specifier}`;
7233
- }
7234
- // http, https, data, about, ...
7235
- if (!reference.url.startsWith("file:")) {
7236
- return null;
7237
- }
7238
- if (reference.original && !reference.original.url.startsWith("file:")) {
7239
- return null;
7240
- }
7241
- if (reference.isInline) {
7242
- return null;
7243
- }
7244
-
7245
- if (
7246
- reference.specifierPathname.endsWith(`/${directoryContentMagicName}`)
7247
- ) {
7248
- const { rootDirectoryUrl } = reference.ownerUrlInfo.context;
7249
- const directoryUrl = new URL(
7250
- reference.specifierPathname
7251
- .replace(`/${directoryContentMagicName}`, "/")
7252
- .slice(1),
7253
- rootDirectoryUrl,
7254
- ).href;
7255
- return directoryUrl;
7256
- }
7257
- // ignore "./" on new URL("./")
7258
- // if (
7259
- // reference.subtype === "new_url_first_arg" &&
7260
- // reference.specifier === "./"
7261
- // ) {
7262
- // return `ignore:${reference.url}`;
7263
- // }
7264
- const urlObject = new URL(reference.url);
7265
- let fsStat = readEntryStatSync(urlObject, { nullIfNotFound: true });
7266
- reference.fsStat = fsStat;
7267
- const { search, hash } = urlObject;
7268
- urlObject.search = "";
7269
- urlObject.hash = "";
7270
- // must be read before applyFsStatEffectsOnUrlObject which forces the
7271
- // trailing slash on directories
7272
- const specifierUsesTrailingSlash = urlObject.pathname.endsWith("/");
7273
- applyFsStatEffectsOnUrlObject(urlObject, fsStat);
7274
- const shouldApplyFilesystemMagicResolution =
7275
- reference.type === "js_import";
7276
- if (shouldApplyFilesystemMagicResolution) {
7277
- const filesystemResolution = applyFileSystemMagicResolution(
7278
- urlObject.href,
7279
- {
7280
- fileStat: fsStat,
7281
- magicDirectoryIndex,
7282
- magicExtensions: getExtensionsToTry(
7283
- magicExtensions,
7284
- reference.ownerUrlInfo.url,
7285
- ),
7286
- },
7287
- );
7288
- if (filesystemResolution.stat) {
7289
- fsStat = filesystemResolution.stat;
7290
- reference.fsStat = fsStat;
7291
- urlObject.href = filesystemResolution.url;
7292
- applyFsStatEffectsOnUrlObject(urlObject, fsStat);
7293
- }
7294
- }
7295
- if (spa) {
7296
- // for SPA we want to serve the root HTML file most of the time
7297
- if (!fsStat) {
7298
- if (urlToExtension(urlObject)) {
7299
- // url has an extension, we assume it's a file request -> let 404 happen
7300
- return null;
7301
- }
7302
- if (specifierUsesTrailingSlash) {
7303
- // the trailing slash asks for a directory and there is none here
7304
- // -> let 404 happen (same reasoning as the extension above)
7305
- return null;
7306
- }
7307
- const spaFallbackUrl = getSpaFallbackUrl(reference);
7308
- if (spaFallbackUrl) {
7309
- return spaFallbackUrl;
7310
- }
7311
- return null;
7312
- }
7313
- if (fsStat.isDirectory()) {
7314
- // When requesting a directory, check if we have an HTML entry file for that directory
7315
- const directoryEntryFileUrl = getDirectoryEntryFileUrl(urlObject);
7316
- if (directoryEntryFileUrl) {
7317
- reference.fsStat = readEntryStatSync(directoryEntryFileUrl);
7318
- return directoryEntryFileUrl;
7319
- }
7320
- if (!specifierUsesTrailingSlash) {
7321
- // the trailing slash is what tells a directory apart from a route:
7322
- // "/join/" is the directory, "/join" is a route owned by the SPA
7323
- // even when "join/" exists in the source files.
7324
- // Without this a source directory would shadow the route having
7325
- // the same name and the SPA would be unreachable in dev while
7326
- // being perfectly fine once built
7327
- const spaFallbackUrl = getSpaFallbackUrl(reference);
7328
- if (spaFallbackUrl) {
7329
- reference.fsStat = readEntryStatSync(spaFallbackUrl, {
7330
- nullIfNotFound: true,
7331
- });
7332
- return spaFallbackUrl;
7333
- }
7334
- }
7335
- }
7336
- }
7337
- if (!fsStat) {
7338
- return null;
7339
- }
7340
- const urlBeforeSymlinkResolution = urlObject.href;
7341
- if (preserveSymlinks) {
7342
- return `${urlBeforeSymlinkResolution}${search}${hash}`;
7343
- }
7344
- const urlAfterSymlinkResolution = resolveSymlink(
7345
- urlBeforeSymlinkResolution,
7346
- );
7347
- if (urlAfterSymlinkResolution !== urlBeforeSymlinkResolution) {
7348
- reference.leadsToASymlink = true;
7349
- // reference.baseUrl = urlBeforeSymlinkResolution;
7350
- }
7351
- const resolvedUrl = `${urlAfterSymlinkResolution}${search}${hash}`;
7352
- return resolvedUrl;
7353
- },
7354
- };
7355
- };
7356
-
7357
- const applyFsStatEffectsOnUrlObject = (urlObject, fsStat) => {
7358
- if (!fsStat) {
7359
- return;
7360
- }
7361
- const { pathname } = urlObject;
7362
- const pathnameUsesTrailingSlash = pathname.endsWith("/");
7363
- // force trailing slash on directories
7364
- if (fsStat.isDirectory()) {
7365
- if (!pathnameUsesTrailingSlash) {
7366
- urlObject.pathname = `${pathname}/`;
7367
- }
7368
- } else if (pathnameUsesTrailingSlash) {
7369
- // otherwise remove trailing slash if any
7370
- // a warning here? (because it's strange to reference a file with a trailing slash)
7371
- urlObject.pathname = pathname.slice(0, -1);
7372
- }
7373
- };
7374
-
7375
- const resolveSymlink = (fileUrl) => {
7376
- const urlObject = new URL(fileUrl);
7377
- const realpath = realpathSync(urlObject);
7378
- const realUrlObject = pathToFileURL(realpath);
7379
- if (urlObject.pathname.endsWith("/")) {
7380
- realUrlObject.pathname += `/`;
7381
- }
7382
- return realUrlObject.href;
7383
- };
7384
-
7385
- const getDirectoryEntryFileUrl = (directoryUrl) => {
7386
- const indexHtmlFileUrl = new URL(`index.html`, directoryUrl);
7387
- if (existsSync(indexHtmlFileUrl)) {
7388
- return indexHtmlFileUrl.href;
7389
- }
7390
- const filename = urlToFilename(directoryUrl);
7391
- const htmlFileUrlCandidate = new URL(`${filename}.html`, directoryUrl);
7392
- if (existsSync(htmlFileUrlCandidate)) {
7393
- return htmlFileUrlCandidate.href;
7394
- }
7395
- return null;
7396
- };
7397
- const getSpaFallbackUrl = (reference) => {
7398
- const { requestedUrl, rootDirectoryUrl, mainFilePath } =
7399
- reference.ownerUrlInfo.context;
7400
- if (!requestedUrl) {
7401
- // the SPA fallback answers a request; during build there is none
7402
- return null;
7403
- }
7404
- const closestHtmlRootFile = getClosestHtmlRootFile(
7405
- requestedUrl,
7406
- rootDirectoryUrl,
7407
- );
7408
- if (closestHtmlRootFile) {
7409
- return closestHtmlRootFile;
7410
- }
7411
- return String(new URL(mainFilePath, rootDirectoryUrl));
7412
- };
7413
- const getClosestHtmlRootFile = (requestedUrl, serverRootDirectoryUrl) => {
7414
- let directoryUrl = new URL("./", requestedUrl);
7415
- while (true) {
7416
- const directoryEntryFileUrl = getDirectoryEntryFileUrl(directoryUrl);
7417
- if (directoryEntryFileUrl) {
7418
- return directoryEntryFileUrl;
7419
- }
7420
- if (!urlIsOrIsInsideOf(directoryUrl, serverRootDirectoryUrl)) {
7421
- return null;
7422
- }
7423
- directoryUrl = new URL("../", directoryUrl);
7424
- }
7425
- };
7426
-
7427
7478
  const directoryContentMagicName = "...";
7428
7479
 
7429
7480
  const jsenvPluginProtocolFile = ({
@@ -7577,7 +7628,16 @@ const jsenvPluginProtocolFile = ({
7577
7628
  }
7578
7629
  const serveFile = (url) => {
7579
7630
  const contentType = CONTENT_TYPE.fromUrlExtension(url);
7580
- const fileBuffer = readFileSync(new URL(url));
7631
+ const urlObject = new URL(url);
7632
+ // taken before the read: a write landing in between moves the
7633
+ // stat past this one, so the dev server sees the content as
7634
+ // outdated (see isValid in the dev server) rather than the reverse
7635
+ const fileStat = statSync(urlObject);
7636
+ const fileBuffer = readFileSync(urlObject);
7637
+ urlInfo.data.fileStat = {
7638
+ mtimeMs: fileStat.mtimeMs,
7639
+ size: fileStat.size,
7640
+ };
7581
7641
  const content = CONTENT_TYPE.isTextual(contentType)
7582
7642
  ? String(fileBuffer)
7583
7643
  : fileBuffer;
@@ -8084,7 +8144,7 @@ const jsenvPluginImportMetaScenarios = () => {
8084
8144
  name: "jsenv:import_meta_scenario",
8085
8145
  appliesDuring: "*",
8086
8146
  transformUrlContent: {
8087
- js_module: async (urlInfo) => {
8147
+ js_module: (urlInfo) => {
8088
8148
  // Do not scan node modules for import.meta.dev/import.meta.build
8089
8149
  // - node modules won't have this in their code
8090
8150
  // - ;or should use other an other technic as this one won't be available
@@ -8100,40 +8160,42 @@ const jsenvPluginImportMetaScenarios = () => {
8100
8160
  ) {
8101
8161
  return null;
8102
8162
  }
8103
- const { metadata } = await applyBabelPlugins({
8104
- babelPlugins: [babelPluginMetadataImportMetaScenarios],
8105
- input: urlInfo.content,
8106
- inputIsJsModule: true,
8107
- inputUrl: urlInfo.originalUrl,
8108
- outputUrl: urlInfo.generatedUrl,
8163
+ const importMetaScenarioNodes = { dev: [], build: [] };
8164
+ visitJsAst(urlInfo.contentAst, {
8165
+ MemberExpression: (node) => {
8166
+ const name = getImportMetaPropertyName(node);
8167
+ if (name === "dev" || name === "build") {
8168
+ importMetaScenarioNodes[name].push(node);
8169
+ }
8170
+ },
8109
8171
  });
8110
- const { dev = [], build = [] } = metadata.importMetaScenarios;
8172
+ const { dev, build } = importMetaScenarioNodes;
8111
8173
  const replacements = [];
8112
- const replace = (path, value) => {
8113
- replacements.push({ path, value });
8174
+ const replace = (node, value) => {
8175
+ replacements.push({ node, value });
8114
8176
  };
8115
8177
  if (urlInfo.context.build) {
8116
8178
  // during build ensure replacement for tree-shaking
8117
- dev.forEach((path) => {
8118
- replace(path, "undefined");
8179
+ dev.forEach((node) => {
8180
+ replace(node, "undefined");
8119
8181
  });
8120
- build.forEach((path) => {
8121
- replace(path, "true");
8182
+ build.forEach((node) => {
8183
+ replace(node, "true");
8122
8184
  });
8123
8185
  } else {
8124
8186
  // during dev we can let "import.meta.build" untouched
8125
8187
  // it will be evaluated to undefined.
8126
8188
  // Moreover it can be surprising to see some "undefined"
8127
8189
  // when source file contains "import.meta.build"
8128
- dev.forEach((path) => {
8129
- replace(path, "true");
8190
+ dev.forEach((node) => {
8191
+ replace(node, "true");
8130
8192
  });
8131
8193
  }
8132
8194
  const magicSource = createMagicSource(urlInfo.content);
8133
- replacements.forEach(({ path, value }) => {
8195
+ replacements.forEach(({ node, value }) => {
8134
8196
  magicSource.replace({
8135
- start: path.node.start,
8136
- end: path.node.end,
8197
+ start: node.start,
8198
+ end: node.end,
8137
8199
  replacement: value,
8138
8200
  });
8139
8201
  });
@@ -8143,42 +8205,6 @@ const jsenvPluginImportMetaScenarios = () => {
8143
8205
  };
8144
8206
  };
8145
8207
 
8146
- const babelPluginMetadataImportMetaScenarios = () => {
8147
- return {
8148
- name: "metadata-import-meta-scenarios",
8149
- visitor: {
8150
- Program(programPath, state) {
8151
- const importMetas = {};
8152
- programPath.traverse({
8153
- MemberExpression(path) {
8154
- const { node } = path;
8155
- const { object } = node;
8156
- if (object.type !== "MetaProperty") {
8157
- return;
8158
- }
8159
- const { property: objectProperty } = object;
8160
- if (objectProperty.name !== "meta") {
8161
- return;
8162
- }
8163
- const { property } = node;
8164
- const { name } = property;
8165
- const importMetaPaths = importMetas[name];
8166
- if (importMetaPaths) {
8167
- importMetaPaths.push(path);
8168
- } else {
8169
- importMetas[name] = [path];
8170
- }
8171
- },
8172
- });
8173
- state.file.metadata.importMetaScenarios = {
8174
- dev: importMetas.dev,
8175
- build: importMetas.build,
8176
- };
8177
- },
8178
- },
8179
- };
8180
- };
8181
-
8182
8208
  /*
8183
8209
  * Source code can contain the following
8184
8210
  * - __DEV__
@@ -8277,15 +8303,10 @@ const jsenvPluginImportMetaCss = () => {
8277
8303
  if (!urlInfo.content.includes("import.meta.css")) {
8278
8304
  return null;
8279
8305
  }
8280
- const { metadata } = await applyBabelPlugins({
8281
- babelPlugins: [babelPluginMetadataUsesImportMetaCss],
8282
- input: urlInfo.content,
8283
- inputIsJsModule: true,
8284
- inputUrl: urlInfo.originalUrl,
8285
- outputUrl: urlInfo.generatedUrl,
8306
+ const importMetaCssNode = visitJsAstUntil(urlInfo.contentAst, {
8307
+ MemberExpression: (node) => getImportMetaPropertyName(node) === "css",
8286
8308
  });
8287
- const { usesImportMetaCss } = metadata;
8288
- if (!usesImportMetaCss) {
8309
+ if (!importMetaCssNode) {
8289
8310
  return null;
8290
8311
  }
8291
8312
  if (urlInfo.context.build) {
@@ -8365,37 +8386,6 @@ const babelPluginRewriteImportMetaCssAssignment = (
8365
8386
  };
8366
8387
  };
8367
8388
 
8368
- const babelPluginMetadataUsesImportMetaCss = () => {
8369
- return {
8370
- name: "metadata-uses-import-meta-css",
8371
- visitor: {
8372
- Program(programPath, state) {
8373
- let usesImportMetaCss = false;
8374
- programPath.traverse({
8375
- MemberExpression(path) {
8376
- const { node } = path;
8377
- const { object } = node;
8378
- if (object.type !== "MetaProperty") {
8379
- return;
8380
- }
8381
- const { property: objectProperty } = object;
8382
- if (objectProperty.name !== "meta") {
8383
- return;
8384
- }
8385
- const { property } = node;
8386
- const { name } = property;
8387
- if (name === "css") {
8388
- usesImportMetaCss = true;
8389
- path.stop();
8390
- }
8391
- },
8392
- });
8393
- state.file.metadata.usesImportMetaCss = usesImportMetaCss;
8394
- },
8395
- },
8396
- };
8397
- };
8398
-
8399
8389
  const injectImportMetaCss = (
8400
8390
  urlInfo,
8401
8391
  { content, importFrom, importName, importAs, hot },
@@ -8438,104 +8428,6 @@ ${importVariableName}(import.meta);
8438
8428
  };
8439
8429
  };
8440
8430
 
8441
- // https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#toc-stages-of-babel
8442
- // https://github.com/cfware/babel-plugin-bundled-import-meta/blob/master/index.js
8443
- // https://github.com/babel/babel/blob/f4edf62f6beeab8ae9f2b7f0b82f1b3b12a581af/packages/babel-helper-module-imports/src/index.js#L7
8444
-
8445
- const babelPluginMetadataImportMetaHot = () => {
8446
- return {
8447
- name: "metadata-import-meta-hot",
8448
- visitor: {
8449
- Program(programPath, state) {
8450
- Object.assign(
8451
- state.file.metadata,
8452
- collectImportMetaHotProperties(programPath),
8453
- );
8454
- },
8455
- },
8456
- };
8457
- };
8458
- const collectImportMetaHotProperties = (programPath) => {
8459
- const importMetaHotPaths = [];
8460
- let hotDecline = false;
8461
- let hotAcceptSelf = false;
8462
- let hotAcceptDependencies = [];
8463
- programPath.traverse({
8464
- MemberExpression(path) {
8465
- const { node } = path;
8466
- const { object } = node;
8467
- if (object.type !== "MetaProperty") {
8468
- return;
8469
- }
8470
- const { property: objectProperty } = object;
8471
- if (objectProperty.name !== "meta") {
8472
- return;
8473
- }
8474
- const { property } = node;
8475
- const { name } = property;
8476
- if (name === "hot") {
8477
- importMetaHotPaths.push(path);
8478
- }
8479
- },
8480
- CallExpression(path) {
8481
- if (isImportMetaHotMethodCall(path, "accept")) {
8482
- const callNode = path.node;
8483
- const args = callNode.arguments;
8484
- if (args.length === 0) {
8485
- hotAcceptSelf = true;
8486
- return;
8487
- }
8488
- const firstArg = args[0];
8489
- if (firstArg.type === "StringLiteral") {
8490
- hotAcceptDependencies = [
8491
- {
8492
- specifierPath: path.get("arguments")[0],
8493
- },
8494
- ];
8495
- return;
8496
- }
8497
- if (firstArg.type === "ArrayExpression") {
8498
- const firstArgPath = path.get("arguments")[0];
8499
- hotAcceptDependencies = firstArg.elements.map((arrayNode, index) => {
8500
- if (arrayNode.type !== "StringLiteral") {
8501
- throw new Error(
8502
- `all array elements must be strings in "import.meta.hot.accept(array)"`,
8503
- );
8504
- }
8505
- return {
8506
- specifierPath: firstArgPath.get(String(index)),
8507
- };
8508
- });
8509
- return;
8510
- }
8511
- // accept first arg can be "anything" such as
8512
- // `const cb = () => {}; import.meta.accept(cb)`
8513
- hotAcceptSelf = true;
8514
- }
8515
- if (isImportMetaHotMethodCall(path, "decline")) {
8516
- hotDecline = true;
8517
- }
8518
- },
8519
- });
8520
- return {
8521
- importMetaHotPaths,
8522
- hotDecline,
8523
- hotAcceptSelf,
8524
- hotAcceptDependencies,
8525
- };
8526
- };
8527
- const isImportMetaHotMethodCall = (path, methodName) => {
8528
- const { property, object } = path.node.callee;
8529
- return (
8530
- property &&
8531
- property.name === methodName &&
8532
- object &&
8533
- object.property &&
8534
- object.property.name === "hot" &&
8535
- object.object.type === "MetaProperty"
8536
- );
8537
- };
8538
-
8539
8431
  // Some "smart" default applied to decide what should hot reload / fullreload:
8540
8432
  // By default:
8541
8433
  // - hot reload on <img src="./image.png" />
@@ -8683,6 +8575,75 @@ const htmlNodeCanHotReload = (node) => {
8683
8575
  ].includes(node.nodeName);
8684
8576
  };
8685
8577
 
8578
+ const analyzeImportMetaHot = (ast) => {
8579
+ const importMetaHotNodes = [];
8580
+ let hotDecline = false;
8581
+ let hotAcceptSelf = false;
8582
+ let hotAcceptSpecifiers = [];
8583
+ visitJsAst(ast, {
8584
+ MemberExpression: (node) => {
8585
+ if (getImportMetaPropertyName(node) === "hot") {
8586
+ importMetaHotNodes.push(node);
8587
+ }
8588
+ },
8589
+ CallExpression: (node) => {
8590
+ const methodName = getImportMetaHotMethodName(node);
8591
+ if (methodName === "accept") {
8592
+ const args = node.arguments;
8593
+ if (args.length === 0) {
8594
+ hotAcceptSelf = true;
8595
+ return;
8596
+ }
8597
+ const [firstArg] = args;
8598
+ if (isStringLiteral(firstArg)) {
8599
+ hotAcceptSpecifiers = [firstArg.value];
8600
+ return;
8601
+ }
8602
+ if (firstArg.type === "ArrayExpression") {
8603
+ hotAcceptSpecifiers = firstArg.elements.map((element) => {
8604
+ if (!isStringLiteral(element)) {
8605
+ throw new Error(
8606
+ `all array elements must be strings in "import.meta.hot.accept(array)"`,
8607
+ );
8608
+ }
8609
+ return element.value;
8610
+ });
8611
+ return;
8612
+ }
8613
+ // accept first arg can be "anything" such as
8614
+ // `const cb = () => {}; import.meta.hot.accept(cb)`
8615
+ hotAcceptSelf = true;
8616
+ return;
8617
+ }
8618
+ if (methodName === "decline") {
8619
+ hotDecline = true;
8620
+ }
8621
+ },
8622
+ });
8623
+ return {
8624
+ importMetaHotNodes,
8625
+ hotDecline,
8626
+ hotAcceptSelf,
8627
+ hotAcceptSpecifiers,
8628
+ };
8629
+ };
8630
+
8631
+ // "import.meta.hot.<method>(...)"
8632
+ const getImportMetaHotMethodName = (callNode) => {
8633
+ const { callee } = callNode;
8634
+ if (callee.type !== "MemberExpression" || callee.computed) {
8635
+ return null;
8636
+ }
8637
+ if (getImportMetaPropertyName(callee.object) !== "hot") {
8638
+ return null;
8639
+ }
8640
+ return callee.property.name;
8641
+ };
8642
+
8643
+ const isStringLiteral = (node) => {
8644
+ return node.type === "Literal" && typeof node.value === "string";
8645
+ };
8646
+
8686
8647
  const jsenvPluginImportMetaHot = () => {
8687
8648
  const importMetaHotClientFileUrl = import.meta
8688
8649
  .resolve("../client/import_meta_hot/import_meta_hot.js");
@@ -8731,7 +8692,7 @@ const jsenvPluginImportMetaHot = () => {
8731
8692
  cssUrlInfo.data.hotAcceptSelf = false;
8732
8693
  cssUrlInfo.data.hotAcceptDependencies = [];
8733
8694
  },
8734
- js_module: async (urlInfo) => {
8695
+ js_module: (urlInfo) => {
8735
8696
  // Do not scan node modules for import.meta.hot
8736
8697
  // - unlikely to be there
8737
8698
  // - we don't watch node modules (too expensive)
@@ -8743,27 +8704,22 @@ const jsenvPluginImportMetaHot = () => {
8743
8704
  if (!urlInfo.content.includes("import.meta.hot")) {
8744
8705
  return null;
8745
8706
  }
8746
- const { metadata } = await applyBabelPlugins({
8747
- babelPlugins: [babelPluginMetadataImportMetaHot],
8748
- input: urlInfo.content,
8749
- inputIsJsModule: true,
8750
- inputUrl: urlInfo.originalUrl,
8751
- outputUrl: urlInfo.generatedUrl,
8752
- });
8753
8707
  const {
8754
- importMetaHotPaths,
8708
+ importMetaHotNodes,
8755
8709
  hotDecline,
8756
8710
  hotAcceptSelf,
8757
- hotAcceptDependencies,
8758
- } = metadata;
8711
+ hotAcceptSpecifiers,
8712
+ } = analyzeImportMetaHot(urlInfo.contentAst);
8759
8713
  urlInfo.data.hotDecline = hotDecline;
8760
8714
  urlInfo.data.hotAcceptSelf = hotAcceptSelf;
8761
- urlInfo.data.hotAcceptDependencies = hotAcceptDependencies;
8762
- if (importMetaHotPaths.length === 0) {
8715
+ urlInfo.data.hotAcceptDependencies = hotAcceptSpecifiers.map(
8716
+ (specifier) => resolveHotAcceptSpecifier(urlInfo, specifier),
8717
+ );
8718
+ if (importMetaHotNodes.length === 0) {
8763
8719
  return null;
8764
8720
  }
8765
8721
  if (urlInfo.context.build) {
8766
- return removeImportMetaHots(urlInfo, importMetaHotPaths);
8722
+ return removeImportMetaHots(urlInfo, importMetaHotNodes);
8767
8723
  }
8768
8724
  return injectImportMetaHot(urlInfo, importMetaHotClientFileUrl);
8769
8725
  },
@@ -8771,12 +8727,32 @@ const jsenvPluginImportMetaHot = () => {
8771
8727
  };
8772
8728
  };
8773
8729
 
8774
- const removeImportMetaHots = (urlInfo, importMetaHotPaths) => {
8730
+ // The specifier given to import.meta.hot.accept() is one the file imports,
8731
+ // so its url is known from that import (autoreload compares urls). A
8732
+ // specifier the file does not import accepts nothing: it is kept as a plain
8733
+ // url, never resolved as a dependency (that could throw on a bare specifier).
8734
+ const resolveHotAcceptSpecifier = (urlInfo, specifier) => {
8735
+ for (const referenceToOther of urlInfo.referenceToOthersSet) {
8736
+ if (
8737
+ referenceToOther.type === "js_import" &&
8738
+ referenceToOther.specifier === specifier
8739
+ ) {
8740
+ return referenceToOther.url;
8741
+ }
8742
+ }
8743
+ try {
8744
+ return new URL(specifier, urlInfo.url).href;
8745
+ } catch {
8746
+ return specifier;
8747
+ }
8748
+ };
8749
+
8750
+ const removeImportMetaHots = (urlInfo, importMetaHotNodes) => {
8775
8751
  const magicSource = createMagicSource(urlInfo.content);
8776
- importMetaHotPaths.forEach((path) => {
8752
+ importMetaHotNodes.forEach((node) => {
8777
8753
  magicSource.replace({
8778
- start: path.node.start,
8779
- end: path.node.end,
8754
+ start: node.start,
8755
+ end: node.end,
8780
8756
  replacement: "undefined",
8781
8757
  });
8782
8758
  });
@@ -9305,6 +9281,132 @@ const jsenvPluginAutoreload = ({
9305
9281
  ];
9306
9282
  };
9307
9283
 
9284
+ /*
9285
+ * Tells the browser the static import graph of a page's module scripts, as
9286
+ * `Link: <url>; rel=modulepreload` response headers, so it can fetch every
9287
+ * module as soon as the page's headers arrive, without waiting to discover
9288
+ * them level by level (each level costing a round trip and, the first time,
9289
+ * a cook).
9290
+ *
9291
+ * Only what the graph already knows is listed: a module never cooked has no
9292
+ * known imports. The header is computed at each response from the graph as it
9293
+ * is then: a page gets its full list from its second load on, the first load
9294
+ * keeps the plain waterfall.
9295
+ *
9296
+ * Headers rather than <link> elements written into the page: the page content
9297
+ * stays what it is (served from memory, revalidated by etag) and the list can
9298
+ * grow without cooking the page again. And not references either: a reference
9299
+ * from the html to every module would make the html a dependent of each of
9300
+ * them, and a hot update propagating up the importers would reach the html
9301
+ * (which declines) instead of stopping at the module accepting it.
9302
+ *
9303
+ * OFF BY DEFAULT, to be enabled once the dev server speaks http/2 or http/3.
9304
+ * Over http/1.1 it makes pages slower — measured on a 500 modules page: first
9305
+ * paint 32ms -> 868ms, load 626ms -> 985ms. Knowing the urls earlier gives the
9306
+ * browser no extra resource: what it has is 6 connections per origin, and
9307
+ * every request goes through them in the order it was learned.
9308
+ * - Without the header, requests come in execution order: the parser asks
9309
+ * for the render-blocking supervisor script first and gets a connection at
9310
+ * once; transfer and execution overlap, a module executing and discovering
9311
+ * its imports while the others download. On localhost the server answers
9312
+ * from memory in 0.1ms, so the 6 connections are busy from the start.
9313
+ * - With the header, hundreds of requests take the 6 connections before the
9314
+ * parser asks for the supervisor script, which then waits for a free
9315
+ * connection behind 1MB+ transfers (520ms measured, 2s with 20ms of RTT).
9316
+ * Meanwhile nothing renders and nothing executes: transfer, then execution,
9317
+ * serialized. http/1.1 cannot reprioritize a queued request, and a
9318
+ * modulepreload has the same priority as a script.
9319
+ * - And there is nothing to hide: the discovery latency a preload removes is
9320
+ * ~1ms per graph level on localhost; with a real RTT a wide graph (more
9321
+ * than 6 modules pending at any time) keeps the 6 connections saturated
9322
+ * anyway, and the load takes requests / 6 * RTT whatever the order.
9323
+ * It pays off for a deep and narrow graph (connections idle, waiting for a
9324
+ * response to learn the next level), and over http/2 or http/3: no
9325
+ * connection limit, and the browser prioritizes the blocking script.
9326
+ */
9327
+
9328
+
9329
+ const jsenvPluginModulepreload = () => {
9330
+ return {
9331
+ name: "jsenv:modulepreload",
9332
+ appliesDuring: "dev",
9333
+ augmentResponse: ({ urlInfo }) => {
9334
+ if (urlInfo.type !== "html") {
9335
+ return null;
9336
+ }
9337
+ const hrefs = collectStaticImportHrefs(urlInfo);
9338
+ if (hrefs.length === 0) {
9339
+ return null;
9340
+ }
9341
+ return {
9342
+ headers: {
9343
+ link: hrefs.map((href) => `<${href}>; rel=modulepreload`).join(", "),
9344
+ },
9345
+ };
9346
+ },
9347
+ };
9348
+ };
9349
+
9350
+ const collectStaticImportHrefs = (htmlUrlInfo) => {
9351
+ const hrefSet = new Set();
9352
+ const visitedSet = new Set();
9353
+ const addHref = (reference) => {
9354
+ const specifier = urlSpecifierEncoding.decode(reference);
9355
+ if (typeof specifier !== "string") {
9356
+ return;
9357
+ }
9358
+ // "?hot" belongs to the request that cooked the importer, the page loading
9359
+ // now imports the url without it
9360
+ hrefSet.add(injectQueryParamsIntoSpecifier(specifier, { hot: undefined }));
9361
+ };
9362
+ const visitJsModule = (jsModuleUrlInfo) => {
9363
+ if (visitedSet.has(jsModuleUrlInfo)) {
9364
+ return;
9365
+ }
9366
+ visitedSet.add(jsModuleUrlInfo);
9367
+ for (const reference of jsModuleUrlInfo.referenceToOthersSet) {
9368
+ if (
9369
+ reference.type !== "js_import" ||
9370
+ reference.isWeak ||
9371
+ reference.isImplicit ||
9372
+ !STATIC_IMPORT_SUBTYPES.includes(reference.subtype) ||
9373
+ !reference.url.startsWith("file:")
9374
+ ) {
9375
+ continue;
9376
+ }
9377
+ const importedUrlInfo = reference.urlInfo;
9378
+ // never cooked: its content, hence its own imports, are unknown
9379
+ if (importedUrlInfo.type !== "js_module") {
9380
+ continue;
9381
+ }
9382
+ addHref(reference);
9383
+ visitJsModule(importedUrlInfo);
9384
+ }
9385
+ };
9386
+ for (const reference of htmlUrlInfo.referenceToOthersSet) {
9387
+ if (
9388
+ reference.type !== "script" ||
9389
+ reference.expectedType !== "js_module" ||
9390
+ // the scripts jsenv adds to every page are few, small and in memory
9391
+ reference.injected ||
9392
+ reference.isWeak
9393
+ ) {
9394
+ continue;
9395
+ }
9396
+ const scriptUrlInfo = reference.urlInfo;
9397
+ if (scriptUrlInfo.type !== "js_module") {
9398
+ continue;
9399
+ }
9400
+ if (!reference.isInline && reference.url.startsWith("file:")) {
9401
+ addHref(reference);
9402
+ }
9403
+ visitJsModule(scriptUrlInfo);
9404
+ }
9405
+ return Array.from(hrefSet);
9406
+ };
9407
+
9408
+ const STATIC_IMPORT_SUBTYPES = ["import_static", "export_named", "export_all"];
9409
+
9308
9410
  /*
9309
9411
  * Tells the browser about the dependencies declared in package.json that
9310
9412
  * node_modules does not match, so the page can say it is running with something
@@ -9946,6 +10048,7 @@ const getCorePlugins = ({
9946
10048
 
9947
10049
  clientAutoreload,
9948
10050
  clientAutoreloadOnServerRestart,
10051
+ modulepreload = false,
9949
10052
  dependencyStatus,
9950
10053
  cacheControl,
9951
10054
  scenarioPlaceholders = true,
@@ -10047,6 +10150,7 @@ const getCorePlugins = ({
10047
10150
  ...(clientAutoreload && clientAutoreload.enabled
10048
10151
  ? [jsenvPluginAutoreload(clientAutoreload)]
10049
10152
  : []),
10153
+ ...(modulepreload ? [jsenvPluginModulepreload()] : []),
10050
10154
  ...(dependencyStatus
10051
10155
  ? [jsenvPluginDependencyStatus(dependencyStatus)]
10052
10156
  : []),