@jsenv/core 41.2.13 → 41.3.0

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.
@@ -0,0 +1,502 @@
1
+ /*
2
+ * Injected into every dev-server page. Gives this browser a stable "client id"
3
+ * (and this browsing context a "tab id"), reports what the tab is doing to the
4
+ * dev server, and shows a toast when another client appears (or resumes) —
5
+ * inviting the user to open that client's live-log monitor.
6
+ *
7
+ * It does NOT open a websocket: server → browser messages ride the existing
8
+ * jsenv server-events channel (window.__server_events__), and browser → server
9
+ * messages are batched POSTs to /.internal/clients/report carrying:
10
+ * - the tab (id, url, title, visibility)
11
+ * - recent qualified activities (load, hot_reload, click, keydown, mousemove,
12
+ * scroll, request, navigation, document_becomes_visible/hidden)
13
+ * - buffered console logs
14
+ */
15
+
16
+ const CLIENT_ID_STORAGE_KEY = "jsenv_client_id";
17
+ const TAB_ID_STORAGE_KEY = "jsenv_tab_id";
18
+ const REPORT_ENDPOINT = "/.internal/clients/report";
19
+ // Flush buffered logs/activities at most this often (a chatty page shouldn't
20
+ // POST per line).
21
+ const FLUSH_INTERVAL_MS = 1000;
22
+ // Heartbeat so the server keeps seeing this client as "online" while the page is
23
+ // open, and notices when it is picked back up after a quiet spell.
24
+ const HEARTBEAT_MS = 15000;
25
+ // Continuous activities (mousemove, scroll) only need to refresh "what the tab
26
+ // is doing" occasionally, not on every event.
27
+ const CONTINUOUS_THROTTLE_MS = 2000;
28
+
29
+ const randomId = () =>
30
+ typeof window.crypto !== "undefined" && window.crypto.randomUUID
31
+ ? window.crypto.randomUUID()
32
+ : `${String(Math.random()).slice(2)}${Date.now().toString(36)}`;
33
+
34
+ const getClientId = () => {
35
+ try {
36
+ let id = localStorage.getItem(CLIENT_ID_STORAGE_KEY);
37
+ if (!id) {
38
+ id = randomId();
39
+ localStorage.setItem(CLIENT_ID_STORAGE_KEY, id);
40
+ }
41
+ return id;
42
+ } catch {
43
+ // storage may be unavailable (private mode, sandbox) — a per-load id is fine.
44
+ return `anon-${Date.now().toString(36)}`;
45
+ }
46
+ };
47
+
48
+ // sessionStorage is per-tab and survives a reload, so it's a good tab identity.
49
+ const getTabId = () => {
50
+ try {
51
+ let id = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
52
+ if (!id) {
53
+ id = randomId();
54
+ sessionStorage.setItem(TAB_ID_STORAGE_KEY, id);
55
+ }
56
+ return id;
57
+ } catch {
58
+ return `tab-${Date.now().toString(36)}`;
59
+ }
60
+ };
61
+
62
+ // Best-effort, non-throwing stringify of console arguments.
63
+ const jsonReplacer = () => {
64
+ const seen = new WeakSet();
65
+ return (key, value) => {
66
+ if (typeof value === "object" && value !== null) {
67
+ if (seen.has(value)) {
68
+ return "[Circular]";
69
+ }
70
+ seen.add(value);
71
+ }
72
+ if (typeof value === "function") {
73
+ return `[Function ${value.name || "anonymous"}]`;
74
+ }
75
+ if (typeof value === "bigint") {
76
+ return `${value}n`;
77
+ }
78
+ return value;
79
+ };
80
+ };
81
+ const formatArg = (arg) => {
82
+ if (typeof arg === "string") {
83
+ return arg;
84
+ }
85
+ if (arg instanceof Error) {
86
+ return arg.stack || `${arg.name}: ${arg.message}`;
87
+ }
88
+ if (arg === undefined) {
89
+ return "undefined";
90
+ }
91
+ try {
92
+ return JSON.stringify(arg, jsonReplacer());
93
+ } catch {
94
+ return String(arg);
95
+ }
96
+ };
97
+ // Reproduce console formatting: %c sets CSS for the following text, %s/%d/%i/%f
98
+ // substitute values, %o/%O stringify objects. Returns a plain `text` (no CSS,
99
+ // good for copy/paste) plus, when any styling is present, `segments` of
100
+ // { text, css } so the monitor can render colors like the devtools console do.
101
+ const formatConsole = (args) => {
102
+ const first = args[0];
103
+ if (typeof first !== "string" || !/%[csdifoO]/.test(first)) {
104
+ return { text: args.map(formatArg).join(" ") };
105
+ }
106
+ const segments = [];
107
+ let buffer = "";
108
+ let css = "";
109
+ const flush = () => {
110
+ if (buffer) {
111
+ segments.push({ text: buffer, css });
112
+ buffer = "";
113
+ }
114
+ };
115
+ let argIndex = 1;
116
+ let i = 0;
117
+ while (i < first.length) {
118
+ const char = first[i];
119
+ const spec = char === "%" ? first[i + 1] : "";
120
+ if (spec === "%") {
121
+ buffer += "%";
122
+ i += 2;
123
+ } else if (spec === "c") {
124
+ flush();
125
+ css = argIndex < args.length ? String(args[argIndex++]) : "";
126
+ i += 2;
127
+ } else if (spec !== "" && "sdifoO".includes(spec)) {
128
+ const value = argIndex < args.length ? args[argIndex++] : undefined;
129
+ buffer += spec === "s" ? String(value) : formatArg(value);
130
+ i += 2;
131
+ } else {
132
+ buffer += char;
133
+ i += 1;
134
+ }
135
+ }
136
+ flush();
137
+ while (argIndex < args.length) {
138
+ segments.push({ text: ` ${formatArg(args[argIndex++])}`, css: "" });
139
+ }
140
+ const text = segments.map((segment) => segment.text).join("");
141
+ const styled = segments.some((segment) => segment.css);
142
+ return styled ? { text, segments } : { text };
143
+ };
144
+
145
+ const setup = () => {
146
+ const clientId = getClientId();
147
+ const tabId = getTabId();
148
+ // Use the unwrapped fetch for our own reports so they don't count as activity.
149
+ const nativeFetch = window.fetch.bind(window);
150
+
151
+ const tabInfo = ({ closing = false } = {}) => ({
152
+ id: tabId,
153
+ url: window.location.href,
154
+ title: document.title,
155
+ visible: document.visibilityState === "visible",
156
+ closing,
157
+ });
158
+
159
+ // Buffer logs and activities; POST them (with the current tab) in batches. The
160
+ // server reads the browser and OS from the request's own headers, so the body
161
+ // only carries the id, tab, activities and log entries.
162
+ let pendingLogs = [];
163
+ let pendingActivities = [];
164
+ const post = async ({ beacon = false, closing = false } = {}) => {
165
+ const logs = pendingLogs;
166
+ const activities = pendingActivities;
167
+ pendingLogs = [];
168
+ pendingActivities = [];
169
+ const payload = JSON.stringify({
170
+ clientId,
171
+ tab: tabInfo({ closing }),
172
+ activities,
173
+ logs,
174
+ });
175
+ if (beacon && window.navigator.sendBeacon) {
176
+ window.navigator.sendBeacon(
177
+ REPORT_ENDPOINT,
178
+ new Blob([payload], { type: "application/json" }),
179
+ );
180
+ return;
181
+ }
182
+ try {
183
+ await nativeFetch(REPORT_ENDPOINT, {
184
+ method: "POST",
185
+ headers: { "content-type": "application/json" },
186
+ body: payload,
187
+ keepalive: true,
188
+ });
189
+ } catch {
190
+ // dev server gone or offline — dropping the report is acceptable.
191
+ }
192
+ };
193
+
194
+ let flushScheduled = false;
195
+ const scheduleFlush = () => {
196
+ if (flushScheduled) {
197
+ return;
198
+ }
199
+ flushScheduled = true;
200
+ setTimeout(() => {
201
+ flushScheduled = false;
202
+ if (!pendingLogs.length && !pendingActivities.length) {
203
+ return;
204
+ }
205
+ post();
206
+ }, FLUSH_INTERVAL_MS);
207
+ };
208
+
209
+ const pushLog = (entry) => {
210
+ pendingLogs.push({ ...entry, ts: entry.ts ?? Date.now() });
211
+ if (pendingLogs.length > 500) {
212
+ pendingLogs.shift(); // cap the buffer if the server is unreachable
213
+ }
214
+ scheduleFlush();
215
+ };
216
+
217
+ // Formatting a console call (%c parsing + serializing object args) is
218
+ // expensive, and verbose debug logging fires it hundreds of times inside a
219
+ // single interaction — enough to dominate the main thread and delay paint.
220
+ // So capture the raw args cheaply on the hot path and format them only when
221
+ // the browser is idle (chunked to respect the idle deadline). Serialization
222
+ // is kept in full; it just no longer blocks the interaction.
223
+ const rawConsoleQueue = [];
224
+ let consoleProcessScheduled = false;
225
+ // requestIdleCallback is missing on Safari/iOS (our main mobile target), so
226
+ // fall back to setTimeout there; either way each run is time-boxed below.
227
+ const scheduleIdle =
228
+ typeof requestIdleCallback === "function"
229
+ ? (fn) => requestIdleCallback(fn, { timeout: 1000 })
230
+ : (fn) => setTimeout(fn, 0);
231
+ const formatOne = (captured) => {
232
+ pushLog({
233
+ level: captured.level,
234
+ ts: captured.ts,
235
+ ...formatConsole(captured.args),
236
+ });
237
+ };
238
+ const processConsoleQueue = (deadline) => {
239
+ consoleProcessScheduled = false;
240
+ const start = performance.now();
241
+ while (rawConsoleQueue.length) {
242
+ // Yield within ~8ms (setTimeout fallback) or when idle time runs out (rIC),
243
+ // so a burst of logs never becomes a long task itself.
244
+ const outOfTime = deadline
245
+ ? !deadline.didTimeout && deadline.timeRemaining() <= 1
246
+ : performance.now() - start > 8;
247
+ if (outOfTime) {
248
+ break;
249
+ }
250
+ formatOne(rawConsoleQueue.shift());
251
+ }
252
+ if (rawConsoleQueue.length) {
253
+ scheduleConsoleProcess();
254
+ }
255
+ };
256
+ const scheduleConsoleProcess = () => {
257
+ if (consoleProcessScheduled) {
258
+ return;
259
+ }
260
+ consoleProcessScheduled = true;
261
+ scheduleIdle(processConsoleQueue);
262
+ };
263
+ // Format everything now (page unloading — losing logs is worse than the cost).
264
+ const drainConsoleQueue = () => {
265
+ while (rawConsoleQueue.length) {
266
+ formatOne(rawConsoleQueue.shift());
267
+ }
268
+ };
269
+ const captureConsole = (level, args) => {
270
+ rawConsoleQueue.push({ level, args, ts: Date.now() });
271
+ if (rawConsoleQueue.length > 2000) {
272
+ rawConsoleQueue.shift(); // bound memory if idle never runs
273
+ }
274
+ scheduleConsoleProcess();
275
+ };
276
+ const recordActivity = (type, detail = "") => {
277
+ pendingActivities.push({ type, detail, ts: Date.now(), tabId });
278
+ if (pendingActivities.length > 50) {
279
+ pendingActivities.shift();
280
+ }
281
+ scheduleFlush();
282
+ };
283
+
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;
290
+ }
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)}`,
306
+ });
307
+ });
308
+
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;
329
+ }
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) {
340
+ return;
341
+ }
342
+ lastScroll = t;
343
+ recordActivity("scroll", `${window.scrollX}/${window.scrollY}`);
344
+ },
345
+ { passive: true },
346
+ );
347
+ 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
+ );
355
+ // push promptly so the dashboard's "active tab" tracks focus changes
356
+ post();
357
+ });
358
+
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
+ // Heartbeat keeps the client "online", refreshes tab info, and lets the server
401
+ // detect a resume.
402
+ post();
403
+ const heartbeatId = setInterval(() => post(), HEARTBEAT_MS);
404
+ window.addEventListener("pagehide", () => {
405
+ clearInterval(heartbeatId);
406
+ // Format whatever was captured but not yet processed, so the final beacon
407
+ // doesn't drop logs.
408
+ drainConsoleQueue();
409
+ post({ beacon: true, closing: true });
410
+ });
411
+
412
+ // Toast when another client appears, and obey pilot commands aimed at us —
413
+ // both reuse the server-events channel. window.__server_events__ is injected
414
+ // before this script, so it's ready.
415
+ window.__server_events__.listenEvents({
416
+ client_here: (event) => {
417
+ const { client, reason } = event.data;
418
+ if (client.id === clientId) {
419
+ return; // don't toast a client about itself
420
+ }
421
+ showClientToast({ client, reason });
422
+ },
423
+ client_command: (event) => {
424
+ const { clientId: targetId, tabId: targetTabId, type, url } = event.data;
425
+ if (targetId !== clientId) {
426
+ return; // aimed at another client
427
+ }
428
+ if (targetTabId && targetTabId !== tabId) {
429
+ return; // aimed at a specific tab of this client, not this one
430
+ }
431
+ if (type === "navigate" && url) {
432
+ window.location.href = url;
433
+ } else if (type === "reload") {
434
+ window.location.reload();
435
+ }
436
+ },
437
+ // jsenv autoreload broadcasts "reload" for hot updates and full reloads. A
438
+ // full reload navigates away before this flushes and surfaces as "load" on
439
+ // the next page; a hot update stays on the page, so this is what records it.
440
+ reload: (event) => {
441
+ const data = event.data || {};
442
+ const reason =
443
+ (typeof data.reason === "string" && data.reason) ||
444
+ (typeof data.cause === "string" && data.cause) ||
445
+ "";
446
+ recordActivity("hot_reload", reason);
447
+ },
448
+ });
449
+ };
450
+
451
+ // "Chrome 149 · iOS 17.2" from the parsed runtime/os, falling back gracefully.
452
+ const describeClient = (client) => {
453
+ const version = (v) => (v && v !== "unknown" ? ` ${v.split(".")[0]}` : "");
454
+ const runtime = client.runtime || {};
455
+ const os = client.os || {};
456
+ const browserLabel =
457
+ runtime.name && runtime.name !== "unknown"
458
+ ? `${runtime.name}${version(runtime.version)}`
459
+ : "";
460
+ const osLabel =
461
+ os.name && os.name !== "unknown" ? `${os.name}${version(os.version)}` : "";
462
+ return (
463
+ [browserLabel, osLabel].filter(Boolean).join(" · ") || "unknown client"
464
+ );
465
+ };
466
+
467
+ const showClientToast = ({ client, reason }) => {
468
+ const label =
469
+ reason === "new" ? "A new client connected" : "A client resumed";
470
+ const el = document.createElement("div");
471
+ el.setAttribute("data-jsenv-client-toast", "");
472
+ el.style.cssText = [
473
+ "position:fixed",
474
+ "z-index:2147483647",
475
+ "right:16px",
476
+ "bottom:16px",
477
+ "max-width:320px",
478
+ "padding:12px 14px",
479
+ "font:13px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
480
+ "color:#fff",
481
+ "background:#1f2937",
482
+ "border-radius:8px",
483
+ "box-shadow:0 6px 20px rgba(0,0,0,0.35)",
484
+ ].join(";");
485
+ el.innerHTML = `<div style="font-weight:600;margin-bottom:4px">${label}</div><div style="opacity:.8;margin-bottom:8px">${describeClient(client)}</div>`;
486
+ const link = document.createElement("a");
487
+ link.href = `/.internal/client?id=${encodeURIComponent(client.id)}`;
488
+ link.target = "_blank";
489
+ link.textContent = "Monitor →";
490
+ link.style.cssText = "color:#93c5fd;text-decoration:none;font-weight:600";
491
+ el.appendChild(link);
492
+ const close = document.createElement("button");
493
+ close.textContent = "✕";
494
+ close.style.cssText =
495
+ "position:absolute;top:6px;right:8px;background:none;border:none;color:#9ca3af;cursor:pointer;font-size:12px";
496
+ close.onclick = () => el.remove();
497
+ el.appendChild(close);
498
+ document.body.appendChild(el);
499
+ setTimeout(() => el.remove(), 12000);
500
+ };
501
+
502
+ window.__client_monitoring__ = { setup };