@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.
@@ -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
+ };