@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.
- 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 +3 -3
- 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
|
@@ -2,6 +2,7 @@ import { bufferToEtag } from "@jsenv/filesystem";
|
|
|
2
2
|
import { formatError } from "@jsenv/humanize";
|
|
3
3
|
import { composeTwoResponses, fetchDirectory } from "@jsenv/server";
|
|
4
4
|
import { URL_META } from "@jsenv/url-meta";
|
|
5
|
+
import { normalizeUrl } from "@jsenv/urls";
|
|
5
6
|
import { readFileSync } from "node:fs";
|
|
6
7
|
|
|
7
8
|
import { watchSourceFiles } from "../../helpers/watch_source_files.js";
|
|
@@ -115,8 +116,14 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
115
116
|
// was compared using etag and it has changed
|
|
116
117
|
return false;
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
// Watched files trust the watcher — except the ones marked
|
|
120
|
+
// revalidateOnFileSystem (package.json files, see node_esm_resolver):
|
|
121
|
+
// the watcher fires a beat AFTER a change, and what these files
|
|
122
|
+
// decide (a package version, hence the ?v= the importer embeds) is
|
|
123
|
+
// cached as immutable by the browser — a request racing the watcher
|
|
124
|
+
// must not win a stale answer it would then keep forever.
|
|
125
|
+
if (!urlInfo.isWatched || urlInfo.revalidateOnFileSystem) {
|
|
126
|
+
// check the filesystem
|
|
120
127
|
let fileContentAsBuffer;
|
|
121
128
|
try {
|
|
122
129
|
fileContentAsBuffer = readFileSync(new URL(urlInfo.url));
|
|
@@ -215,7 +222,14 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
215
222
|
rootDirectoryUrl,
|
|
216
223
|
);
|
|
217
224
|
requestedUrlObject.searchParams.delete("hot");
|
|
218
|
-
|
|
225
|
+
// normalizeUrl, because searchParams.delete re-serializes the whole
|
|
226
|
+
// query and turns a valueless param ("?enabled") into "?enabled=".
|
|
227
|
+
// Every url in the graph is normalized the other way (kitchen.js
|
|
228
|
+
// strips those "="), and requestedUrl is compared to graph urls as
|
|
229
|
+
// a string: an inline urlInfo decides "is this request for me?"
|
|
230
|
+
// that way (jsenv:inline_content_fetcher) and re-cooks its own
|
|
231
|
+
// ALREADY COOKED content when the comparison wrongly fails.
|
|
232
|
+
requestedUrl = normalizeUrl(requestedUrlObject.href);
|
|
219
233
|
}
|
|
220
234
|
const { referer } = request.headers;
|
|
221
235
|
const parentUrl = referer
|
|
@@ -278,7 +292,36 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
278
292
|
return respondWithNotModified();
|
|
279
293
|
}
|
|
280
294
|
}
|
|
281
|
-
|
|
295
|
+
// Cooking is not memoized in dev (see cookGuard in kitchen.js): a
|
|
296
|
+
// request that reaches cook() re-fetches and re-transforms the file
|
|
297
|
+
// even when nothing changed. The 304 path above already avoids that
|
|
298
|
+
// for a browser that revalidates — but a browser with its cache
|
|
299
|
+
// disabled (devtools open, the common way to reload during dev)
|
|
300
|
+
// sends no if-none-match and would re-cook the entire graph on
|
|
301
|
+
// every reload, turning a warm reload into seconds of transform
|
|
302
|
+
// work. Same validity check as the 304 path, same trust: when the
|
|
303
|
+
// graph's in-memory content is still valid, it IS the response —
|
|
304
|
+
// only the status differs (200 with content, since there is no
|
|
305
|
+
// client etag to match).
|
|
306
|
+
const servableFromMemory =
|
|
307
|
+
!urlInfo.error &&
|
|
308
|
+
!inlineParentUrlInfo &&
|
|
309
|
+
!urlInfo.response &&
|
|
310
|
+
urlInfo.content !== undefined &&
|
|
311
|
+
!cacheIsDisabledInResponseHeader(urlInfo) &&
|
|
312
|
+
// a "?hot" request exists to bypass every cache, this one
|
|
313
|
+
// included: it must be cooked, because cooking is what rewrites
|
|
314
|
+
// its references so "?hot" cascades to the modified files below
|
|
315
|
+
// (see jsenv_plugin_hot_search_param) — the memory content was
|
|
316
|
+
// cooked before the change and its references carry nothing.
|
|
317
|
+
// The urlInfo itself often IS valid here (hot reload of a
|
|
318
|
+
// dependency: the file re-requested did not change, one below
|
|
319
|
+
// it did), so isValid() alone cannot catch this.
|
|
320
|
+
!request.searchParams.has("hot") &&
|
|
321
|
+
urlInfo.isValid();
|
|
322
|
+
if (!servableFromMemory) {
|
|
323
|
+
await urlInfo.cook({ request, reference });
|
|
324
|
+
}
|
|
282
325
|
let { response } = urlInfo;
|
|
283
326
|
if (response) {
|
|
284
327
|
return response;
|
|
@@ -319,7 +362,14 @@ export const devServerPluginServeSourceFiles = ({
|
|
|
319
362
|
"content-length": urlInfo.contentLength,
|
|
320
363
|
},
|
|
321
364
|
body: urlInfo.content,
|
|
322
|
-
|
|
365
|
+
// Where the time went, readable in devtools (Network > Timing):
|
|
366
|
+
// the server merges this into the server-timing header. Served
|
|
367
|
+
// from memory: a marker saying so, since nothing was cooked for
|
|
368
|
+
// this request. Cooked: what the kitchen measured (each plugin
|
|
369
|
+
// hook, and the fetch/transform/finalize roll-ups).
|
|
370
|
+
timing: servableFromMemory
|
|
371
|
+
? { "served from memory cache": null }
|
|
372
|
+
: urlInfo.timing,
|
|
323
373
|
};
|
|
324
374
|
const augmentResponseInfo = {
|
|
325
375
|
...kitchen.context,
|
|
@@ -18,6 +18,7 @@ import { jsenvCoreDirectoryUrl } from "../jsenv_core_directory_url.js";
|
|
|
18
18
|
import { createPackageDirectory } from "../kitchen/package_directory.js";
|
|
19
19
|
import { createJsenvPluginStore } from "../plugins/jsenv_plugins_controller.js";
|
|
20
20
|
import { jsenvPluginClientMonitoring } from "../plugins/client_monitoring/jsenv_plugin_client_monitoring.js";
|
|
21
|
+
import { jsenvPluginPageSwitcher } from "../plugins/page_switcher/jsenv_plugin_page_switcher.js";
|
|
21
22
|
import { getCorePlugins } from "../plugins/plugins.js";
|
|
22
23
|
import { jsenvPluginServerEvents } from "../plugins/server_events/jsenv_plugin_server_events.js";
|
|
23
24
|
import { devServerPluginChromeDevToolsJson } from "./dev_server_plugins/dev_server_plugin_chrome_devtools_json.js";
|
|
@@ -40,12 +41,13 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
|
|
|
40
41
|
* @param {string} [params.sourceMainFilePath="./index.html"] - File served for "/".
|
|
41
42
|
* @param {number} [params.port=3456] - Port to listen on (0 = a free port).
|
|
42
43
|
* @param {string} [params.hostname] - Hostname to bind to.
|
|
43
|
-
* @param {boolean} [params.acceptAnyIp=
|
|
44
|
+
* @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.
|
|
44
45
|
* @param {boolean|object} [params.https=false] - HTTPS as `{ certificate, privateKey }`.
|
|
45
46
|
* @param {boolean} [params.http2=false] - HTTP/2 (requires https).
|
|
46
47
|
* @param {Array} [params.plugins=[]] - jsenv plugins (transformUrlContent, serverRoutes, serverEvents, effect, …).
|
|
47
48
|
* @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
|
|
48
49
|
* @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
|
|
50
|
+
* @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).
|
|
49
51
|
* @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
|
|
50
52
|
* @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
|
|
51
53
|
* @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
|
|
@@ -70,7 +72,7 @@ export const startDevServer = async ({
|
|
|
70
72
|
ignore,
|
|
71
73
|
port = 3456,
|
|
72
74
|
hostname,
|
|
73
|
-
acceptAnyIp =
|
|
75
|
+
acceptAnyIp = false,
|
|
74
76
|
https,
|
|
75
77
|
// it's better to use http1 by default because it allows to get statusText in devtools
|
|
76
78
|
// which gives valuable information when there is errors
|
|
@@ -88,6 +90,11 @@ export const startDevServer = async ({
|
|
|
88
90
|
sourceFilesConfig = {},
|
|
89
91
|
clientAutoreload = true,
|
|
90
92
|
clientAutoreloadOnServerRestart = true,
|
|
93
|
+
// server-timing response headers: devtools show how the time to answer is
|
|
94
|
+
// spent (cook measures come from the kitchen, see urlInfo.timing). Entries
|
|
95
|
+
// under minDuration are dropped so a human reads the measures that matter;
|
|
96
|
+
// a test wants them all, hence 0 there.
|
|
97
|
+
serverTiming = { minDuration: EXECUTED_BY_TEST_PLAN ? 0 : 0.5 },
|
|
91
98
|
|
|
92
99
|
// runtimeCompat is the runtimeCompat for the build
|
|
93
100
|
// when specified, dev server use it to warn in case
|
|
@@ -227,7 +234,9 @@ export const startDevServer = async ({
|
|
|
227
234
|
...(EXECUTED_BY_TEST_PLAN
|
|
228
235
|
? []
|
|
229
236
|
: [
|
|
230
|
-
jsenvPluginClientMonitoring(
|
|
237
|
+
jsenvPluginClientMonitoring(),
|
|
238
|
+
// cmd+K on any served page to jump to another one.
|
|
239
|
+
jsenvPluginPageSwitcher(),
|
|
231
240
|
]),
|
|
232
241
|
...plugins,
|
|
233
242
|
...getCorePlugins({
|
|
@@ -324,6 +333,7 @@ export const startDevServer = async ({
|
|
|
324
333
|
hostname,
|
|
325
334
|
port,
|
|
326
335
|
requestWaitingMs: 60_000,
|
|
336
|
+
serverTiming,
|
|
327
337
|
plugins: finalServerPlugins,
|
|
328
338
|
// will allow to open file, provide more context on each route
|
|
329
339
|
canExposeSensitiveData: true,
|
package/src/kitchen/kitchen.js
CHANGED
|
@@ -645,14 +645,23 @@ ${ANSI.color(normalizedReturnValue, ANSI.YELLOW)}
|
|
|
645
645
|
if (!urlInfo.url.startsWith("ignore:")) {
|
|
646
646
|
try {
|
|
647
647
|
await urlInfo.dependencies.startCollecting(async () => {
|
|
648
|
+
// Each phase timed into urlInfo.timing: the dev server turns it into
|
|
649
|
+
// a server-timing response header, so devtools show where the time
|
|
650
|
+
// to cook a file goes (fetch vs transform vs finalize).
|
|
651
|
+
const timePhase = async (name, phase) => {
|
|
652
|
+
const start = performance.now();
|
|
653
|
+
await phase();
|
|
654
|
+
urlInfo.timing[name] = performance.now() - start;
|
|
655
|
+
};
|
|
656
|
+
|
|
648
657
|
// "fetchUrlContent" hook
|
|
649
|
-
await urlInfo.fetchContent();
|
|
658
|
+
await timePhase("fetch", () => urlInfo.fetchContent());
|
|
650
659
|
|
|
651
660
|
// "transform" hook
|
|
652
|
-
await urlInfo.transformContent();
|
|
661
|
+
await timePhase("transform", () => urlInfo.transformContent());
|
|
653
662
|
|
|
654
663
|
// "finalize" hook
|
|
655
|
-
await urlInfo.finalizeContent();
|
|
664
|
+
await timePhase("finalize", () => urlInfo.finalizeContent());
|
|
656
665
|
});
|
|
657
666
|
} catch (e) {
|
|
658
667
|
urlInfo.error = e;
|
|
@@ -427,6 +427,17 @@ const createUrlInfo = (url, context) => {
|
|
|
427
427
|
if (referenceFromOther.gotInlined()) {
|
|
428
428
|
const urlInfoReferencingThisOne = referenceFromOther.ownerUrlInfo;
|
|
429
429
|
considerModified(urlInfoReferencingThisOne);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// A reference with a versioning effect writes this url's VERSION into
|
|
433
|
+
// its owner's cooked content (the ?v= param, read from package.json):
|
|
434
|
+
// this url modified means that content now embeds a stale version, so
|
|
435
|
+
// the owner is as modified as an owner of inlined content. Without
|
|
436
|
+
// this, the owner's cooked content survives the modification and a
|
|
437
|
+
// validity check that "heals" this url (see isValid re-reading files
|
|
438
|
+
// from disk) leaves the graph claiming the owner is fresh.
|
|
439
|
+
if (referenceFromOther.hasVersioningEffect) {
|
|
440
|
+
considerModified(referenceFromOther.ownerUrlInfo);
|
|
430
441
|
}
|
|
431
442
|
}
|
|
432
443
|
for (const searchParamVariant of urlInfo.searchParamVariantSet) {
|
|
@@ -282,8 +282,11 @@
|
|
|
282
282
|
const v = version && version !== "unknown" ? ` ${version}` : "";
|
|
283
283
|
return `${label}${v}`;
|
|
284
284
|
};
|
|
285
|
+
// The ip is part of who a client is: it tells the main client
|
|
286
|
+
// (localhost — this machine) from a device on the network.
|
|
287
|
+
const ipLabel = client.local ? "localhost" : client.ip;
|
|
285
288
|
return (
|
|
286
|
-
[part(client.runtime, RUNTIME_LABELS), part(client.os)]
|
|
289
|
+
[part(client.runtime, RUNTIME_LABELS), part(client.os), ipLabel]
|
|
287
290
|
.filter(Boolean)
|
|
288
291
|
.join(" · ") || clientId.slice(0, 8)
|
|
289
292
|
);
|
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
* - buffered console logs
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
// The main client is the machine the dev server runs on, talking to itself
|
|
17
|
+
// via localhost. Watching it makes no sense — its devtools are one keystroke
|
|
18
|
+
// away, and the person reading the dashboard IS this client — so it only
|
|
19
|
+
// reports presence (heartbeat + tab info, so the dashboard lists it and can
|
|
20
|
+
// pilot it) and watches nothing: no console capture, no activity tracking, no
|
|
21
|
+
// fetch/history patching. A client reaching the server over the network (a
|
|
22
|
+
// phone on the LAN ip, acceptAnyIp: true) is the one being monitored, and
|
|
23
|
+
// reports everything.
|
|
24
|
+
const LOCAL_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
|
|
25
|
+
const isLocalClient = LOCAL_HOSTNAMES.includes(window.location.hostname);
|
|
26
|
+
|
|
16
27
|
const CLIENT_ID_STORAGE_KEY = "jsenv_client_id";
|
|
17
28
|
const TAB_ID_STORAGE_KEY = "jsenv_tab_id";
|
|
18
29
|
const REPORT_ENDPOINT = "/.internal/clients/report";
|
|
@@ -25,6 +36,12 @@ const HEARTBEAT_MS = 15000;
|
|
|
25
36
|
// Continuous activities (mousemove, scroll) only need to refresh "what the tab
|
|
26
37
|
// is doing" occasionally, not on every event.
|
|
27
38
|
const CONTINUOUS_THROTTLE_MS = 2000;
|
|
39
|
+
// A single log line is worth reading, not storing whole: one console.log of a
|
|
40
|
+
// big object serializes to megabytes, and that string is then kept by the
|
|
41
|
+
// buffer here, by the dev server's own per-client buffer, and by the
|
|
42
|
+
// server-events history — three copies of something nobody will read past the
|
|
43
|
+
// first screen. Cut at the source, once, where the size is known.
|
|
44
|
+
const LOG_TEXT_MAX = 10_000;
|
|
28
45
|
|
|
29
46
|
const randomId = () =>
|
|
30
47
|
typeof window.crypto !== "undefined" && window.crypto.randomUUID
|
|
@@ -191,19 +208,47 @@ const setup = () => {
|
|
|
191
208
|
}
|
|
192
209
|
};
|
|
193
210
|
|
|
211
|
+
// A page can declare itself perf critical (window.__jsenv_perf_critical__()):
|
|
212
|
+
// measuring an animation means nothing if the thing measuring it steals a
|
|
213
|
+
// frame. Monitoring then holds everything back until the page has been
|
|
214
|
+
// genuinely idle — no interaction for a while — instead of flushing on its
|
|
215
|
+
// own schedule.
|
|
216
|
+
let perfCritical = false;
|
|
217
|
+
let lastInteractionMs = 0;
|
|
218
|
+
const PERF_CRITICAL_QUIET_MS = 2000;
|
|
219
|
+
const isQuiet = () =>
|
|
220
|
+
!perfCritical || Date.now() - lastInteractionMs > PERF_CRITICAL_QUIET_MS;
|
|
221
|
+
const onInteraction = () => {
|
|
222
|
+
lastInteractionMs = Date.now();
|
|
223
|
+
};
|
|
224
|
+
if (!isLocalClient) {
|
|
225
|
+
for (const eventName of ["pointerdown", "keydown", "wheel", "touchstart"]) {
|
|
226
|
+
window.addEventListener(eventName, onInteraction, {
|
|
227
|
+
capture: true,
|
|
228
|
+
passive: true,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
194
233
|
let flushScheduled = false;
|
|
195
234
|
const scheduleFlush = () => {
|
|
196
235
|
if (flushScheduled) {
|
|
197
236
|
return;
|
|
198
237
|
}
|
|
199
238
|
flushScheduled = true;
|
|
200
|
-
|
|
239
|
+
const attempt = () => {
|
|
240
|
+
if (!isQuiet()) {
|
|
241
|
+
// Still being used: come back later rather than take the frame now.
|
|
242
|
+
setTimeout(attempt, PERF_CRITICAL_QUIET_MS);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
201
245
|
flushScheduled = false;
|
|
202
246
|
if (!pendingLogs.length && !pendingActivities.length) {
|
|
203
247
|
return;
|
|
204
248
|
}
|
|
205
249
|
post();
|
|
206
|
-
}
|
|
250
|
+
};
|
|
251
|
+
setTimeout(attempt, FLUSH_INTERVAL_MS);
|
|
207
252
|
};
|
|
208
253
|
|
|
209
254
|
const pushLog = (entry) => {
|
|
@@ -224,15 +269,35 @@ const setup = () => {
|
|
|
224
269
|
let consoleProcessScheduled = false;
|
|
225
270
|
// requestIdleCallback is missing on Safari/iOS (our main mobile target), so
|
|
226
271
|
// fall back to setTimeout there; either way each run is time-boxed below.
|
|
272
|
+
// Named on window rather than exported: the page that needs it is a plain
|
|
273
|
+
// html file, and it must be able to ask before anything else has loaded.
|
|
274
|
+
window.__jsenv_perf_critical__ = () => {
|
|
275
|
+
perfCritical = true;
|
|
276
|
+
};
|
|
277
|
+
|
|
227
278
|
const scheduleIdle =
|
|
228
279
|
typeof requestIdleCallback === "function"
|
|
229
280
|
? (fn) => requestIdleCallback(fn, { timeout: 1000 })
|
|
230
281
|
: (fn) => setTimeout(fn, 0);
|
|
282
|
+
const truncate = (text) =>
|
|
283
|
+
typeof text === "string" && text.length > LOG_TEXT_MAX
|
|
284
|
+
? `${text.slice(0, LOG_TEXT_MAX)}… (${text.length - LOG_TEXT_MAX} more characters)`
|
|
285
|
+
: text;
|
|
231
286
|
const formatOne = (captured) => {
|
|
287
|
+
const formatted = formatConsole(captured.args);
|
|
232
288
|
pushLog({
|
|
233
289
|
level: captured.level,
|
|
234
290
|
ts: captured.ts,
|
|
235
|
-
...
|
|
291
|
+
...formatted,
|
|
292
|
+
text: truncate(formatted.text),
|
|
293
|
+
// The styled runs carry the same text a second time (see formatConsole),
|
|
294
|
+
// so they are cut the same way.
|
|
295
|
+
segments: formatted.segments
|
|
296
|
+
? formatted.segments.map((segment) => ({
|
|
297
|
+
...segment,
|
|
298
|
+
text: truncate(segment.text),
|
|
299
|
+
}))
|
|
300
|
+
: undefined,
|
|
236
301
|
});
|
|
237
302
|
};
|
|
238
303
|
const processConsoleQueue = (deadline) => {
|
|
@@ -281,122 +346,129 @@ const setup = () => {
|
|
|
281
346
|
scheduleFlush();
|
|
282
347
|
};
|
|
283
348
|
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
349
|
+
// Everything that WATCHES the page — console, errors, activity, requests,
|
|
350
|
+
// navigations — is what the main client does without (see isLocalClient).
|
|
351
|
+
if (!isLocalClient) {
|
|
352
|
+
// Forward console.* while keeping the original behavior intact.
|
|
353
|
+
const LEVELS = ["log", "info", "warn", "error", "debug"];
|
|
354
|
+
for (const level of LEVELS) {
|
|
355
|
+
const original = console[level];
|
|
356
|
+
if (typeof original !== "function") {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
console[level] = (...args) => {
|
|
360
|
+
original.apply(console, args);
|
|
361
|
+
captureConsole(level, args);
|
|
362
|
+
};
|
|
290
363
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
window.addEventListener("unhandledrejection", (event) => {
|
|
303
|
-
pushLog({
|
|
304
|
-
level: "error",
|
|
305
|
-
text: `Unhandled rejection: ${formatArg(event.reason)}`,
|
|
364
|
+
window.addEventListener("error", (event) => {
|
|
365
|
+
const location = event.filename
|
|
366
|
+
? ` (${event.filename}:${event.lineno})`
|
|
367
|
+
: "";
|
|
368
|
+
pushLog({ level: "error", text: `${event.message}${location}` });
|
|
369
|
+
});
|
|
370
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
371
|
+
pushLog({
|
|
372
|
+
level: "error",
|
|
373
|
+
text: `Unhandled rejection: ${formatArg(event.reason)}`,
|
|
374
|
+
});
|
|
306
375
|
});
|
|
307
|
-
});
|
|
308
376
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
377
|
+
// Qualified activity so the dashboard can say what the tab was last doing.
|
|
378
|
+
// The detail is kept short enough to read inline (e.g. "mousemove: 40/120").
|
|
379
|
+
window.addEventListener(
|
|
380
|
+
"click",
|
|
381
|
+
(event) => recordActivity("click", `${event.clientX}/${event.clientY}`),
|
|
382
|
+
{ passive: true },
|
|
383
|
+
);
|
|
384
|
+
window.addEventListener(
|
|
385
|
+
"keydown",
|
|
386
|
+
(event) => recordActivity("keydown", event.key),
|
|
387
|
+
{ passive: true },
|
|
388
|
+
);
|
|
389
|
+
let lastMove = 0;
|
|
390
|
+
let lastScroll = 0;
|
|
391
|
+
window.addEventListener(
|
|
392
|
+
"mousemove",
|
|
393
|
+
(event) => {
|
|
394
|
+
const t = Date.now();
|
|
395
|
+
if (t - lastMove < CONTINUOUS_THROTTLE_MS) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
lastMove = t;
|
|
399
|
+
recordActivity("mousemove", `${event.clientX}/${event.clientY}`);
|
|
400
|
+
},
|
|
401
|
+
{ passive: true },
|
|
402
|
+
);
|
|
403
|
+
window.addEventListener(
|
|
404
|
+
"scroll",
|
|
405
|
+
() => {
|
|
406
|
+
const t = Date.now();
|
|
407
|
+
if (t - lastScroll < CONTINUOUS_THROTTLE_MS) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
lastScroll = t;
|
|
411
|
+
recordActivity("scroll", `${window.scrollX}/${window.scrollY}`);
|
|
412
|
+
},
|
|
413
|
+
{ passive: true },
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Report outgoing HTTP requests (skipping our own internal traffic).
|
|
417
|
+
const isInternal = (url) => String(url).includes("/.internal/");
|
|
418
|
+
window.fetch = (input, init) => {
|
|
419
|
+
const url =
|
|
420
|
+
typeof input === "string"
|
|
421
|
+
? input
|
|
422
|
+
: input instanceof Request
|
|
423
|
+
? input.url
|
|
424
|
+
: String(input);
|
|
425
|
+
if (!isInternal(url)) {
|
|
426
|
+
const method =
|
|
427
|
+
(init && init.method) ||
|
|
428
|
+
(input instanceof Request ? input.method : "GET");
|
|
429
|
+
recordActivity("request", `${method} ${url}`);
|
|
329
430
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const t = Date.now();
|
|
339
|
-
if (t - lastScroll < CONTINUOUS_THROTTLE_MS) {
|
|
431
|
+
return nativeFetch(input, init);
|
|
432
|
+
};
|
|
433
|
+
// SPA navigations (history API + back/forward).
|
|
434
|
+
const reportNavigation = () =>
|
|
435
|
+
recordActivity("navigation", window.location.href);
|
|
436
|
+
const patchHistory = (method) => {
|
|
437
|
+
const original = window.history[method];
|
|
438
|
+
if (typeof original !== "function") {
|
|
340
439
|
return;
|
|
341
440
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
441
|
+
window.history[method] = (...args) => {
|
|
442
|
+
const result = original.apply(window.history, args);
|
|
443
|
+
reportNavigation();
|
|
444
|
+
return result;
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
patchHistory("pushState");
|
|
448
|
+
patchHistory("replaceState");
|
|
449
|
+
window.addEventListener("popstate", reportNavigation);
|
|
450
|
+
|
|
451
|
+
// Record the page load itself as an activity: after a reload the tab goes
|
|
452
|
+
// hidden (pagehide on the old page) then loads again here, and without this
|
|
453
|
+
// the dashboard would only ever show the "hidden" side of a reload. Sent
|
|
454
|
+
// with the first heartbeat below.
|
|
455
|
+
recordActivity("load", window.location.href);
|
|
456
|
+
}
|
|
457
|
+
|
|
347
458
|
document.addEventListener("visibilitychange", () => {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
459
|
+
if (!isLocalClient) {
|
|
460
|
+
// Spell out the direction so the activity reads meaningfully on its own,
|
|
461
|
+
// rather than a bare "visibility" whose value you have to interpret.
|
|
462
|
+
recordActivity(
|
|
463
|
+
document.visibilityState === "visible"
|
|
464
|
+
? "document_becomes_visible"
|
|
465
|
+
: "document_becomes_hidden",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
355
468
|
// push promptly so the dashboard's "active tab" tracks focus changes
|
|
356
469
|
post();
|
|
357
470
|
});
|
|
358
471
|
|
|
359
|
-
// Report outgoing HTTP requests (skipping our own internal traffic).
|
|
360
|
-
const isInternal = (url) => String(url).includes("/.internal/");
|
|
361
|
-
window.fetch = (input, init) => {
|
|
362
|
-
const url =
|
|
363
|
-
typeof input === "string"
|
|
364
|
-
? input
|
|
365
|
-
: input instanceof Request
|
|
366
|
-
? input.url
|
|
367
|
-
: String(input);
|
|
368
|
-
if (!isInternal(url)) {
|
|
369
|
-
const method =
|
|
370
|
-
(init && init.method) ||
|
|
371
|
-
(input instanceof Request ? input.method : "GET");
|
|
372
|
-
recordActivity("request", `${method} ${url}`);
|
|
373
|
-
}
|
|
374
|
-
return nativeFetch(input, init);
|
|
375
|
-
};
|
|
376
|
-
// SPA navigations (history API + back/forward).
|
|
377
|
-
const reportNavigation = () =>
|
|
378
|
-
recordActivity("navigation", window.location.href);
|
|
379
|
-
const patchHistory = (method) => {
|
|
380
|
-
const original = window.history[method];
|
|
381
|
-
if (typeof original !== "function") {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
window.history[method] = (...args) => {
|
|
385
|
-
const result = original.apply(window.history, args);
|
|
386
|
-
reportNavigation();
|
|
387
|
-
return result;
|
|
388
|
-
};
|
|
389
|
-
};
|
|
390
|
-
patchHistory("pushState");
|
|
391
|
-
patchHistory("replaceState");
|
|
392
|
-
window.addEventListener("popstate", reportNavigation);
|
|
393
|
-
|
|
394
|
-
// Record the page load itself as an activity: after a reload the tab goes
|
|
395
|
-
// hidden (pagehide on the old page) then loads again here, and without this the
|
|
396
|
-
// dashboard would only ever show the "hidden" side of a reload. Sent with the
|
|
397
|
-
// first heartbeat below.
|
|
398
|
-
recordActivity("load", window.location.href);
|
|
399
|
-
|
|
400
472
|
// Heartbeat keeps the client "online", refreshes tab info, and lets the server
|
|
401
473
|
// detect a resume.
|
|
402
474
|
post();
|
|
@@ -438,6 +510,9 @@ const setup = () => {
|
|
|
438
510
|
// full reload navigates away before this flushes and surfaces as "load" on
|
|
439
511
|
// the next page; a hot update stays on the page, so this is what records it.
|
|
440
512
|
reload: (event) => {
|
|
513
|
+
if (isLocalClient) {
|
|
514
|
+
return; // the main client records no activity
|
|
515
|
+
}
|
|
441
516
|
const data = event.data || {};
|
|
442
517
|
const reason =
|
|
443
518
|
(typeof data.reason === "string" && data.reason) ||
|
|
@@ -486,7 +561,8 @@ const showClientToast = ({ client, reason }) => {
|
|
|
486
561
|
const link = document.createElement("a");
|
|
487
562
|
link.href = `/.internal/client?id=${encodeURIComponent(client.id)}`;
|
|
488
563
|
link.target = "_blank";
|
|
489
|
-
link.
|
|
564
|
+
link.rel = "noopener";
|
|
565
|
+
link.textContent = "Monitor ↗";
|
|
490
566
|
link.style.cssText = "color:#93c5fd;text-decoration:none;font-weight:600";
|
|
491
567
|
el.appendChild(link);
|
|
492
568
|
const close = document.createElement("button");
|
|
@@ -249,12 +249,12 @@
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
const headHtml = `<tr>
|
|
252
|
-
<th>First seen</th><th>OS</th><th>Browser</th><th>Active tab</th>
|
|
252
|
+
<th>First seen</th><th>OS</th><th>Browser</th><th>IP</th><th>Active tab</th>
|
|
253
253
|
<th>Last activity</th><th>Logs</th><th></th>
|
|
254
254
|
</tr>`;
|
|
255
255
|
document.getElementById("head").innerHTML = headHtml;
|
|
256
256
|
document.getElementById("head2").innerHTML = headHtml;
|
|
257
|
-
const COLSPAN =
|
|
257
|
+
const COLSPAN = 8;
|
|
258
258
|
|
|
259
259
|
const ago = (ts) => {
|
|
260
260
|
const s = Math.round((Date.now() - ts) / 1000);
|
|
@@ -394,11 +394,21 @@
|
|
|
394
394
|
<a class="monitor" href="/.internal/client?id=${encodeURIComponent(d.id)}">Monitor →</a>
|
|
395
395
|
</div>`;
|
|
396
396
|
};
|
|
397
|
+
// The ip says how the client reaches the server, which is what tells the
|
|
398
|
+
// main client (this machine, via localhost) from a device on the network.
|
|
399
|
+
const ipCell = (d) => {
|
|
400
|
+
if (!d.ip) {
|
|
401
|
+
return '<span class="muted">—</span>';
|
|
402
|
+
}
|
|
403
|
+
const label = d.local ? "localhost" : d.ip;
|
|
404
|
+
return `<span title="${escapeHtml(d.ip)}">${escapeHtml(label)}</span>`;
|
|
405
|
+
};
|
|
397
406
|
const rowHtml = (d) => {
|
|
398
407
|
return `<tr>
|
|
399
408
|
<td>${firstSeenCell(d)}</td>
|
|
400
409
|
<td>${named(d.os)}</td>
|
|
401
410
|
<td>${named(d.runtime)}</td>
|
|
411
|
+
<td>${ipCell(d)}</td>
|
|
402
412
|
<td>${tabCell(d)}</td>
|
|
403
413
|
<td>${activityCell(d)}</td>
|
|
404
414
|
<td>${d.logCount}</td>
|
|
@@ -511,9 +521,7 @@
|
|
|
511
521
|
return navigablePages;
|
|
512
522
|
}
|
|
513
523
|
try {
|
|
514
|
-
navigablePages = await (
|
|
515
|
-
await fetch("/.internal/clients/pages.json")
|
|
516
|
-
).json();
|
|
524
|
+
navigablePages = await (await fetch("/.internal/pages.json")).json();
|
|
517
525
|
} catch {
|
|
518
526
|
navigablePages = [];
|
|
519
527
|
}
|