@jsenv/core 41.4.0 → 41.4.2

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,6 +1,6 @@
1
1
  import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
2
  import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from "node:fs";
3
- import { registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, urlToRelativeUrl, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, 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";
3
+ import { registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, urlToRelativeUrl, 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";
4
4
  import { createPluginsController } from "@jsenv/server/src/plugins_controller.js";
5
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
6
  import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
@@ -752,6 +752,15 @@ const parseUserAgentHeader = (userAgent) => {
752
752
  * cooked one of our pages and reports back; we can only see clients that execute
753
753
  * our injected script, not arbitrary HTTP clients of the dev server.
754
754
  *
755
+ * The MAIN client — the machine the dev server runs on, browsing via
756
+ * localhost — is listed but not watched: it reports presence only (heartbeat,
757
+ * tabs), no console logs and no activity. Its devtools are already at hand,
758
+ * and the person reading the dashboard is that client. Watching is for the
759
+ * clients that reach the server over the network (a phone on the LAN address,
760
+ * acceptAnyIp: true), and every client record carries the ip it reports from —
761
+ * that ip is what tells the two kinds apart. See isLocalClient in
762
+ * client_reporter.js (the client side of the same rule).
763
+ *
755
764
  * Transport reuses what the dev server already has instead of opening a second
756
765
  * websocket:
757
766
  * - server → clients uses the jsenv "server events" channel (the same websocket
@@ -821,6 +830,18 @@ const ACTIVITY_MAX_PER_CLIENT = 50;
821
830
  const INACTIVITY_MS = 60 * 1000;
822
831
  // A tab not heard from for this long is considered closed and dropped.
823
832
  const TAB_TTL_MS = 2 * 60 * 1000;
833
+ // What a browser sends is not to be trusted with the server's memory: a log
834
+ // line is cut at the source too (see client_reporter.js), and cut again here so
835
+ // a hand-made POST cannot park megabytes in the buffer — which the
836
+ // server-events history would then keep a second time.
837
+ // A little above the client's own cut, so the "… (N more characters)" it adds
838
+ // survives this one — the reader needs to know something was left out.
839
+ const LOG_TEXT_MAX = 10_064;
840
+ // Clients seen since the server started, at most. One per browser profile in
841
+ // practice, but every private window and every cleared storage adds one that
842
+ // never comes back, each carrying its own buffer — so the oldest ones that are
843
+ // no longer online are let go.
844
+ const CLIENT_MAX = 50;
824
845
 
825
846
  // The dev server already parses browser + version from a request (sec-ch-ua or
826
847
  // user-agent) via getRuntimeFromRequest; it does not cover the OS, so this fills
@@ -861,7 +882,14 @@ const osFromUserAgent = (userAgent) => {
861
882
  return { name: "unknown", version: "" };
862
883
  };
863
884
 
864
- const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
885
+ // The machine the dev server runs on, talking to itself: the main client. A
886
+ // phone (or any other device) reaching the server over the network reports
887
+ // with the machine's LAN address instead — which is why the ip is kept on
888
+ // every client record: it is what tells the main client from the others.
889
+ const isLocalIp = (ip) =>
890
+ ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
891
+
892
+ const jsenvPluginClientMonitoring = () => {
865
893
  // id -> client record
866
894
  const clients = new Map();
867
895
 
@@ -887,6 +915,9 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
887
915
  userAgent,
888
916
  runtime: runtimeFromRequest(request),
889
917
  os: osFromUserAgent(userAgent),
918
+ // The address the reports come from; request.ipForwarded when a proxy
919
+ // sits in between, so the client's own address is kept, not the proxy's.
920
+ ip: request.ipForwarded || request.ip,
890
921
  firstSeen: now(),
891
922
  lastSeen: now(),
892
923
  everSeen: false,
@@ -898,14 +929,38 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
898
929
  tabs: new Map(),
899
930
  };
900
931
  clients.set(id, client);
901
- } else if (userAgent && userAgent !== client.userAgent) {
902
- client.userAgent = userAgent;
903
- client.runtime = runtimeFromRequest(request);
904
- client.os = osFromUserAgent(userAgent);
932
+ } else {
933
+ if (userAgent && userAgent !== client.userAgent) {
934
+ client.userAgent = userAgent;
935
+ client.runtime = runtimeFromRequest(request);
936
+ client.os = osFromUserAgent(userAgent);
937
+ }
938
+ // A device changes address (wifi drop, DHCP): the record follows it.
939
+ const ip = request.ipForwarded || request.ip;
940
+ if (ip && ip !== client.ip) {
941
+ client.ip = ip;
942
+ }
905
943
  }
906
944
  return client;
907
945
  };
908
946
 
947
+ // The oldest silent ones first: a client still reporting is one someone is
948
+ // looking at, whatever its age.
949
+ const pruneClients = () => {
950
+ if (clients.size <= CLIENT_MAX) {
951
+ return;
952
+ }
953
+ const droppable = [...clients.values()]
954
+ .filter((client) => !isOnline(client))
955
+ .sort((a, b) => a.lastSeen - b.lastSeen);
956
+ for (const client of droppable) {
957
+ if (clients.size <= CLIENT_MAX) {
958
+ return;
959
+ }
960
+ clients.delete(client.id);
961
+ }
962
+ };
963
+
909
964
  const pruneLogs = (client) => {
910
965
  const cutoff = now() - LOG_TTL_MS;
911
966
  while (client.logs.length && client.logs[0].ts < cutoff) {
@@ -997,6 +1052,10 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
997
1052
  // parsed { name, version } so pages can show a friendly browser/OS
998
1053
  runtime: client.runtime,
999
1054
  os: client.os,
1055
+ ip: client.ip,
1056
+ // The main client — the machine the dev server runs on, talking to
1057
+ // itself over localhost.
1058
+ local: isLocalIp(client.ip),
1000
1059
  firstSeen: client.firstSeen,
1001
1060
  lastSeen: client.lastSeen,
1002
1061
  online: isOnline(client),
@@ -1053,6 +1112,7 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
1053
1112
 
1054
1113
  updateTab(client, body.tab);
1055
1114
  pruneTabs(client);
1115
+ pruneClients();
1056
1116
 
1057
1117
  if (firstEver) {
1058
1118
  sendClientHere({ reason: "new", client: serializeClient(client) });
@@ -1073,13 +1133,22 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
1073
1133
  for (const rawLog of logs) {
1074
1134
  const entry = {
1075
1135
  level: rawLog.level || "log",
1076
- text: typeof rawLog.text === "string" ? rawLog.text : "",
1136
+ text:
1137
+ typeof rawLog.text === "string"
1138
+ ? rawLog.text.slice(0, LOG_TEXT_MAX)
1139
+ : "",
1077
1140
  ts: rawLog.ts || now(),
1078
1141
  };
1079
1142
  // styled console segments ({ text, css } per %c run), when present, so a
1080
1143
  // monitor can render colors; the plain text stays for copy/paste.
1081
1144
  if (Array.isArray(rawLog.segments)) {
1082
- entry.segments = rawLog.segments;
1145
+ entry.segments = rawLog.segments.map((segment) => ({
1146
+ ...segment,
1147
+ text:
1148
+ typeof segment?.text === "string"
1149
+ ? segment.text.slice(0, LOG_TEXT_MAX)
1150
+ : "",
1151
+ }));
1083
1152
  }
1084
1153
  client.logs.push(entry);
1085
1154
  sendClientLog({ clientId, ...entry });
@@ -1103,57 +1172,6 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
1103
1172
  };
1104
1173
  };
1105
1174
 
1106
- // The .html pages served under the source directory, as server-relative URLs,
1107
- // so the dashboard can offer them as "navigate this client to…" targets. Skips
1108
- // node_modules, build output and dot-dirs. Cached briefly — one scan per
1109
- // dialog-open is plenty; it needn't be fresh to the second.
1110
- const PAGE_SCAN_TTL_MS = 3000;
1111
- const PAGE_SCAN_SKIP_DIRS = new Set([
1112
- "node_modules",
1113
- "dist",
1114
- "git_ignored",
1115
- "old",
1116
- ]);
1117
- let pageScanCache = null;
1118
- let pageScanAt = 0;
1119
- const listNavigablePages = () => {
1120
- if (!rootDirectoryUrl) {
1121
- return [];
1122
- }
1123
- if (pageScanCache && now() - pageScanAt < PAGE_SCAN_TTL_MS) {
1124
- return pageScanCache;
1125
- }
1126
- const pages = [];
1127
- const walk = (dirUrl) => {
1128
- let entries;
1129
- try {
1130
- entries = readdirSync(new URL(dirUrl), { withFileTypes: true });
1131
- } catch {
1132
- return;
1133
- }
1134
- for (const entry of entries) {
1135
- const name = entry.name;
1136
- if (name[0] === ".") {
1137
- continue; // .git, .agents, dot-files…
1138
- }
1139
- if (entry.isDirectory()) {
1140
- if (!PAGE_SCAN_SKIP_DIRS.has(name)) {
1141
- walk(`${dirUrl}${name}/`);
1142
- }
1143
- } else if (name.endsWith(".html")) {
1144
- pages.push(
1145
- `/${urlToRelativeUrl(`${dirUrl}${name}`, rootDirectoryUrl)}`,
1146
- );
1147
- }
1148
- }
1149
- };
1150
- walk(String(rootDirectoryUrl));
1151
- pages.sort();
1152
- pageScanCache = pages;
1153
- pageScanAt = now();
1154
- return pages;
1155
- };
1156
-
1157
1175
  // Desktop pilots a client: validate a { clientId, tabId?, type, url? } command
1158
1176
  // and broadcast it as a "client_command" server event. The reporter runs it
1159
1177
  // only if the id (and tabId, when given) matches. type: "navigate" | "reload".
@@ -1278,14 +1296,6 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
1278
1296
  declarationSource: import.meta.url,
1279
1297
  fetch: (request) => ingestCommand(request),
1280
1298
  },
1281
- {
1282
- endpoint: "GET /.internal/clients/pages.json",
1283
- description:
1284
- "The .html pages under the source directory, offered as navigation targets for a client.",
1285
- availableMediaTypes: ["application/json"],
1286
- declarationSource: import.meta.url,
1287
- fetch: () => jsonResponse(listNavigablePages()),
1288
- },
1289
1299
  {
1290
1300
  endpoint: "GET /.internal/clients.json",
1291
1301
  description: "Snapshot of every client seen since the server started.",
@@ -1309,6 +1319,48 @@ const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
1309
1319
  };
1310
1320
  };
1311
1321
 
1322
+ /*
1323
+ * cmd+K (ctrl+K elsewhere) on any page the dev server serves opens a list of
1324
+ * the .html files it serves, filter as you type, Enter to go there.
1325
+ *
1326
+ * The list is the one the filesystem plugin already publishes for everyone
1327
+ * (GET /.internal/pages.json, see protocol_file/html_pages.js) — this only adds
1328
+ * the way to reach it without leaving the page one is working on.
1329
+ *
1330
+ * Registered with the dev server rather than among the core plugins, next to
1331
+ * the other things that inject a client script: the script it adds is a
1332
+ * reference like any other, and it has to be added while references are still
1333
+ * being resolved — added later it stays a file:// url the browser refuses.
1334
+ *
1335
+ * A shortcut on every page is a shortcut taken from every page, so the page
1336
+ * comes first: the key is watched on the document, in the bubble phase, and
1337
+ * anything that called preventDefault on its way there keeps it. A page with
1338
+ * its own cmd+K owes nothing to this one.
1339
+ */
1340
+
1341
+
1342
+ const clientFileUrl$1 = new URL("../js/page_switcher.js", import.meta.url)
1343
+ .href;
1344
+
1345
+ const jsenvPluginPageSwitcher = () => {
1346
+ return {
1347
+ name: "jsenv:page_switcher",
1348
+ // Dev only: it is a way around the source tree, which a built app has no
1349
+ // business carrying.
1350
+ appliesDuring: "dev",
1351
+ transformUrlContent: {
1352
+ html: (urlInfo) => {
1353
+ const htmlAst = parseHtml({ html: urlInfo.content, url: urlInfo.url });
1354
+ injectJsenvScript(htmlAst, {
1355
+ src: clientFileUrl$1,
1356
+ pluginName: "jsenv:page_switcher",
1357
+ });
1358
+ return stringifyHtmlAst(htmlAst);
1359
+ },
1360
+ },
1361
+ };
1362
+ };
1363
+
1312
1364
  /*
1313
1365
  * https://github.com/parcel-bundler/parcel/blob/v2/packages/transformers/css/src/CSSTransformer.js
1314
1366
  */
@@ -3009,6 +3061,11 @@ const addRelationshipWithPackageJson = ({
3009
3061
  String(packageJsonContentAsBuffer),
3010
3062
  );
3011
3063
  }
3064
+ // Checked on disk at every validation rather than trusted to the watcher:
3065
+ // what this file decides (the package version, hence the ?v= importers
3066
+ // embed) ends up in the browser's immutable cache, so a request racing the
3067
+ // watcher must never be answered from a stale package.json.
3068
+ packageJsonReference.urlInfo.revalidateOnFileSystem = true;
3012
3069
  };
3013
3070
 
3014
3071
  const createResolverWithFallbackOnError = (mainResolver, fallbackResolver) => {
@@ -3262,6 +3319,127 @@ const FILE_AND_SERVER_URLS_CONVERTER = {
3262
3319
  },
3263
3320
  };
3264
3321
 
3322
+ /*
3323
+ * The .html files under the served directory, as urls one can navigate to.
3324
+ *
3325
+ * Lives here, next to the plugin that owns the filesystem, because more than
3326
+ * one feature wants the same list: the client dashboard sends a browser to one
3327
+ * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks
3328
+ * gets the same answer.
3329
+ *
3330
+ * Each page comes with what kind of page it is, read from where it sits and
3331
+ * what it is called — the two conventions this repo already follows:
3332
+ * - "experiment": something tried out, under a lab/ directory or named
3333
+ * *_experiment.html;
3334
+ * - "demo": something shown, under a demos/ directory or named *_demo.html;
3335
+ * - "page": everything else.
3336
+ *
3337
+ * Scanned on demand rather than watched: the list is asked for when a human
3338
+ * opens a picker, which is rare and never on a hot path, and a short cache is
3339
+ * enough to keep a burst of asks from walking the tree twice.
3340
+ */
3341
+
3342
+
3343
+ const SCAN_TTL_MS = 5000;
3344
+
3345
+ // What to walk and what to skip, in the shape @jsenv/url-meta reads: the last
3346
+ // matching pattern wins, so the exclusions come after the "every .html" rule.
3347
+ // Dependencies, build output and jsenv's own caches hold no page worth going to.
3348
+ const HTML_PAGE_ASSOCIATIONS = {
3349
+ page: {
3350
+ "**/*.html": true,
3351
+ "**/.*/": false,
3352
+ "**/node_modules/": false,
3353
+ "**/dist/": false,
3354
+ "**/build/": false,
3355
+ "**/coverage/": false,
3356
+ "**/git_ignored/": false,
3357
+ "**/old/": false,
3358
+ },
3359
+ experiment: {
3360
+ "**/lab/**/*.html": true,
3361
+ "**/*_experiment.html": true,
3362
+ },
3363
+ demo: {
3364
+ "**/demos/**/*.html": true,
3365
+ "**/*_demo.html": true,
3366
+ },
3367
+ };
3368
+
3369
+ // An experiment inside a demos/ directory is an experiment: the more specific
3370
+ // of the two wins, and "shown" is the weaker claim.
3371
+ const readKind = (meta) => {
3372
+ if (meta.experiment) {
3373
+ return "experiment";
3374
+ }
3375
+ if (meta.demo) {
3376
+ return "demo";
3377
+ }
3378
+ return "page";
3379
+ };
3380
+
3381
+ // Which package a page belongs to: the nearest directory above it holding a
3382
+ // package.json, said as a url so whoever draws a tree can mark that very node.
3383
+ // Not the root itself — everything is under it, and "the whole repo" is not a
3384
+ // package one distinguishes from another. Memoized per directory: a scan asks
3385
+ // the same question once per file and there are hundreds of them.
3386
+ const createPackageDirectoryFinder = (rootDirectoryUrl) => {
3387
+ const cache = new Map();
3388
+ const find = (directoryUrl) => {
3389
+ if (cache.has(directoryUrl)) {
3390
+ return cache.get(directoryUrl);
3391
+ }
3392
+ let result = null;
3393
+ if (directoryUrl.length > String(rootDirectoryUrl).length) {
3394
+ result = existsSync(new URL("./package.json", directoryUrl))
3395
+ ? directoryUrl
3396
+ : find(new URL("../", directoryUrl).href);
3397
+ }
3398
+ cache.set(directoryUrl, result);
3399
+ return result;
3400
+ };
3401
+ return find;
3402
+ };
3403
+
3404
+ const createHtmlPageLister = ({ rootDirectoryUrl }) => {
3405
+ let cache = null;
3406
+ let cachedAt = 0;
3407
+
3408
+ return async () => {
3409
+ if (!rootDirectoryUrl) {
3410
+ return [];
3411
+ }
3412
+ const now = Date.now();
3413
+ if (cache && now - cachedAt < SCAN_TTL_MS) {
3414
+ return cache;
3415
+ }
3416
+ const fileResultArray = await collectFiles({
3417
+ directoryUrl: rootDirectoryUrl,
3418
+ associations: HTML_PAGE_ASSOCIATIONS,
3419
+ predicate: (meta) => Boolean(meta.page),
3420
+ });
3421
+ const findPackageDirectory = createPackageDirectoryFinder(rootDirectoryUrl);
3422
+ const pages = fileResultArray.map(({ relativeUrl, meta }) => {
3423
+ const fileUrl = new URL(relativeUrl, rootDirectoryUrl).href;
3424
+ const packageDirectoryUrl = findPackageDirectory(
3425
+ new URL("./", fileUrl).href,
3426
+ );
3427
+ return {
3428
+ url: `/${relativeUrl}`,
3429
+ kind: readKind(meta),
3430
+ // Relative to the root and without its trailing slash, which is how a
3431
+ // tree names its own nodes.
3432
+ packageUrl: packageDirectoryUrl
3433
+ ? `/${packageDirectoryUrl.slice(String(rootDirectoryUrl).length).replace(/\/$/, "")}`
3434
+ : null,
3435
+ };
3436
+ });
3437
+ cache = pages;
3438
+ cachedAt = now;
3439
+ return pages;
3440
+ };
3441
+ };
3442
+
3265
3443
  const getDirectoryWatchPatterns = (
3266
3444
  directoryUrl,
3267
3445
  watchedDirectoryUrl,
@@ -4019,6 +4197,8 @@ const jsenvPluginProtocolFile = ({
4019
4197
  packageDirectory,
4020
4198
  sourceFilesConfig,
4021
4199
  }) => {
4200
+ const listHtmlPages = createHtmlPageLister({ rootDirectoryUrl });
4201
+
4022
4202
  return [
4023
4203
  jsenvPluginFsRedirection({
4024
4204
  spa,
@@ -4074,6 +4254,27 @@ const jsenvPluginProtocolFile = ({
4074
4254
  );
4075
4255
  },
4076
4256
  },
4257
+ {
4258
+ name: "jsenv:html_pages",
4259
+ appliesDuring: "dev",
4260
+ serverRoutes: [
4261
+ {
4262
+ endpoint: "GET /.internal/pages.json",
4263
+ description:
4264
+ "The .html files served under the source directory, as urls to navigate to.",
4265
+ availableMediaTypes: ["application/json"],
4266
+ declarationSource: import.meta.url,
4267
+ fetch: async () => ({
4268
+ status: 200,
4269
+ headers: {
4270
+ "content-type": "application/json",
4271
+ "cache-control": "no-store",
4272
+ },
4273
+ body: JSON.stringify(await listHtmlPages()),
4274
+ }),
4275
+ },
4276
+ ],
4277
+ },
4077
4278
  ...(directoryListing
4078
4279
  ? [
4079
4280
  jsenvPluginDirectoryListing({
@@ -9203,6 +9404,17 @@ const createUrlInfo = (url, context) => {
9203
9404
  if (referenceFromOther.gotInlined()) {
9204
9405
  const urlInfoReferencingThisOne = referenceFromOther.ownerUrlInfo;
9205
9406
  considerModified(urlInfoReferencingThisOne);
9407
+ continue;
9408
+ }
9409
+ // A reference with a versioning effect writes this url's VERSION into
9410
+ // its owner's cooked content (the ?v= param, read from package.json):
9411
+ // this url modified means that content now embeds a stale version, so
9412
+ // the owner is as modified as an owner of inlined content. Without
9413
+ // this, the owner's cooked content survives the modification and a
9414
+ // validity check that "heals" this url (see isValid re-reading files
9415
+ // from disk) leaves the graph claiming the owner is fresh.
9416
+ if (referenceFromOther.hasVersioningEffect) {
9417
+ considerModified(referenceFromOther.ownerUrlInfo);
9206
9418
  }
9207
9419
  }
9208
9420
  for (const searchParamVariant of urlInfo.searchParamVariantSet) {
@@ -10461,14 +10673,23 @@ ${ANSI.color(normalizedReturnValue, ANSI.YELLOW)}
10461
10673
  if (!urlInfo.url.startsWith("ignore:")) {
10462
10674
  try {
10463
10675
  await urlInfo.dependencies.startCollecting(async () => {
10676
+ // Each phase timed into urlInfo.timing: the dev server turns it into
10677
+ // a server-timing response header, so devtools show where the time
10678
+ // to cook a file goes (fetch vs transform vs finalize).
10679
+ const timePhase = async (name, phase) => {
10680
+ const start = performance.now();
10681
+ await phase();
10682
+ urlInfo.timing[name] = performance.now() - start;
10683
+ };
10684
+
10464
10685
  // "fetchUrlContent" hook
10465
- await urlInfo.fetchContent();
10686
+ await timePhase("fetch", () => urlInfo.fetchContent());
10466
10687
 
10467
10688
  // "transform" hook
10468
- await urlInfo.transformContent();
10689
+ await timePhase("transform", () => urlInfo.transformContent());
10469
10690
 
10470
10691
  // "finalize" hook
10471
- await urlInfo.finalizeContent();
10692
+ await timePhase("finalize", () => urlInfo.finalizeContent());
10472
10693
  });
10473
10694
  } catch (e) {
10474
10695
  urlInfo.error = e;
@@ -10816,8 +11037,14 @@ const devServerPluginServeSourceFiles = ({
10816
11037
  // was compared using etag and it has changed
10817
11038
  return false;
10818
11039
  }
10819
- if (!urlInfo.isWatched) {
10820
- // file is not watched, check the filesystem
11040
+ // Watched files trust the watcher — except the ones marked
11041
+ // revalidateOnFileSystem (package.json files, see node_esm_resolver):
11042
+ // the watcher fires a beat AFTER a change, and what these files
11043
+ // decide (a package version, hence the ?v= the importer embeds) is
11044
+ // cached as immutable by the browser — a request racing the watcher
11045
+ // must not win a stale answer it would then keep forever.
11046
+ if (!urlInfo.isWatched || urlInfo.revalidateOnFileSystem) {
11047
+ // check the filesystem
10821
11048
  let fileContentAsBuffer;
10822
11049
  try {
10823
11050
  fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
@@ -10916,7 +11143,14 @@ const devServerPluginServeSourceFiles = ({
10916
11143
  rootDirectoryUrl,
10917
11144
  );
10918
11145
  requestedUrlObject.searchParams.delete("hot");
10919
- requestedUrl = requestedUrlObject.href;
11146
+ // normalizeUrl, because searchParams.delete re-serializes the whole
11147
+ // query and turns a valueless param ("?enabled") into "?enabled=".
11148
+ // Every url in the graph is normalized the other way (kitchen.js
11149
+ // strips those "="), and requestedUrl is compared to graph urls as
11150
+ // a string: an inline urlInfo decides "is this request for me?"
11151
+ // that way (jsenv:inline_content_fetcher) and re-cooks its own
11152
+ // ALREADY COOKED content when the comparison wrongly fails.
11153
+ requestedUrl = normalizeUrl(requestedUrlObject.href);
10920
11154
  }
10921
11155
  const { referer } = request.headers;
10922
11156
  const parentUrl = referer
@@ -10979,7 +11213,36 @@ const devServerPluginServeSourceFiles = ({
10979
11213
  return respondWithNotModified();
10980
11214
  }
10981
11215
  }
10982
- await urlInfo.cook({ request, reference });
11216
+ // Cooking is not memoized in dev (see cookGuard in kitchen.js): a
11217
+ // request that reaches cook() re-fetches and re-transforms the file
11218
+ // even when nothing changed. The 304 path above already avoids that
11219
+ // for a browser that revalidates — but a browser with its cache
11220
+ // disabled (devtools open, the common way to reload during dev)
11221
+ // sends no if-none-match and would re-cook the entire graph on
11222
+ // every reload, turning a warm reload into seconds of transform
11223
+ // work. Same validity check as the 304 path, same trust: when the
11224
+ // graph's in-memory content is still valid, it IS the response —
11225
+ // only the status differs (200 with content, since there is no
11226
+ // client etag to match).
11227
+ const servableFromMemory =
11228
+ !urlInfo.error &&
11229
+ !inlineParentUrlInfo &&
11230
+ !urlInfo.response &&
11231
+ urlInfo.content !== undefined &&
11232
+ !cacheIsDisabledInResponseHeader(urlInfo) &&
11233
+ // a "?hot" request exists to bypass every cache, this one
11234
+ // included: it must be cooked, because cooking is what rewrites
11235
+ // its references so "?hot" cascades to the modified files below
11236
+ // (see jsenv_plugin_hot_search_param) — the memory content was
11237
+ // cooked before the change and its references carry nothing.
11238
+ // The urlInfo itself often IS valid here (hot reload of a
11239
+ // dependency: the file re-requested did not change, one below
11240
+ // it did), so isValid() alone cannot catch this.
11241
+ !request.searchParams.has("hot") &&
11242
+ urlInfo.isValid();
11243
+ if (!servableFromMemory) {
11244
+ await urlInfo.cook({ request, reference });
11245
+ }
10983
11246
  let { response } = urlInfo;
10984
11247
  if (response) {
10985
11248
  return response;
@@ -11020,7 +11283,14 @@ const devServerPluginServeSourceFiles = ({
11020
11283
  "content-length": urlInfo.contentLength,
11021
11284
  },
11022
11285
  body: urlInfo.content,
11023
- timing: urlInfo.timing, // TODO: use something else
11286
+ // Where the time went, readable in devtools (Network > Timing):
11287
+ // the server merges this into the server-timing header. Served
11288
+ // from memory: a marker saying so, since nothing was cooked for
11289
+ // this request. Cooked: what the kitchen measured (each plugin
11290
+ // hook, and the fetch/transform/finalize roll-ups).
11291
+ timing: servableFromMemory
11292
+ ? { "served from memory cache": null }
11293
+ : urlInfo.timing,
11024
11294
  };
11025
11295
  const augmentResponseInfo = {
11026
11296
  ...kitchen.context,
@@ -11143,12 +11413,13 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
11143
11413
  * @param {string} [params.sourceMainFilePath="./index.html"] - File served for "/".
11144
11414
  * @param {number} [params.port=3456] - Port to listen on (0 = a free port).
11145
11415
  * @param {string} [params.hostname] - Hostname to bind to.
11146
- * @param {boolean} [params.acceptAnyIp=true] - Also accept connections on the machine's IPs.
11416
+ * @param {boolean} [params.acceptAnyIp=false] - Also accept connections on the machine's IPs (so other devices on the network — a phone — can reach the server). Off by default: exposing the dev server beyond localhost is an explicit choice, not something a dev tool decides.
11147
11417
  * @param {boolean|object} [params.https=false] - HTTPS as `{ certificate, privateKey }`.
11148
11418
  * @param {boolean} [params.http2=false] - HTTP/2 (requires https).
11149
11419
  * @param {Array} [params.plugins=[]] - jsenv plugins (transformUrlContent, serverRoutes, serverEvents, effect, …).
11150
11420
  * @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
11151
11421
  * @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
11422
+ * @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).
11152
11423
  * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
11153
11424
  * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
11154
11425
  * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
@@ -11173,7 +11444,7 @@ const startDevServer = async ({
11173
11444
  ignore,
11174
11445
  port = 3456,
11175
11446
  hostname,
11176
- acceptAnyIp = true,
11447
+ acceptAnyIp = false,
11177
11448
  https,
11178
11449
  // it's better to use http1 by default because it allows to get statusText in devtools
11179
11450
  // which gives valuable information when there is errors
@@ -11191,6 +11462,11 @@ const startDevServer = async ({
11191
11462
  sourceFilesConfig = {},
11192
11463
  clientAutoreload = true,
11193
11464
  clientAutoreloadOnServerRestart = true,
11465
+ // server-timing response headers: devtools show how the time to answer is
11466
+ // spent (cook measures come from the kitchen, see urlInfo.timing). Entries
11467
+ // under minDuration are dropped so a human reads the measures that matter;
11468
+ // a test wants them all, hence 0 there.
11469
+ serverTiming = { minDuration: EXECUTED_BY_TEST_PLAN ? 0 : 0.5 },
11194
11470
 
11195
11471
  // runtimeCompat is the runtimeCompat for the build
11196
11472
  // when specified, dev server use it to warn in case
@@ -11329,7 +11605,9 @@ const startDevServer = async ({
11329
11605
  ...(EXECUTED_BY_TEST_PLAN
11330
11606
  ? []
11331
11607
  : [
11332
- jsenvPluginClientMonitoring({ rootDirectoryUrl: sourceDirectoryUrl }),
11608
+ jsenvPluginClientMonitoring(),
11609
+ // cmd+K on any served page to jump to another one.
11610
+ jsenvPluginPageSwitcher(),
11333
11611
  ]),
11334
11612
  ...plugins,
11335
11613
  ...getCorePlugins({
@@ -11426,6 +11704,7 @@ const startDevServer = async ({
11426
11704
  hostname,
11427
11705
  port,
11428
11706
  requestWaitingMs: 60_000,
11707
+ serverTiming,
11429
11708
  plugins: finalServerPlugins,
11430
11709
  // will allow to open file, provide more context on each route
11431
11710
  canExposeSensitiveData: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.0",
3
+ "version": "41.4.2",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -76,9 +76,9 @@
76
76
  "@jsenv/js-module-fallback": "1.4.37",
77
77
  "@jsenv/plugin-bundling": "2.10.16",
78
78
  "@jsenv/plugin-minification": "1.7.5",
79
- "@jsenv/plugin-supervisor": "1.8.7",
79
+ "@jsenv/plugin-supervisor": "1.8.8",
80
80
  "@jsenv/plugin-transpilation": "1.5.78",
81
- "@jsenv/server": "17.4.1",
81
+ "@jsenv/server": "17.5.0",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
84
84
  },