@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.17",
3
+ "version": "41.4.18",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -72,12 +72,12 @@
72
72
  "test:snapshot_clear": "npx @jsenv/filesystem clear **/tests/**/side_effects/"
73
73
  },
74
74
  "dependencies": {
75
- "@jsenv/ast": "6.9.1",
76
- "@jsenv/js-module-fallback": "1.5.0",
77
- "@jsenv/plugin-bundling": "2.10.16",
78
- "@jsenv/plugin-minification": "1.7.8",
79
- "@jsenv/plugin-supervisor": "1.8.12",
80
- "@jsenv/plugin-transpilation": "1.6.0",
75
+ "@jsenv/ast": "6.9.2",
76
+ "@jsenv/js-module-fallback": "1.5.1",
77
+ "@jsenv/plugin-bundling": "2.10.17",
78
+ "@jsenv/plugin-minification": "1.7.9",
79
+ "@jsenv/plugin-supervisor": "1.8.13",
80
+ "@jsenv/plugin-transpilation": "1.6.1",
81
81
  "@jsenv/server": "17.6.1",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
@@ -1,9 +1,8 @@
1
1
  import { bufferToEtag } from "@jsenv/filesystem";
2
2
  import { formatError } from "@jsenv/humanize";
3
3
  import { composeTwoResponses, fetchDirectory } from "@jsenv/server";
4
- import { URL_META } from "@jsenv/url-meta";
5
4
  import { normalizeUrl } from "@jsenv/urls";
6
- import { readFileSync } from "node:fs";
5
+ import { readFileSync, statSync } from "node:fs";
7
6
 
8
7
  import { watchSourceFiles } from "../../helpers/watch_source_files.js";
9
8
  import { WEB_URL_CONVERTER } from "../../helpers/web_url_converter.js";
@@ -56,11 +55,10 @@ export const devServerPluginServeSourceFiles = ({
56
55
  if (existing) {
57
56
  return existing;
58
57
  }
59
- const watchAssociations = URL_META.resolveAssociations(
60
- { watch: stopWatchingSourceFiles.watchPatterns },
61
- sourceDirectoryUrl,
62
- );
63
58
  let kitchen;
59
+ // per url info, the file stat under which its content was last compared
60
+ // with the file on disk and found identical (see isValid)
61
+ const fileStatValidatedMap = new WeakMap();
64
62
  clientFileChangeEventEmitter.on(({ url, event }) => {
65
63
  const urlInfo = kitchen.graph.getUrlInfo(url);
66
64
  if (urlInfo) {
@@ -101,11 +99,6 @@ export const devServerPluginServeSourceFiles = ({
101
99
  packageDirectory,
102
100
  });
103
101
  kitchen.graph.urlInfoCreatedEventEmitter.on((urlInfoCreated) => {
104
- const { watch } = URL_META.applyAssociations({
105
- url: urlInfoCreated.url,
106
- associations: watchAssociations,
107
- });
108
- urlInfoCreated.isWatched = watch;
109
102
  // when an url depends on many others, we check all these (like package.json)
110
103
  urlInfoCreated.isValid = () => {
111
104
  const seenSet = new Set();
@@ -125,37 +118,64 @@ export const devServerPluginServeSourceFiles = ({
125
118
  // was compared using etag and it has changed
126
119
  return false;
127
120
  }
128
- // Watched files trust the watcher — except the ones marked
129
- // revalidateOnFileSystem (package.json files, see node_esm_resolver):
130
- // the watcher fires a beat AFTER a change, and what these files
131
- // decide (a package version, hence the ?v= the importer embeds) is
132
- // cached as immutable by the browser — a request racing the watcher
133
- // must not win a stale answer it would then keep forever.
134
- if (!urlInfo.isWatched || urlInfo.revalidateOnFileSystem) {
135
- // check the filesystem
136
- let fileContentAsBuffer;
121
+ // The content held in memory is the file as it was read; a request
122
+ // must never win a stale answer, whatever the watcher's latency,
123
+ // and the ?v= a package.json decides even ends up in the browser's
124
+ // immutable cache. So the file is checked on disk at every
125
+ // validation — cheaply: a stat, compared with the one taken when
126
+ // the content was read (or last found identical). Only a file
127
+ // whose stat moved is read and hashed again.
128
+ // Inline content (a <script> inside an html) has no file of its
129
+ // own: it is as fresh as the html holding it, checked by the caller.
130
+ if (!urlInfo.isInline) {
131
+ let fileStat;
137
132
  try {
138
- fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
139
- } catch (e) {
140
- if (e.code === "ENOENT") {
141
- urlInfo.onModified();
142
- return false;
143
- }
133
+ fileStat = statSync(new URL(urlInfo.url), {
134
+ throwIfNoEntry: false,
135
+ });
136
+ } catch {
144
137
  return false;
145
138
  }
146
- const fileContentEtag = bufferToEtag(fileContentAsBuffer);
147
- if (fileContentEtag !== urlInfo.originalContentEtag) {
139
+ if (!fileStat) {
148
140
  urlInfo.onModified();
149
- // restore content to be able to compare it again later
150
- urlInfo.kitchen.urlInfoTransformer.setContent(
151
- urlInfo,
152
- String(fileContentAsBuffer),
153
- {
154
- contentEtag: fileContentEtag,
155
- },
156
- );
157
141
  return false;
158
142
  }
143
+ const fileStatKnown =
144
+ fileStatValidatedMap.get(urlInfo) || urlInfo.data.fileStat;
145
+ const fileUnchanged =
146
+ fileStatKnown &&
147
+ fileStatKnown.mtimeMs === fileStat.mtimeMs &&
148
+ fileStatKnown.size === fileStat.size;
149
+ if (!fileUnchanged) {
150
+ let fileContentAsBuffer;
151
+ try {
152
+ fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
153
+ } catch (e) {
154
+ if (e.code === "ENOENT") {
155
+ urlInfo.onModified();
156
+ return false;
157
+ }
158
+ return false;
159
+ }
160
+ const fileContentEtag = bufferToEtag(fileContentAsBuffer);
161
+ if (fileContentEtag !== urlInfo.originalContentEtag) {
162
+ fileStatValidatedMap.delete(urlInfo);
163
+ urlInfo.onModified();
164
+ // restore content to be able to compare it again later
165
+ urlInfo.kitchen.urlInfoTransformer.setContent(
166
+ urlInfo,
167
+ String(fileContentAsBuffer),
168
+ {
169
+ contentEtag: fileContentEtag,
170
+ },
171
+ );
172
+ return false;
173
+ }
174
+ fileStatValidatedMap.set(urlInfo, {
175
+ mtimeMs: fileStat.mtimeMs,
176
+ size: fileStat.size,
177
+ });
178
+ }
159
179
  }
160
180
  for (const implicitUrl of urlInfo.implicitUrlSet) {
161
181
  const implicitUrlInfo = urlInfo.graph.getUrlInfo(implicitUrl);
@@ -271,6 +291,40 @@ export const devServerPluginServeSourceFiles = ({
271
291
  const ifNoneMatch = request.headers["if-none-match"];
272
292
  const inlineParentUrlInfo = urlInfo.findParentIfInline();
273
293
  const urlInfoTargetedByCache = inlineParentUrlInfo || urlInfo;
294
+ // The content held in memory is the response when it is finalized
295
+ // and still valid. Content can be defined while a cook is still in
296
+ // flight (a file watcher invalidation re-cooking in the background,
297
+ // for instance): at that point it holds the raw fetched content,
298
+ // transformations not applied yet. Serving that would send an html
299
+ // without any of the injected scripts. An inline url info (a
300
+ // <script> inside an html) is cooked again whenever the html
301
+ // containing it is cooked: its content is as fresh as the html's,
302
+ // so both must be valid.
303
+ const hasFreshContent = (urlInfo) =>
304
+ urlInfo.content !== undefined &&
305
+ urlInfo.contentFinalized &&
306
+ urlInfo.isValid();
307
+ const memoryContentIsFresh = () =>
308
+ inlineParentUrlInfo
309
+ ? hasFreshContent(urlInfo) && hasFreshContent(inlineParentUrlInfo)
310
+ : hasFreshContent(urlInfo);
311
+ // a 304 goes through the hooks too: its headers stand for the
312
+ // cached response's, they must say the same
313
+ const augmentResponse = (response) => {
314
+ const augmentResponseInfo = {
315
+ ...kitchen.context,
316
+ reference,
317
+ urlInfo,
318
+ };
319
+ kitchen.jsenvPluginsController.callHooks(
320
+ "augmentResponse",
321
+ augmentResponseInfo,
322
+ (returnValue) => {
323
+ response = composeTwoResponses(response, returnValue);
324
+ },
325
+ );
326
+ return response;
327
+ };
274
328
  const respondWithNotModified = () => {
275
329
  const headers = {
276
330
  "cache-control": `private,max-age=0,must-revalidate`,
@@ -280,23 +334,21 @@ export const devServerPluginServeSourceFiles = ({
280
334
  headers[key] = urlInfo.headers[key];
281
335
  }
282
336
  });
283
- return {
337
+ return augmentResponse({
284
338
  status: 304,
285
339
  headers,
286
- };
340
+ });
287
341
  };
288
342
 
289
343
  try {
290
- // an inline url info is cooked again every time the file containing it
291
- // is cooked, so its content is only known after cooking; its etag is
292
- // compared below, once cooked
293
- if (!urlInfo.error && ifNoneMatch && !inlineParentUrlInfo) {
344
+ if (!urlInfo.error && ifNoneMatch) {
294
345
  const [clientOriginalContentEtag, clientContentEtag] =
295
346
  ifNoneMatch.split("_");
296
347
  if (
297
- urlInfo.originalContentEtag === clientOriginalContentEtag &&
348
+ urlInfoTargetedByCache.originalContentEtag ===
349
+ clientOriginalContentEtag &&
298
350
  urlInfo.contentEtag === clientContentEtag &&
299
- urlInfo.isValid()
351
+ memoryContentIsFresh()
300
352
  ) {
301
353
  return respondWithNotModified();
302
354
  }
@@ -314,18 +366,9 @@ export const devServerPluginServeSourceFiles = ({
314
366
  // client etag to match).
315
367
  const servableFromMemory =
316
368
  !urlInfo.error &&
317
- !inlineParentUrlInfo &&
318
369
  !urlInfo.response &&
319
- urlInfo.content !== undefined &&
320
- // content can be defined while a cook is still in flight (a file
321
- // watcher invalidation re-cooking in the background, for
322
- // instance): at that point it holds the raw fetched content,
323
- // transformations not applied yet. Serving that would send an
324
- // html without any of the injected scripts. Only finalized
325
- // content is a complete response; anything else must go through
326
- // cook() below, which joins the pending cook (see debounceCook).
327
- urlInfo.contentFinalized &&
328
370
  !cacheIsDisabledInResponseHeader(urlInfo) &&
371
+ !cacheIsDisabledInResponseHeader(urlInfoTargetedByCache) &&
329
372
  // a "?hot" request exists to bypass every cache, this one
330
373
  // included: it must be cooked, because cooking is what rewrites
331
374
  // its references so "?hot" cascades to the modified files below
@@ -344,7 +387,7 @@ export const devServerPluginServeSourceFiles = ({
344
387
  // signal registry throwing on duplicate ids). Re-cooking under
345
388
  // the normal request rewrites the references clean.
346
389
  !urlInfo.contentCookedForHotRequest &&
347
- urlInfo.isValid();
390
+ memoryContentIsFresh();
348
391
  if (!servableFromMemory) {
349
392
  await urlInfo.cook({ request, reference });
350
393
  urlInfo.contentCookedForHotRequest =
@@ -402,19 +445,7 @@ export const devServerPluginServeSourceFiles = ({
402
445
  ? { "served from memory cache": null }
403
446
  : urlInfo.timing,
404
447
  };
405
- const augmentResponseInfo = {
406
- ...kitchen.context,
407
- reference,
408
- urlInfo,
409
- };
410
- kitchen.jsenvPluginsController.callHooks(
411
- "augmentResponse",
412
- augmentResponseInfo,
413
- (returnValue) => {
414
- response = composeTwoResponses(response, returnValue);
415
- },
416
- );
417
- return response;
448
+ return augmentResponse(response);
418
449
  } catch (error) {
419
450
  const originalError = error ? error.cause || error : error;
420
451
  if (originalError.asResponse) {
@@ -492,6 +523,14 @@ export const devServerPluginServeSourceFiles = ({
492
523
  "cache-control": "no-store",
493
524
  },
494
525
  };
526
+ } finally {
527
+ // What the request put on the url info context is for this
528
+ // request only: a cook happening later for another reason (the
529
+ // html holding an inline script is cooked again) must not see a
530
+ // stale "requestedUrl" and take the inline script for a direct
531
+ // request, which would make it pick the content of a previous
532
+ // reference (see jsenv:inline_content_fetcher).
533
+ forgetRequestFromContext(urlInfo.context);
495
534
  }
496
535
  },
497
536
  },
@@ -501,6 +540,15 @@ export const devServerPluginServeSourceFiles = ({
501
540
  return [devServerPluginRoutes, ...devServerJsenvPluginStore.allServerPlugins];
502
541
  };
503
542
 
543
+ const forgetRequestFromContext = (context) => {
544
+ // own properties only: what is inherited from the owner context stays
545
+ for (const key of ["request", "requestedUrl", "reference"]) {
546
+ if (Object.hasOwn(context, key)) {
547
+ delete context[key];
548
+ }
549
+ }
550
+ };
551
+
504
552
  const cacheIsDisabledInResponseHeader = (urlInfo) => {
505
553
  return (
506
554
  urlInfo.headers["cache-control"] === "no-store" ||
@@ -8,8 +8,13 @@ import {
8
8
  serverPluginCORS,
9
9
  startServer,
10
10
  } from "@jsenv/server";
11
- import { urlIsOrIsInsideOf, urlToRelativeUrl } from "@jsenv/urls";
11
+ import {
12
+ urlIsOrIsInsideOf,
13
+ urlToFileSystemPath,
14
+ urlToRelativeUrl,
15
+ } from "@jsenv/urls";
12
16
  import { existsSync } from "node:fs";
17
+ import { pathToFileURL } from "node:url";
13
18
 
14
19
  import { defaultRuntimeCompat } from "../build/build_params.js";
15
20
  import { createEventEmitter } from "../helpers/event_emitter.js";
@@ -50,6 +55,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
50
55
  * @param {boolean|object} [params.serverTiming={ minDuration: 0.5 }] - server-timing response headers; `minDuration` (ms) drops entries that took less (0 when run by the test plan, so tests see every entry).
51
56
  * @param {boolean|object} [params.ribbon=true] - The "ribbon" overlay marking the page as non-production. As an object: `text` (defaults to `"DEV"`), `color` (background, defaults to `"orange"`), `textColor` (defaults to dark or light depending on `color`), `href` (turns the ribbon into a link; only then does it capture clicks), `target` (defaults to `"_blank"`), `position` (`"top-right"` (default), `"top-left"`, `"bottom-right"`, `"bottom-left"` draw a diagonal corner ribbon; `"top"`, `"bottom"` draw a full width band), `htmlInclude` (defaults to `"/**\/*.html"`).
52
57
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
58
+ * @param {boolean} [params.modulepreload=false] - Send `Link: <url>; rel=modulepreload` response headers listing the static import graph of a page (as far as the graph knows it). Off by default, to enable once the server runs on http/2 or http/3: over http/1.1 the preloads take the 6 connections per origin ahead of the render-blocking scripts, and pages get slower (the why, with measures, in jsenv_plugin_modulepreload.js).
53
59
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
54
60
  * @param {object} [params.injections] - Values to inject into files, as `{ urlPattern: getInjections }`. Keys are url patterns relative to sourceDirectoryUrl (`"./index.html"`, `"**\/*.js"`), values are functions receiving `urlInfo` and returning (or resolving to) an object of placeholders to replace, named `__LIKE_THIS__` by convention. In JS the value is injected as a JS literal (a string brings its own quotes), everywhere else as-is so it can be concatenated: `href="__BACKEND_URL__/users/me"`. An html url pattern also covers what is inlined in that html, so `<script>window.backendUrl = __BACKEND_URL__;</script>` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`.
55
61
  * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
@@ -91,6 +97,7 @@ export const startDevServer = async ({
91
97
  sourceFilesConfig = {},
92
98
  clientAutoreload = true,
93
99
  clientAutoreloadOnServerRestart = true,
100
+ modulepreload = false,
94
101
  // server-timing response headers: devtools show how the time to answer is
95
102
  // spent (cook measures come from the kitchen, see urlInfo.timing). Entries
96
103
  // under minDuration are dropped so a human reads the measures that matter;
@@ -181,9 +188,12 @@ export const startDevServer = async ({
181
188
  }
182
189
 
183
190
  const logger = createLogger({ logLevel });
184
- const startDevServerTask = createTaskLog("start dev server", {
185
- disabled: !logger.levels.info,
186
- });
191
+ const startDevServerTask = createTaskLog(
192
+ `start dev server for ${humanizeSourceDirectory(sourceDirectoryUrl)}`,
193
+ {
194
+ disabled: !logger.levels.info,
195
+ },
196
+ );
187
197
 
188
198
  const serverStopCallbackSet = new Set();
189
199
  const serverStopAbortController = new AbortController();
@@ -262,6 +272,7 @@ export const startDevServer = async ({
262
272
 
263
273
  clientAutoreload,
264
274
  clientAutoreloadOnServerRestart,
275
+ modulepreload,
265
276
  dependencyStatus: {
266
277
  dependencyProblemEventEmitter,
267
278
  getDependencyProblems: dependencyWatcher.getProblems,
@@ -366,3 +377,12 @@ export const startDevServer = async ({
366
377
  kitchenCache,
367
378
  };
368
379
  };
380
+
381
+ // "./src/" when inside the current working directory, the full path otherwise
382
+ const humanizeSourceDirectory = (sourceDirectoryUrl) => {
383
+ const cwdUrl = pathToFileURL(`${process.cwd()}/`).href;
384
+ if (urlIsOrIsInsideOf(sourceDirectoryUrl, cwdUrl)) {
385
+ return `./${urlToRelativeUrl(sourceDirectoryUrl, cwdUrl)}`;
386
+ }
387
+ return urlToFileSystemPath(sourceDirectoryUrl);
388
+ };
@@ -2,24 +2,27 @@ import {
2
2
  lookupPackageDirectory,
3
3
  registerDirectoryLifecycle,
4
4
  } from "@jsenv/filesystem";
5
- import { urlToRelativeUrl } from "@jsenv/urls";
5
+ import { urlIsOrIsInsideOf, urlToRelativeUrl } from "@jsenv/urls";
6
6
  import { readFileSync } from "node:fs";
7
7
 
8
8
  export const getDirectoryWatchPatterns = (
9
9
  directoryUrl,
10
10
  watchedDirectoryUrl,
11
- { sourceFilesConfig },
11
+ { sourceFilesConfig, defaultPatterns = true },
12
12
  ) => {
13
13
  const directoryUrlRelativeToWatchedDirectory = urlToRelativeUrl(
14
14
  directoryUrl,
15
15
  watchedDirectoryUrl,
16
16
  );
17
- const watchPatterns = {
18
- [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
19
- [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
20
- [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
21
- [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
22
- };
17
+ const watchPatterns = {};
18
+ if (defaultPatterns) {
19
+ Object.assign(watchPatterns, {
20
+ [`${directoryUrlRelativeToWatchedDirectory}**/*`]: true, // by default watch everything inside the source directory
21
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*`]: false, // file starting with a dot -> do not watch
22
+ [`${directoryUrlRelativeToWatchedDirectory}**/.*/`]: false, // directory starting with a dot -> do not watch
23
+ [`${directoryUrlRelativeToWatchedDirectory}**/node_modules/`]: false, // node_modules directory -> do not watch
24
+ });
25
+ }
23
26
  for (const key of Object.keys(sourceFilesConfig)) {
24
27
  watchPatterns[`${directoryUrlRelativeToWatchedDirectory}${key}`] =
25
28
  sourceFilesConfig[key];
@@ -40,11 +43,12 @@ export const watchSourceFiles = (
40
43
  // And jsenv should not consider these as source files and watch them (to not hurt performances)
41
44
  const watchPatterns = {};
42
45
  let watchedDirectoryUrl = "";
43
- const addDirectoryToWatch = (directoryUrl) => {
46
+ const addDirectoryToWatch = (directoryUrl, { defaultPatterns } = {}) => {
44
47
  Object.assign(
45
48
  watchPatterns,
46
49
  getDirectoryWatchPatterns(directoryUrl, watchedDirectoryUrl, {
47
50
  sourceFilesConfig,
51
+ defaultPatterns,
48
52
  }),
49
53
  );
50
54
  };
@@ -76,7 +80,6 @@ export const watchSourceFiles = (
76
80
  },
77
81
  },
78
82
  );
79
- stopWatchingSourceFiles.watchPatterns = watchPatterns;
80
83
  return stopWatchingSourceFiles;
81
84
  };
82
85
 
@@ -96,16 +99,18 @@ export const watchSourceFiles = (
96
99
  }
97
100
  watchedDirectoryUrl = packageDirectoryUrl;
98
101
  for (const workspace of workspaces) {
99
- if (workspace.endsWith("*")) {
100
- const workspaceDirectoryUrl = new URL(
101
- workspace.slice(0, -1),
102
- packageDirectoryUrl,
103
- );
104
- addDirectoryToWatch(workspaceDirectoryUrl);
105
- } else {
106
- const workspaceRelativeUrl = new URL(workspace, packageDirectoryUrl);
107
- addDirectoryToWatch(workspaceRelativeUrl);
108
- }
102
+ const workspaceDirectoryUrl = workspace.endsWith("*")
103
+ ? new URL(workspace.slice(0, -1), packageDirectoryUrl)
104
+ : new URL(workspace, packageDirectoryUrl);
105
+ addDirectoryToWatch(workspaceDirectoryUrl, {
106
+ // The source directory patterns already cover a workspace inside it.
107
+ // Every pattern is tested against every file found while watching, so
108
+ // the same patterns rooted at each workspace only multiply that work.
109
+ defaultPatterns: !urlIsOrIsInsideOf(
110
+ workspaceDirectoryUrl,
111
+ sourceDirectoryUrl,
112
+ ),
113
+ });
109
114
  }
110
115
  // we are updating the root directory
111
116
  // we must make the patterns relative to source directory relative to the new root directory
@@ -634,20 +634,37 @@ const checkForDependencyRemovalEffects = (reference) => {
634
634
  };
635
635
 
636
636
  const traceFromUrlSite = (urlSite) => {
637
- const codeFrame = urlSite.content
638
- ? generateContentFrame({
639
- content: urlSite.content,
640
- line: urlSite.line,
641
- column: urlSite.column,
642
- })
643
- : "";
644
- return {
645
- codeFrame,
646
- message: stringifyUrlSite(urlSite),
647
- url: urlSite.url,
648
- line: urlSite.line,
649
- column: urlSite.column,
637
+ const { url, line, column, content } = urlSite;
638
+ const trace = { url, line, column };
639
+ // A file references many urls and an error on one of them is rare: the code
640
+ // frame (and the message embedding it) is built the first time it is read,
641
+ // not for every reference found.
642
+ defineLazyProperty(trace, "codeFrame", () =>
643
+ content ? generateContentFrame({ content, line, column }) : "",
644
+ );
645
+ defineLazyProperty(trace, "message", () => stringifyUrlSite(urlSite));
646
+ return trace;
647
+ };
648
+
649
+ const defineLazyProperty = (object, property, compute) => {
650
+ const setValue = (value) => {
651
+ Object.defineProperty(object, property, {
652
+ enumerable: true,
653
+ configurable: true,
654
+ writable: true,
655
+ value,
656
+ });
650
657
  };
658
+ Object.defineProperty(object, property, {
659
+ enumerable: true,
660
+ configurable: true,
661
+ get: () => {
662
+ const value = compute();
663
+ setValue(value);
664
+ return value;
665
+ },
666
+ set: setValue,
667
+ });
651
668
  };
652
669
 
653
670
  const adjustUrlSite = (urlInfo, { url, line, column }) => {
@@ -185,7 +185,6 @@ const createUrlInfo = (url, context) => {
185
185
  dereferencedTimestamp: 0,
186
186
  originalContentEtag: null,
187
187
  contentEtag: null,
188
- isWatched: false,
189
188
  isValid: () => false,
190
189
  data: {}, // plugins can put whatever they want here
191
190
  referenceToOthersSet: new Set(),
@@ -23,7 +23,11 @@
23
23
  *
24
24
  */
25
25
 
26
- import { applyBabelPlugins } from "@jsenv/ast";
26
+ import {
27
+ applyBabelPlugins,
28
+ getImportMetaPropertyName,
29
+ visitJsAstUntil,
30
+ } from "@jsenv/ast";
27
31
 
28
32
  export const jsenvPluginImportMetaCss = () => {
29
33
  const importMetaCssDevClientFileUrl = import.meta
@@ -47,15 +51,10 @@ export const jsenvPluginImportMetaCss = () => {
47
51
  if (!urlInfo.content.includes("import.meta.css")) {
48
52
  return null;
49
53
  }
50
- const { metadata } = await applyBabelPlugins({
51
- babelPlugins: [babelPluginMetadataUsesImportMetaCss],
52
- input: urlInfo.content,
53
- inputIsJsModule: true,
54
- inputUrl: urlInfo.originalUrl,
55
- outputUrl: urlInfo.generatedUrl,
54
+ const importMetaCssNode = visitJsAstUntil(urlInfo.contentAst, {
55
+ MemberExpression: (node) => getImportMetaPropertyName(node) === "css",
56
56
  });
57
- const { usesImportMetaCss } = metadata;
58
- if (!usesImportMetaCss) {
57
+ if (!importMetaCssNode) {
59
58
  return null;
60
59
  }
61
60
  if (urlInfo.context.build) {
@@ -135,37 +134,6 @@ const babelPluginRewriteImportMetaCssAssignment = (
135
134
  };
136
135
  };
137
136
 
138
- const babelPluginMetadataUsesImportMetaCss = () => {
139
- return {
140
- name: "metadata-uses-import-meta-css",
141
- visitor: {
142
- Program(programPath, state) {
143
- let usesImportMetaCss = false;
144
- programPath.traverse({
145
- MemberExpression(path) {
146
- const { node } = path;
147
- const { object } = node;
148
- if (object.type !== "MetaProperty") {
149
- return;
150
- }
151
- const { property: objectProperty } = object;
152
- if (objectProperty.name !== "meta") {
153
- return;
154
- }
155
- const { property } = node;
156
- const { name } = property;
157
- if (name === "css") {
158
- usesImportMetaCss = true;
159
- path.stop();
160
- }
161
- },
162
- });
163
- state.file.metadata.usesImportMetaCss = usesImportMetaCss;
164
- },
165
- },
166
- };
167
- };
168
-
169
137
  const injectImportMetaCss = (
170
138
  urlInfo,
171
139
  { content, importFrom, importName, importAs, hot },
@@ -0,0 +1,70 @@
1
+ import { getImportMetaPropertyName, visitJsAst } from "@jsenv/ast";
2
+
3
+ export const analyzeImportMetaHot = (ast) => {
4
+ const importMetaHotNodes = [];
5
+ let hotDecline = false;
6
+ let hotAcceptSelf = false;
7
+ let hotAcceptSpecifiers = [];
8
+ visitJsAst(ast, {
9
+ MemberExpression: (node) => {
10
+ if (getImportMetaPropertyName(node) === "hot") {
11
+ importMetaHotNodes.push(node);
12
+ }
13
+ },
14
+ CallExpression: (node) => {
15
+ const methodName = getImportMetaHotMethodName(node);
16
+ if (methodName === "accept") {
17
+ const args = node.arguments;
18
+ if (args.length === 0) {
19
+ hotAcceptSelf = true;
20
+ return;
21
+ }
22
+ const [firstArg] = args;
23
+ if (isStringLiteral(firstArg)) {
24
+ hotAcceptSpecifiers = [firstArg.value];
25
+ return;
26
+ }
27
+ if (firstArg.type === "ArrayExpression") {
28
+ hotAcceptSpecifiers = firstArg.elements.map((element) => {
29
+ if (!isStringLiteral(element)) {
30
+ throw new Error(
31
+ `all array elements must be strings in "import.meta.hot.accept(array)"`,
32
+ );
33
+ }
34
+ return element.value;
35
+ });
36
+ return;
37
+ }
38
+ // accept first arg can be "anything" such as
39
+ // `const cb = () => {}; import.meta.hot.accept(cb)`
40
+ hotAcceptSelf = true;
41
+ return;
42
+ }
43
+ if (methodName === "decline") {
44
+ hotDecline = true;
45
+ }
46
+ },
47
+ });
48
+ return {
49
+ importMetaHotNodes,
50
+ hotDecline,
51
+ hotAcceptSelf,
52
+ hotAcceptSpecifiers,
53
+ };
54
+ };
55
+
56
+ // "import.meta.hot.<method>(...)"
57
+ const getImportMetaHotMethodName = (callNode) => {
58
+ const { callee } = callNode;
59
+ if (callee.type !== "MemberExpression" || callee.computed) {
60
+ return null;
61
+ }
62
+ if (getImportMetaPropertyName(callee.object) !== "hot") {
63
+ return null;
64
+ }
65
+ return callee.property.name;
66
+ };
67
+
68
+ const isStringLiteral = (node) => {
69
+ return node.type === "Literal" && typeof node.value === "string";
70
+ };