@jsenv/terminal-recorder 1.5.52 → 1.5.53
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/package.json +2 -2
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs.map +4 -1620
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/node_modules/@xterm/xterm/lib/xterm.mjs.map +4 -3589
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/packages/internal/plugin-supervisor/src/client/supervisor.js +172 -4
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/src/plugins/client_monitoring/client/client_reporter.js +578 -0
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/src/plugins/dependency_status/client/dependency_status.js +59 -0
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/src/plugins/page_switcher/client/page_switcher.js +731 -0
- package/src/.jsenv/chrome@149.0.0/@fs/Users/dmail/Documents/dev/jsenv/core/src/plugins/server_events/client/server_events_client.js +461 -0
- package/src/.jsenv/chrome@149.0.0/xterm.html +20 -0
- package/src/.jsenv/chrome@149.0.0/xterm.html@L11C7-L15C16.js +5 -0
- package/src/.jsenv/chrome@149.0.0/xterm.html@L17C7-L21C16.js +5 -0
|
@@ -0,0 +1,578 @@
|
|
|
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
|
+
// 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
|
+
|
|
27
|
+
const CLIENT_ID_STORAGE_KEY = "jsenv_client_id";
|
|
28
|
+
const TAB_ID_STORAGE_KEY = "jsenv_tab_id";
|
|
29
|
+
const REPORT_ENDPOINT = "/.internal/clients/report";
|
|
30
|
+
// Flush buffered logs/activities at most this often (a chatty page shouldn't
|
|
31
|
+
// POST per line).
|
|
32
|
+
const FLUSH_INTERVAL_MS = 1000;
|
|
33
|
+
// Heartbeat so the server keeps seeing this client as "online" while the page is
|
|
34
|
+
// open, and notices when it is picked back up after a quiet spell.
|
|
35
|
+
const HEARTBEAT_MS = 15000;
|
|
36
|
+
// Continuous activities (mousemove, scroll) only need to refresh "what the tab
|
|
37
|
+
// is doing" occasionally, not on every event.
|
|
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;
|
|
45
|
+
|
|
46
|
+
const randomId = () =>
|
|
47
|
+
typeof window.crypto !== "undefined" && window.crypto.randomUUID
|
|
48
|
+
? window.crypto.randomUUID()
|
|
49
|
+
: `${String(Math.random()).slice(2)}${Date.now().toString(36)}`;
|
|
50
|
+
|
|
51
|
+
const getClientId = () => {
|
|
52
|
+
try {
|
|
53
|
+
let id = localStorage.getItem(CLIENT_ID_STORAGE_KEY);
|
|
54
|
+
if (!id) {
|
|
55
|
+
id = randomId();
|
|
56
|
+
localStorage.setItem(CLIENT_ID_STORAGE_KEY, id);
|
|
57
|
+
}
|
|
58
|
+
return id;
|
|
59
|
+
} catch {
|
|
60
|
+
// storage may be unavailable (private mode, sandbox) — a per-load id is fine.
|
|
61
|
+
return `anon-${Date.now().toString(36)}`;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// sessionStorage is per-tab and survives a reload, so it's a good tab identity.
|
|
66
|
+
const getTabId = () => {
|
|
67
|
+
try {
|
|
68
|
+
let id = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
|
69
|
+
if (!id) {
|
|
70
|
+
id = randomId();
|
|
71
|
+
sessionStorage.setItem(TAB_ID_STORAGE_KEY, id);
|
|
72
|
+
}
|
|
73
|
+
return id;
|
|
74
|
+
} catch {
|
|
75
|
+
return `tab-${Date.now().toString(36)}`;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Best-effort, non-throwing stringify of console arguments.
|
|
80
|
+
const jsonReplacer = () => {
|
|
81
|
+
const seen = new WeakSet();
|
|
82
|
+
return (key, value) => {
|
|
83
|
+
if (typeof value === "object" && value !== null) {
|
|
84
|
+
if (seen.has(value)) {
|
|
85
|
+
return "[Circular]";
|
|
86
|
+
}
|
|
87
|
+
seen.add(value);
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === "function") {
|
|
90
|
+
return `[Function ${value.name || "anonymous"}]`;
|
|
91
|
+
}
|
|
92
|
+
if (typeof value === "bigint") {
|
|
93
|
+
return `${value}n`;
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const formatArg = (arg) => {
|
|
99
|
+
if (typeof arg === "string") {
|
|
100
|
+
return arg;
|
|
101
|
+
}
|
|
102
|
+
if (arg instanceof Error) {
|
|
103
|
+
return arg.stack || `${arg.name}: ${arg.message}`;
|
|
104
|
+
}
|
|
105
|
+
if (arg === undefined) {
|
|
106
|
+
return "undefined";
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return JSON.stringify(arg, jsonReplacer());
|
|
110
|
+
} catch {
|
|
111
|
+
return String(arg);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
// Reproduce console formatting: %c sets CSS for the following text, %s/%d/%i/%f
|
|
115
|
+
// substitute values, %o/%O stringify objects. Returns a plain `text` (no CSS,
|
|
116
|
+
// good for copy/paste) plus, when any styling is present, `segments` of
|
|
117
|
+
// { text, css } so the monitor can render colors like the devtools console do.
|
|
118
|
+
const formatConsole = (args) => {
|
|
119
|
+
const first = args[0];
|
|
120
|
+
if (typeof first !== "string" || !/%[csdifoO]/.test(first)) {
|
|
121
|
+
return { text: args.map(formatArg).join(" ") };
|
|
122
|
+
}
|
|
123
|
+
const segments = [];
|
|
124
|
+
let buffer = "";
|
|
125
|
+
let css = "";
|
|
126
|
+
const flush = () => {
|
|
127
|
+
if (buffer) {
|
|
128
|
+
segments.push({ text: buffer, css });
|
|
129
|
+
buffer = "";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
let argIndex = 1;
|
|
133
|
+
let i = 0;
|
|
134
|
+
while (i < first.length) {
|
|
135
|
+
const char = first[i];
|
|
136
|
+
const spec = char === "%" ? first[i + 1] : "";
|
|
137
|
+
if (spec === "%") {
|
|
138
|
+
buffer += "%";
|
|
139
|
+
i += 2;
|
|
140
|
+
} else if (spec === "c") {
|
|
141
|
+
flush();
|
|
142
|
+
css = argIndex < args.length ? String(args[argIndex++]) : "";
|
|
143
|
+
i += 2;
|
|
144
|
+
} else if (spec !== "" && "sdifoO".includes(spec)) {
|
|
145
|
+
const value = argIndex < args.length ? args[argIndex++] : undefined;
|
|
146
|
+
buffer += spec === "s" ? String(value) : formatArg(value);
|
|
147
|
+
i += 2;
|
|
148
|
+
} else {
|
|
149
|
+
buffer += char;
|
|
150
|
+
i += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
while (argIndex < args.length) {
|
|
155
|
+
segments.push({ text: ` ${formatArg(args[argIndex++])}`, css: "" });
|
|
156
|
+
}
|
|
157
|
+
const text = segments.map((segment) => segment.text).join("");
|
|
158
|
+
const styled = segments.some((segment) => segment.css);
|
|
159
|
+
return styled ? { text, segments } : { text };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const setup = () => {
|
|
163
|
+
const clientId = getClientId();
|
|
164
|
+
const tabId = getTabId();
|
|
165
|
+
// Use the unwrapped fetch for our own reports so they don't count as activity.
|
|
166
|
+
const nativeFetch = window.fetch.bind(window);
|
|
167
|
+
|
|
168
|
+
const tabInfo = ({ closing = false } = {}) => ({
|
|
169
|
+
id: tabId,
|
|
170
|
+
url: window.location.href,
|
|
171
|
+
title: document.title,
|
|
172
|
+
visible: document.visibilityState === "visible",
|
|
173
|
+
closing,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Buffer logs and activities; POST them (with the current tab) in batches. The
|
|
177
|
+
// server reads the browser and OS from the request's own headers, so the body
|
|
178
|
+
// only carries the id, tab, activities and log entries.
|
|
179
|
+
let pendingLogs = [];
|
|
180
|
+
let pendingActivities = [];
|
|
181
|
+
const post = async ({ beacon = false, closing = false } = {}) => {
|
|
182
|
+
const logs = pendingLogs;
|
|
183
|
+
const activities = pendingActivities;
|
|
184
|
+
pendingLogs = [];
|
|
185
|
+
pendingActivities = [];
|
|
186
|
+
const payload = JSON.stringify({
|
|
187
|
+
clientId,
|
|
188
|
+
tab: tabInfo({ closing }),
|
|
189
|
+
activities,
|
|
190
|
+
logs,
|
|
191
|
+
});
|
|
192
|
+
if (beacon && window.navigator.sendBeacon) {
|
|
193
|
+
window.navigator.sendBeacon(
|
|
194
|
+
REPORT_ENDPOINT,
|
|
195
|
+
new Blob([payload], { type: "application/json" }),
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await nativeFetch(REPORT_ENDPOINT, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "content-type": "application/json" },
|
|
203
|
+
body: payload,
|
|
204
|
+
keepalive: true,
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
// dev server gone or offline — dropping the report is acceptable.
|
|
208
|
+
}
|
|
209
|
+
};
|
|
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
|
+
|
|
233
|
+
let flushScheduled = false;
|
|
234
|
+
const scheduleFlush = () => {
|
|
235
|
+
if (flushScheduled) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
flushScheduled = true;
|
|
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
|
+
}
|
|
245
|
+
flushScheduled = false;
|
|
246
|
+
if (!pendingLogs.length && !pendingActivities.length) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
post();
|
|
250
|
+
};
|
|
251
|
+
setTimeout(attempt, FLUSH_INTERVAL_MS);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const pushLog = (entry) => {
|
|
255
|
+
pendingLogs.push({ ...entry, ts: entry.ts ?? Date.now() });
|
|
256
|
+
if (pendingLogs.length > 500) {
|
|
257
|
+
pendingLogs.shift(); // cap the buffer if the server is unreachable
|
|
258
|
+
}
|
|
259
|
+
scheduleFlush();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Formatting a console call (%c parsing + serializing object args) is
|
|
263
|
+
// expensive, and verbose debug logging fires it hundreds of times inside a
|
|
264
|
+
// single interaction — enough to dominate the main thread and delay paint.
|
|
265
|
+
// So capture the raw args cheaply on the hot path and format them only when
|
|
266
|
+
// the browser is idle (chunked to respect the idle deadline). Serialization
|
|
267
|
+
// is kept in full; it just no longer blocks the interaction.
|
|
268
|
+
const rawConsoleQueue = [];
|
|
269
|
+
let consoleProcessScheduled = false;
|
|
270
|
+
// requestIdleCallback is missing on Safari/iOS (our main mobile target), so
|
|
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
|
+
|
|
278
|
+
const scheduleIdle =
|
|
279
|
+
typeof requestIdleCallback === "function"
|
|
280
|
+
? (fn) => requestIdleCallback(fn, { timeout: 1000 })
|
|
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;
|
|
286
|
+
const formatOne = (captured) => {
|
|
287
|
+
const formatted = formatConsole(captured.args);
|
|
288
|
+
pushLog({
|
|
289
|
+
level: captured.level,
|
|
290
|
+
ts: captured.ts,
|
|
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,
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
const processConsoleQueue = (deadline) => {
|
|
304
|
+
consoleProcessScheduled = false;
|
|
305
|
+
const start = performance.now();
|
|
306
|
+
while (rawConsoleQueue.length) {
|
|
307
|
+
// Yield within ~8ms (setTimeout fallback) or when idle time runs out (rIC),
|
|
308
|
+
// so a burst of logs never becomes a long task itself.
|
|
309
|
+
const outOfTime = deadline
|
|
310
|
+
? !deadline.didTimeout && deadline.timeRemaining() <= 1
|
|
311
|
+
: performance.now() - start > 8;
|
|
312
|
+
if (outOfTime) {
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
formatOne(rawConsoleQueue.shift());
|
|
316
|
+
}
|
|
317
|
+
if (rawConsoleQueue.length) {
|
|
318
|
+
scheduleConsoleProcess();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const scheduleConsoleProcess = () => {
|
|
322
|
+
if (consoleProcessScheduled) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
consoleProcessScheduled = true;
|
|
326
|
+
scheduleIdle(processConsoleQueue);
|
|
327
|
+
};
|
|
328
|
+
// Format everything now (page unloading — losing logs is worse than the cost).
|
|
329
|
+
const drainConsoleQueue = () => {
|
|
330
|
+
while (rawConsoleQueue.length) {
|
|
331
|
+
formatOne(rawConsoleQueue.shift());
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const captureConsole = (level, args) => {
|
|
335
|
+
rawConsoleQueue.push({ level, args, ts: Date.now() });
|
|
336
|
+
if (rawConsoleQueue.length > 2000) {
|
|
337
|
+
rawConsoleQueue.shift(); // bound memory if idle never runs
|
|
338
|
+
}
|
|
339
|
+
scheduleConsoleProcess();
|
|
340
|
+
};
|
|
341
|
+
const recordActivity = (type, detail = "") => {
|
|
342
|
+
pendingActivities.push({ type, detail, ts: Date.now(), tabId });
|
|
343
|
+
if (pendingActivities.length > 50) {
|
|
344
|
+
pendingActivities.shift();
|
|
345
|
+
}
|
|
346
|
+
scheduleFlush();
|
|
347
|
+
};
|
|
348
|
+
|
|
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
|
+
};
|
|
363
|
+
}
|
|
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
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
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}`);
|
|
430
|
+
}
|
|
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") {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
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
|
+
|
|
458
|
+
document.addEventListener("visibilitychange", () => {
|
|
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
|
+
}
|
|
468
|
+
// push promptly so the dashboard's "active tab" tracks focus changes
|
|
469
|
+
post();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Heartbeat keeps the client "online", refreshes tab info, and lets the server
|
|
473
|
+
// detect a resume.
|
|
474
|
+
post();
|
|
475
|
+
const heartbeatId = setInterval(() => post(), HEARTBEAT_MS);
|
|
476
|
+
window.addEventListener("pagehide", () => {
|
|
477
|
+
clearInterval(heartbeatId);
|
|
478
|
+
// Format whatever was captured but not yet processed, so the final beacon
|
|
479
|
+
// doesn't drop logs.
|
|
480
|
+
drainConsoleQueue();
|
|
481
|
+
post({ beacon: true, closing: true });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Toast when another client appears, and obey pilot commands aimed at us —
|
|
485
|
+
// both reuse the server-events channel. window.__server_events__ is injected
|
|
486
|
+
// before this script, so it's ready.
|
|
487
|
+
window.__server_events__.listenEvents({
|
|
488
|
+
client_here: (event) => {
|
|
489
|
+
const { client, reason } = event.data;
|
|
490
|
+
if (client.id === clientId) {
|
|
491
|
+
return; // don't toast a client about itself
|
|
492
|
+
}
|
|
493
|
+
showClientToast({ client, reason });
|
|
494
|
+
},
|
|
495
|
+
client_command: (event) => {
|
|
496
|
+
const { clientId: targetId, tabId: targetTabId, type, url } = event.data;
|
|
497
|
+
if (targetId !== clientId) {
|
|
498
|
+
return; // aimed at another client
|
|
499
|
+
}
|
|
500
|
+
if (targetTabId && targetTabId !== tabId) {
|
|
501
|
+
return; // aimed at a specific tab of this client, not this one
|
|
502
|
+
}
|
|
503
|
+
if (type === "navigate" && url) {
|
|
504
|
+
window.location.href = url;
|
|
505
|
+
} else if (type === "reload") {
|
|
506
|
+
window.location.reload();
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
// jsenv autoreload broadcasts "reload" for hot updates and full reloads. A
|
|
510
|
+
// full reload navigates away before this flushes and surfaces as "load" on
|
|
511
|
+
// the next page; a hot update stays on the page, so this is what records it.
|
|
512
|
+
reload: (event) => {
|
|
513
|
+
if (isLocalClient) {
|
|
514
|
+
return; // the main client records no activity
|
|
515
|
+
}
|
|
516
|
+
const data = event.data || {};
|
|
517
|
+
const reason =
|
|
518
|
+
(typeof data.reason === "string" && data.reason) ||
|
|
519
|
+
(typeof data.cause === "string" && data.cause) ||
|
|
520
|
+
"";
|
|
521
|
+
recordActivity("hot_reload", reason);
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// "Chrome 149 · iOS 17.2" from the parsed runtime/os, falling back gracefully.
|
|
527
|
+
const describeClient = (client) => {
|
|
528
|
+
const version = (v) => (v && v !== "unknown" ? ` ${v.split(".")[0]}` : "");
|
|
529
|
+
const runtime = client.runtime || {};
|
|
530
|
+
const os = client.os || {};
|
|
531
|
+
const browserLabel =
|
|
532
|
+
runtime.name && runtime.name !== "unknown"
|
|
533
|
+
? `${runtime.name}${version(runtime.version)}`
|
|
534
|
+
: "";
|
|
535
|
+
const osLabel =
|
|
536
|
+
os.name && os.name !== "unknown" ? `${os.name}${version(os.version)}` : "";
|
|
537
|
+
return (
|
|
538
|
+
[browserLabel, osLabel].filter(Boolean).join(" · ") || "unknown client"
|
|
539
|
+
);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const showClientToast = ({ client, reason }) => {
|
|
543
|
+
const label =
|
|
544
|
+
reason === "new" ? "A new client connected" : "A client resumed";
|
|
545
|
+
const el = document.createElement("div");
|
|
546
|
+
el.setAttribute("data-jsenv-client-toast", "");
|
|
547
|
+
el.style.cssText = [
|
|
548
|
+
"position:fixed",
|
|
549
|
+
"z-index:2147483647",
|
|
550
|
+
"right:16px",
|
|
551
|
+
"bottom:16px",
|
|
552
|
+
"max-width:320px",
|
|
553
|
+
"padding:12px 14px",
|
|
554
|
+
"font:13px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
|
|
555
|
+
"color:#fff",
|
|
556
|
+
"background:#1f2937",
|
|
557
|
+
"border-radius:8px",
|
|
558
|
+
"box-shadow:0 6px 20px rgba(0,0,0,0.35)",
|
|
559
|
+
].join(";");
|
|
560
|
+
el.innerHTML = `<div style="font-weight:600;margin-bottom:4px">${label}</div><div style="opacity:.8;margin-bottom:8px">${describeClient(client)}</div>`;
|
|
561
|
+
const link = document.createElement("a");
|
|
562
|
+
link.href = `/.internal/client?id=${encodeURIComponent(client.id)}`;
|
|
563
|
+
link.target = "_blank";
|
|
564
|
+
link.rel = "noopener";
|
|
565
|
+
link.textContent = "Monitor ↗";
|
|
566
|
+
link.style.cssText = "color:#93c5fd;text-decoration:none;font-weight:600";
|
|
567
|
+
el.appendChild(link);
|
|
568
|
+
const close = document.createElement("button");
|
|
569
|
+
close.textContent = "✕";
|
|
570
|
+
close.style.cssText =
|
|
571
|
+
"position:absolute;top:6px;right:8px;background:none;border:none;color:#9ca3af;cursor:pointer;font-size:12px";
|
|
572
|
+
close.onclick = () => el.remove();
|
|
573
|
+
el.appendChild(close);
|
|
574
|
+
document.body.appendChild(el);
|
|
575
|
+
setTimeout(() => el.remove(), 12000);
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
window.__client_monitoring__ = { setup };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Displays the "some dependencies are outdated" warning overlay.
|
|
3
|
+
*
|
|
4
|
+
* A missing dependency is not handled here: it makes the import fail, so the
|
|
5
|
+
* error overlay already says it, with more precision (which import, where).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let removeOverlay = () => {};
|
|
9
|
+
|
|
10
|
+
export const initDependencyStatus = ({ problems }) => {
|
|
11
|
+
render(problems);
|
|
12
|
+
// without the server events channel the page only knows the state it was
|
|
13
|
+
// served with, which is still better than nothing
|
|
14
|
+
if (window.__server_events__) {
|
|
15
|
+
window.__server_events__.listenEvents({
|
|
16
|
+
dependency_status: (event) => {
|
|
17
|
+
render(event.data.problems);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const render = (problems) => {
|
|
24
|
+
const outdatedList = problems.filter(({ state }) => state === "outdated");
|
|
25
|
+
removeOverlay();
|
|
26
|
+
removeOverlay = () => {};
|
|
27
|
+
if (outdatedList.length === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const supervisor = window.__supervisor__;
|
|
31
|
+
if (!supervisor || !supervisor.reportWarning) {
|
|
32
|
+
console.warn(summarize(outdatedList));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
removeOverlay = supervisor.reportWarning({
|
|
36
|
+
title:
|
|
37
|
+
outdatedList.length === 1
|
|
38
|
+
? "A dependency is outdated"
|
|
39
|
+
: `${outdatedList.length} dependencies are outdated`,
|
|
40
|
+
text: outdatedList.map(describe).join("\n\n"),
|
|
41
|
+
details: [
|
|
42
|
+
"The page is running with what is installed in node_modules, which is not what package.json asks for. It may work, but it is not the code the project expects.",
|
|
43
|
+
"Run npm install to fix it. If an install is already running, there is nothing to do but wait.",
|
|
44
|
+
"This page reloads on its own as soon as node_modules matches package.json.",
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const describe = ({ packageName, declaredVersion, installedVersion }) => {
|
|
50
|
+
return `${packageName}
|
|
51
|
+
package.json declares ${declaredVersion}
|
|
52
|
+
node_modules holds ${installedVersion || "an unknown version"}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const summarize = (outdatedList) => {
|
|
56
|
+
return `[jsenv] outdated dependencies, run npm install: ${outdatedList
|
|
57
|
+
.map(({ packageName }) => packageName)
|
|
58
|
+
.join(", ")}`;
|
|
59
|
+
};
|