@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.
@@ -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");