@jsenv/core 41.4.1 → 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.
- package/dist/build/build.js +173 -4
- package/dist/build/jsenv_core_packages.js +91 -1
- package/dist/html/client_monitor_page.html +4 -1
- package/dist/html/clients_page.html +13 -5
- package/dist/js/client_reporter.js +185 -109
- package/dist/js/page_switcher.js +731 -0
- package/dist/start_dev_server/jsenv_core_packages.js +695 -4
- package/dist/start_dev_server/start_dev_server.js +357 -78
- package/package.json +2 -2
- package/src/dev/dev_server_plugins/dev_server_plugin_serve_source_files.js +55 -5
- package/src/dev/start_dev_server.js +13 -3
- package/src/kitchen/kitchen.js +12 -3
- package/src/kitchen/url_graph/url_graph.js +11 -0
- package/src/plugins/client_monitoring/client/client_monitor_page.html +4 -1
- package/src/plugins/client_monitoring/client/client_reporter.js +185 -109
- package/src/plugins/client_monitoring/client/clients_page.html +13 -5
- package/src/plugins/client_monitoring/jsenv_plugin_client_monitoring.js +76 -68
- package/src/plugins/page_switcher/client/page_switcher.js +731 -0
- package/src/plugins/page_switcher/jsenv_plugin_page_switcher.js +42 -0
- package/src/plugins/protocol_file/html_pages.js +122 -0
- package/src/plugins/protocol_file/jsenv_plugin_protocol_file.js +24 -0
- package/src/plugins/resolution_node_esm/node_esm_resolver.js +5 -0
|
@@ -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
|
-
|
|
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
|
|
902
|
-
client.userAgent
|
|
903
|
-
|
|
904
|
-
|
|
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:
|
|
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
|
-
|
|
10820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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 =
|
|
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(
|
|
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.
|
|
3
|
+
"version": "41.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tool to develop, test and build js projects",
|
|
6
6
|
"repository": {
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@jsenv/plugin-minification": "1.7.5",
|
|
79
79
|
"@jsenv/plugin-supervisor": "1.8.8",
|
|
80
80
|
"@jsenv/plugin-transpilation": "1.5.78",
|
|
81
|
-
"@jsenv/server": "17.
|
|
81
|
+
"@jsenv/server": "17.5.0",
|
|
82
82
|
"@jsenv/sourcemap": "1.4.2",
|
|
83
83
|
"react-table": "7.8.0"
|
|
84
84
|
},
|