@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.
@@ -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
- if (!urlInfo.isWatched) {
119
- // file is not watched, check the filesystem
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
- requestedUrl = requestedUrlObject.href;
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
- await urlInfo.cook({ request, reference });
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
- timing: urlInfo.timing, // TODO: use something else
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=true] - Also accept connections on the machine's IPs.
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 = true,
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({ rootDirectoryUrl: sourceDirectoryUrl }),
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,
@@ -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
- setTimeout(() => {
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
- }, FLUSH_INTERVAL_MS);
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
- ...formatConsole(captured.args),
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
- // Forward console.* while keeping the original behavior intact.
285
- const LEVELS = ["log", "info", "warn", "error", "debug"];
286
- for (const level of LEVELS) {
287
- const original = console[level];
288
- if (typeof original !== "function") {
289
- continue;
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
- console[level] = (...args) => {
292
- original.apply(console, args);
293
- captureConsole(level, args);
294
- };
295
- }
296
- window.addEventListener("error", (event) => {
297
- const location = event.filename
298
- ? ` (${event.filename}:${event.lineno})`
299
- : "";
300
- pushLog({ level: "error", text: `${event.message}${location}` });
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
- // Qualified activity so the dashboard can say what the tab was last doing.
310
- // The detail is kept short enough to read inline (e.g. "mousemove: 40/120").
311
- window.addEventListener(
312
- "click",
313
- (event) => recordActivity("click", `${event.clientX}/${event.clientY}`),
314
- { passive: true },
315
- );
316
- window.addEventListener(
317
- "keydown",
318
- (event) => recordActivity("keydown", event.key),
319
- { passive: true },
320
- );
321
- let lastMove = 0;
322
- let lastScroll = 0;
323
- window.addEventListener(
324
- "mousemove",
325
- (event) => {
326
- const t = Date.now();
327
- if (t - lastMove < CONTINUOUS_THROTTLE_MS) {
328
- return;
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
- lastMove = t;
331
- recordActivity("mousemove", `${event.clientX}/${event.clientY}`);
332
- },
333
- { passive: true },
334
- );
335
- window.addEventListener(
336
- "scroll",
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
- lastScroll = t;
343
- recordActivity("scroll", `${window.scrollX}/${window.scrollY}`);
344
- },
345
- { passive: true },
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
- // Spell out the direction so the activity reads meaningfully on its own,
349
- // rather than a bare "visibility" whose value you have to interpret.
350
- recordActivity(
351
- document.visibilityState === "visible"
352
- ? "document_becomes_visible"
353
- : "document_becomes_hidden",
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.textContent = "Monitor →";
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 = 7;
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
  }