@jsenv/core 41.5.34 → 41.5.37

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.
@@ -1,4 +1,265 @@
1
- /*
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Server clients inspector</title>
7
+ <style>body {
8
+ color: #111827;
9
+ background: #f3f4f6;
10
+ margin: 0;
11
+ padding: 24px;
12
+ font: 14px / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
13
+ }
14
+
15
+ h1 {
16
+ margin: 0 0 4px;
17
+ font-size: 1.4rem;
18
+ }
19
+
20
+ h2 {
21
+ color: #6b7280;
22
+ text-transform: uppercase;
23
+ letter-spacing: .04em;
24
+ margin: 26px 0 8px;
25
+ font-size: .95rem;
26
+ }
27
+
28
+ p.intro {
29
+ color: #6b7280;
30
+ margin: 0 0 8px;
31
+ }
32
+
33
+ table {
34
+ border-collapse: collapse;
35
+ background: #fff;
36
+ border-radius: 8px;
37
+ width: 100%;
38
+ overflow: hidden;
39
+ box-shadow: 0 1px 3px #0000001a;
40
+ }
41
+
42
+ th, td {
43
+ text-align: left;
44
+ vertical-align: middle;
45
+ border-bottom: 1px solid #eef2f7;
46
+ padding: 10px 14px;
47
+ }
48
+
49
+ th {
50
+ color: #6b7280;
51
+ text-transform: uppercase;
52
+ letter-spacing: .04em;
53
+ background: #f9fafb;
54
+ font-size: .78rem;
55
+ }
56
+
57
+ tr:last-child td {
58
+ border-bottom: none;
59
+ }
60
+
61
+ .empty {
62
+ color: #9ca3af;
63
+ text-align: center;
64
+ padding: 22px;
65
+ }
66
+
67
+ .muted {
68
+ color: #9ca3af;
69
+ }
70
+
71
+ .when {
72
+ color: #374151;
73
+ white-space: nowrap;
74
+ }
75
+
76
+ .url {
77
+ color: #374151;
78
+ text-align: left;
79
+ text-overflow: ellipsis;
80
+ white-space: nowrap;
81
+ vertical-align: middle;
82
+ direction: rtl;
83
+ max-width: 240px;
84
+ display: inline-block;
85
+ overflow: hidden;
86
+ }
87
+
88
+ .tag {
89
+ color: #4338ca;
90
+ background: #eef2ff;
91
+ border-radius: 999px;
92
+ padding: 1px 7px;
93
+ font-size: .72rem;
94
+ font-weight: 600;
95
+ display: inline-block;
96
+ }
97
+
98
+ .tag.on {
99
+ color: #047857;
100
+ background: #d1fae5;
101
+ }
102
+
103
+ .tag.ellip {
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ vertical-align: middle;
107
+ max-width: 320px;
108
+ overflow: hidden;
109
+ }
110
+
111
+ .actions {
112
+ flex-wrap: wrap;
113
+ align-items: center;
114
+ gap: 6px;
115
+ display: flex;
116
+ }
117
+
118
+ .btn {
119
+ color: #374151;
120
+ font: inherit;
121
+ cursor: pointer;
122
+ background: #f3f4f6;
123
+ border: 1px solid #d1d5db;
124
+ border-radius: 6px;
125
+ padding: 2px 9px;
126
+ font-size: .75rem;
127
+ }
128
+
129
+ .btn:hover {
130
+ background: #e5e7eb;
131
+ }
132
+
133
+ .btn.primary {
134
+ color: #fff;
135
+ background: #2563eb;
136
+ border-color: #2563eb;
137
+ }
138
+
139
+ .btn.primary:hover {
140
+ background: #1d4ed8;
141
+ }
142
+
143
+ .monitor {
144
+ color: #2563eb;
145
+ white-space: nowrap;
146
+ margin-left: 4px;
147
+ padding: 2px 4px;
148
+ font-size: .8rem;
149
+ font-weight: 600;
150
+ text-decoration: none;
151
+ }
152
+
153
+ .monitor:hover {
154
+ text-decoration: underline;
155
+ }
156
+
157
+ dialog {
158
+ border: none;
159
+ border-radius: 10px;
160
+ width: min(90vw, 640px);
161
+ padding: 0;
162
+ box-shadow: 0 20px 60px #0000004d;
163
+ }
164
+
165
+ dialog::backdrop {
166
+ background: #0006;
167
+ }
168
+
169
+ .dlg-head {
170
+ border-bottom: 1px solid #eef2f7;
171
+ align-items: center;
172
+ gap: 8px;
173
+ padding: 12px 16px;
174
+ display: flex;
175
+ }
176
+
177
+ .dlg-head .spacer {
178
+ flex: 1;
179
+ }
180
+
181
+ .dlg-body {
182
+ max-height: 60vh;
183
+ padding: 14px 16px;
184
+ overflow: auto;
185
+ }
186
+
187
+ .dlg-body pre {
188
+ white-space: pre-wrap;
189
+ word-break: break-all;
190
+ margin: 0;
191
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
192
+ font-size: 12px;
193
+ }
194
+
195
+ ul.list {
196
+ margin: 0;
197
+ padding: 0;
198
+ list-style: none;
199
+ }
200
+
201
+ ul.list li {
202
+ border-bottom: 1px solid #f1f5f9;
203
+ padding: 8px 0;
204
+ }
205
+
206
+ ul.list li:last-child {
207
+ border-bottom: none;
208
+ }
209
+
210
+ .url2 {
211
+ color: #374151;
212
+ word-break: break-all;
213
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
214
+ font-size: 12px;
215
+ }
216
+
217
+ .ttl {
218
+ font-weight: 600;
219
+ }
220
+ </style>
221
+ </head>
222
+ <body>
223
+ <h1>Server clients inspector</h1>
224
+ <p class="intro">
225
+ Every browser that has connected to this dev server since it started. The
226
+ columns are a résumé; use the actions to dig in or monitor a client's
227
+ logs.
228
+ </p>
229
+
230
+ <h2>This browser</h2>
231
+ <table>
232
+ <thead id="head"></thead>
233
+ <tbody id="self-rows"></tbody>
234
+ </table>
235
+
236
+ <h2>Other clients</h2>
237
+ <table>
238
+ <thead id="head2"></thead>
239
+ <tbody id="other-rows"></tbody>
240
+ </table>
241
+
242
+ <h2>Headless browsers</h2>
243
+ <p class="intro">
244
+ Browsers driven by a program — a test run, a screenshot script — rather
245
+ than by someone looking at them.
246
+ </p>
247
+ <table>
248
+ <thead id="head3"></thead>
249
+ <tbody id="headless-rows"></tbody>
250
+ </table>
251
+
252
+ <dialog id="dlg">
253
+ <div class="dlg-head">
254
+ <strong id="dlg-title"></strong>
255
+ <span class="spacer"></span>
256
+ <button class="btn" id="dlg-copy">Copy</button>
257
+ <button class="btn" id="dlg-close">Close</button>
258
+ </div>
259
+ <div class="dlg-body" id="dlg-body"></div>
260
+ </dialog>
261
+
262
+ <script type="module">/*
2
263
  * "Which page?", asked the same way everywhere.
3
264
  *
4
265
  * The .html files the dev server serves (GET /.internal/pages.json, see
@@ -808,4 +1069,391 @@ const isCommandKey = (event) =>
808
1069
  const isEditorKey = (event) =>
809
1070
  (event.key === "e" || event.key === "E") && isCommandKey(event);
810
1071
 
811
- export { asPageUrl, isCommandKey, isEditorKey, loadPages, openPagePicker };
1072
+ const selfRows = document.getElementById("self-rows");
1073
+ const otherRows = document.getElementById("other-rows");
1074
+ const headlessRows = document.getElementById("headless-rows");
1075
+ // The monitoring script (injected into this page too) stores our own id.
1076
+ let selfId = null;
1077
+ try {
1078
+ selfId = localStorage.getItem("jsenv_client_id");
1079
+ } catch {
1080
+ // storage blocked — we just won't single ourselves out
1081
+ }
1082
+
1083
+ const headHtml = `<tr>
1084
+ <th>First seen</th><th>OS</th><th>Browser</th><th>IP</th><th>Active tab</th>
1085
+ <th>Last activity</th><th>Logs</th><th></th>
1086
+ </tr>`;
1087
+ document.getElementById("head").innerHTML = headHtml;
1088
+ document.getElementById("head2").innerHTML = headHtml;
1089
+ document.getElementById("head3").innerHTML = headHtml;
1090
+ const COLSPAN = 8;
1091
+
1092
+ const ago = (ts) => {
1093
+ const s = Math.round((Date.now() - ts) / 1000);
1094
+ if (s < 5) return "just now";
1095
+ if (s < 60) return `${s}s ago`;
1096
+ const m = Math.round(s / 60);
1097
+ if (m < 60) return `${m}m ago`;
1098
+ const h = Math.round(m / 60);
1099
+ return `${h}h ago`;
1100
+ };
1101
+ const pad2 = (n) => String(n).padStart(2, "0");
1102
+ // Absolute date+time, kept for the "first seen" tooltip.
1103
+ const fmtDate = (ts) => {
1104
+ const d = new Date(ts);
1105
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
1106
+ };
1107
+ // Human relative time for "first seen" ("5 minutes ago", "yesterday"),
1108
+ // rendered with the largest unit that fits. Re-rendered every few seconds
1109
+ // (see the interval below) so it stays fresh.
1110
+ const relativeTimeFormat = new Intl.RelativeTimeFormat(undefined, {
1111
+ numeric: "auto",
1112
+ });
1113
+ const RELATIVE_UNITS = [
1114
+ ["year", 31536000],
1115
+ ["month", 2592000],
1116
+ ["week", 604800],
1117
+ ["day", 86400],
1118
+ ["hour", 3600],
1119
+ ["minute", 60],
1120
+ ];
1121
+ const relativeTime = (ts) => {
1122
+ const deltaSeconds = Math.round((ts - Date.now()) / 1000); // <0 = past
1123
+ const abs = deltaSeconds < 0 ? -deltaSeconds : deltaSeconds;
1124
+ for (const [unit, secondsInUnit] of RELATIVE_UNITS) {
1125
+ if (abs >= secondsInUnit) {
1126
+ return relativeTimeFormat.format(
1127
+ Math.round(deltaSeconds / secondsInUnit),
1128
+ unit,
1129
+ );
1130
+ }
1131
+ }
1132
+ return relativeTimeFormat.format(deltaSeconds, "second");
1133
+ };
1134
+ const escapeHtml = (str) =>
1135
+ String(str).replace(
1136
+ /[&<>"]/g,
1137
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
1138
+ );
1139
+ // Trim noisy sub-pixel precision for display (e.g. scroll "0/432.333" →
1140
+ // "0/432"); the full value stays in the stored/reported data.
1141
+ const roundNumbers = (text) =>
1142
+ String(text).replace(/-?\d+\.\d+/g, (n) => String(Math.round(n)));
1143
+ const shortUrl = (url) => {
1144
+ try {
1145
+ const u = new URL(url);
1146
+ return `${u.pathname}${u.search}${u.hash}` || "/";
1147
+ } catch {
1148
+ return url;
1149
+ }
1150
+ };
1151
+ // Last path segment of a URL ("…/8_picker_wheel_demo.html" → the filename),
1152
+ // for compact activity labels. Non-URLs pass through unchanged.
1153
+ const fileName = (url) => {
1154
+ try {
1155
+ const segments = new URL(url).pathname.split("/").filter(Boolean);
1156
+ return segments.length ? segments[segments.length - 1] : "/";
1157
+ } catch {
1158
+ return url;
1159
+ }
1160
+ };
1161
+ // The compact form of an activity detail for the "Last activity" column:
1162
+ // URL-valued details collapse to their filename, a request keeps its
1163
+ // method, coordinates lose sub-pixel noise. The full detail stays in the
1164
+ // tooltip and the activity dialog / monitor.
1165
+ const compactDetail = (type, detail) => {
1166
+ if (!detail) {
1167
+ return "";
1168
+ }
1169
+ if (type === "load" || type === "navigation") {
1170
+ return fileName(detail);
1171
+ }
1172
+ if (type === "request") {
1173
+ const spaceIndex = detail.indexOf(" ");
1174
+ return spaceIndex === -1
1175
+ ? fileName(detail)
1176
+ : `${detail.slice(0, spaceIndex)} ${fileName(detail.slice(spaceIndex + 1))}`;
1177
+ }
1178
+ return roundNumbers(detail);
1179
+ };
1180
+ const RUNTIME_LABELS = {
1181
+ ios_safari: "iOS Safari",
1182
+ chrome: "Chrome",
1183
+ edge: "Edge",
1184
+ opera: "Opera",
1185
+ samsung: "Samsung Internet",
1186
+ firefox: "Firefox",
1187
+ safari: "Safari",
1188
+ node: "Node",
1189
+ };
1190
+ const named = ({ name, version } = {}) => {
1191
+ if (!name || name === "unknown") {
1192
+ return '<span class="muted">unknown</span>';
1193
+ }
1194
+ const label = RUNTIME_LABELS[name] || name;
1195
+ const v = version && version !== "unknown" ? ` ${version}` : "";
1196
+ return escapeHtml(`${label}${v}`);
1197
+ };
1198
+
1199
+ const firstSeenCell = (d) =>
1200
+ `<span class="when" title="${escapeHtml(fmtDate(d.firstSeen))}">${escapeHtml(relativeTime(d.firstSeen))}</span>`;
1201
+ const tabCell = (d) => {
1202
+ if (!d.activeTab) {
1203
+ return '<span class="muted">—</span>';
1204
+ }
1205
+ const url = d.activeTab.url || "";
1206
+ return `<span class="url" title="${escapeHtml(url)}"><bdi dir="ltr">${escapeHtml(shortUrl(url))}</bdi></span>`;
1207
+ };
1208
+ const activityCell = (d) => {
1209
+ const a = d.lastActivity;
1210
+ if (!a) {
1211
+ return '<span class="muted">—</span>';
1212
+ }
1213
+ const compact = a.detail
1214
+ ? `${a.type}: ${compactDetail(a.type, a.detail)}`
1215
+ : a.type;
1216
+ const full = a.detail ? `${a.type}: ${a.detail}` : a.type;
1217
+ return `<span class="tag ellip" title="${escapeHtml(full)}">${escapeHtml(compact)}</span> <span class="muted">${ago(a.ts)}</span>`;
1218
+ };
1219
+ const actionsCell = (d) => {
1220
+ const id = escapeHtml(d.id);
1221
+ return `<div class="actions">
1222
+ <button class="btn primary" data-action="navigate" data-id="${id}">Navigate…</button>
1223
+ <button class="btn" data-action="reload" data-id="${id}">Reload</button>
1224
+ <button class="btn" data-action="tabs" data-id="${id}">Tabs</button>
1225
+ <button class="btn" data-action="activity" data-id="${id}">Activity</button>
1226
+ <button class="btn" data-action="ua" data-id="${id}">User agent</button>
1227
+ <a class="monitor" href="/.internal/client?id=${encodeURIComponent(d.id)}">Monitor →</a>
1228
+ </div>`;
1229
+ };
1230
+ // The ip says how the client reaches the server, which is what tells the
1231
+ // main client (this machine, via localhost) from a device on the network.
1232
+ const ipCell = (d) => {
1233
+ if (!d.ip) {
1234
+ return '<span class="muted">—</span>';
1235
+ }
1236
+ const label = d.local ? "localhost" : d.ip;
1237
+ return `<span title="${escapeHtml(d.ip)}">${escapeHtml(label)}</span>`;
1238
+ };
1239
+ const rowHtml = (d) => {
1240
+ return `<tr>
1241
+ <td>${firstSeenCell(d)}</td>
1242
+ <td>${named(d.os)}</td>
1243
+ <td>${named(d.runtime)}</td>
1244
+ <td>${ipCell(d)}</td>
1245
+ <td>${tabCell(d)}</td>
1246
+ <td>${activityCell(d)}</td>
1247
+ <td>${d.logCount}</td>
1248
+ <td>${actionsCell(d)}</td>
1249
+ </tr>`;
1250
+ };
1251
+
1252
+ let lastClients = [];
1253
+ const render = (clients) => {
1254
+ lastClients = clients;
1255
+ const self = selfId ? clients.find((d) => d.id === selfId) : null;
1256
+ // The client someone is using right now comes first; among the quiet
1257
+ // ones, the newest. A client that never reported an activity sorts
1258
+ // after those that did.
1259
+ const byRecency = (a, b) => {
1260
+ const aActivity = a.lastActivity ? a.lastActivity.ts : 0;
1261
+ const bActivity = b.lastActivity ? b.lastActivity.ts : 0;
1262
+ if (aActivity !== bActivity) {
1263
+ return bActivity - aActivity;
1264
+ }
1265
+ return b.firstSeen - a.firstSeen;
1266
+ };
1267
+ // A headless browser is a program's, not a person's: it comes and goes
1268
+ // with a test run, so it gets its own table instead of pushing the
1269
+ // clients someone is actually browsing with out of sight.
1270
+ const others = clients
1271
+ .filter((d) => d !== self && !d.headless)
1272
+ .sort(byRecency);
1273
+ const headless = clients
1274
+ .filter((d) => d !== self && d.headless)
1275
+ .sort(byRecency);
1276
+ selfRows.innerHTML = self
1277
+ ? rowHtml(self)
1278
+ : `<tr><td class="empty" colspan="${COLSPAN}">This browser isn't registered yet — it will appear shortly.</td></tr>`;
1279
+ otherRows.innerHTML = others.length
1280
+ ? others.map(rowHtml).join("")
1281
+ : `<tr><td class="empty" colspan="${COLSPAN}">No other client has connected.</td></tr>`;
1282
+ headlessRows.innerHTML = headless.length
1283
+ ? headless.map(rowHtml).join("")
1284
+ : `<tr><td class="empty" colspan="${COLSPAN}">No headless browser has connected.</td></tr>`;
1285
+ };
1286
+
1287
+ // Dialogs (all tabs / recent activity / user agent).
1288
+ const dlg = document.getElementById("dlg");
1289
+ const dlgTitle = document.getElementById("dlg-title");
1290
+ const dlgBody = document.getElementById("dlg-body");
1291
+ const dlgCopy = document.getElementById("dlg-copy");
1292
+ document.getElementById("dlg-close").onclick = () => dlg.close();
1293
+ const openDialog = (title, html, copyText) => {
1294
+ dlgTitle.textContent = title;
1295
+ dlgBody.innerHTML = html;
1296
+ if (copyText === undefined) {
1297
+ dlgCopy.style.display = "none";
1298
+ } else {
1299
+ dlgCopy.style.display = "";
1300
+ dlgCopy.textContent = "Copy";
1301
+ dlgCopy.onclick = async () => {
1302
+ try {
1303
+ await window.navigator.clipboard.writeText(copyText);
1304
+ dlgCopy.textContent = "Copied!";
1305
+ setTimeout(() => {
1306
+ dlgCopy.textContent = "Copy";
1307
+ }, 1200);
1308
+ } catch {
1309
+ // clipboard blocked — ignore
1310
+ }
1311
+ };
1312
+ }
1313
+ dlg.showModal();
1314
+ };
1315
+
1316
+ const fetchClient = async (id) => {
1317
+ try {
1318
+ return await (
1319
+ await fetch(`/.internal/client.json?id=${encodeURIComponent(id)}`)
1320
+ ).json();
1321
+ } catch {
1322
+ return null;
1323
+ }
1324
+ };
1325
+ const tabsHtml = (tabs) => {
1326
+ if (!tabs.length) {
1327
+ return '<p class="muted">No tab.</p>';
1328
+ }
1329
+ return `<ul class="list">${tabs
1330
+ .slice()
1331
+ .sort((a, b) => b.visible - a.visible || b.lastSeen - a.lastSeen)
1332
+ .map(
1333
+ (t) => `<li>
1334
+ <div>${t.visible ? '<span class="tag on">active</span> ' : ""}<span class="ttl">${escapeHtml(t.title) || '<span class="muted">(no title)</span>'}</span> <span class="muted">${ago(t.lastSeen)}</span></div>
1335
+ <div class="url2">${escapeHtml(t.url)}</div>
1336
+ </li>`,
1337
+ )
1338
+ .join("")}</ul>`;
1339
+ };
1340
+ const activitiesHtml = (activities) => {
1341
+ if (!activities.length) {
1342
+ return '<p class="muted">No activity yet.</p>';
1343
+ }
1344
+ return `<ul class="list">${activities
1345
+ .slice()
1346
+ .reverse()
1347
+ .map(
1348
+ (a) => `<li>
1349
+ <div><span class="tag">${escapeHtml(a.type)}</span> <span class="muted">${ago(a.ts)}</span></div>
1350
+ ${a.detail ? `<div class="url2">${escapeHtml(roundNumbers(a.detail))}</div>` : ""}
1351
+ </li>`,
1352
+ )
1353
+ .join("")}</ul>`;
1354
+ };
1355
+
1356
+ // Pilot a client: POST a command; the server broadcasts it and the
1357
+ // matching reporter runs it (navigate/reload) on the targeted tab.
1358
+ const sendCommand = async (payload) => {
1359
+ try {
1360
+ await fetch("/.internal/clients/command", {
1361
+ method: "POST",
1362
+ headers: { "content-type": "application/json" },
1363
+ body: JSON.stringify(payload),
1364
+ });
1365
+ } catch {
1366
+ // dev server gone — ignore
1367
+ }
1368
+ };
1369
+ const shortLabel = (d) => {
1370
+ const parts = [d.os && d.os.name, d.runtime && d.runtime.name].filter(
1371
+ Boolean,
1372
+ );
1373
+ return parts.length ? parts.join(" ") : d.id.slice(0, 8);
1374
+ };
1375
+ // Which page to send a client to is the same question cmd+K asks on any
1376
+ // dev-served page, so it is the same tree that answers it (see
1377
+ // protocol_file/client/page_picker.js) — filters, folding and all. Only
1378
+ // the ending differs: the url goes to the client's active tab (tabId from
1379
+ // activeTab, so no other tab moves) instead of to this window. No href on
1380
+ // the rows for that very reason: a click here must not take THIS browser
1381
+ // to a page meant for another one.
1382
+ const openNavigateDialog = (client) => {
1383
+ openPagePicker({
1384
+ title: `Navigate ${shortLabel(client)} to…`,
1385
+ placeholder: "Go to page…",
1386
+ hereUrl: client.activeTab ? client.activeTab.url : null,
1387
+ onPick: (page) => {
1388
+ sendCommand({
1389
+ clientId: client.id,
1390
+ tabId: client.activeTab ? client.activeTab.id : null,
1391
+ type: "navigate",
1392
+ url: page.url,
1393
+ });
1394
+ },
1395
+ });
1396
+ };
1397
+
1398
+ document.addEventListener("click", async (event) => {
1399
+ const button = event.target.closest("button[data-action]");
1400
+ if (!button) {
1401
+ return;
1402
+ }
1403
+ const id = button.getAttribute("data-id");
1404
+ const action = button.getAttribute("data-action");
1405
+ if (action === "ua") {
1406
+ const client = lastClients.find((d) => d.id === id);
1407
+ const ua = (client && client.userAgent) || "";
1408
+ openDialog("User agent", `<pre>${escapeHtml(ua)}</pre>`, ua);
1409
+ return;
1410
+ }
1411
+ if (action === "navigate") {
1412
+ const client = lastClients.find((d) => d.id === id);
1413
+ if (client) {
1414
+ openNavigateDialog(client);
1415
+ }
1416
+ return;
1417
+ }
1418
+ if (action === "reload") {
1419
+ const client = lastClients.find((d) => d.id === id);
1420
+ sendCommand({
1421
+ clientId: id,
1422
+ tabId: client && client.activeTab ? client.activeTab.id : null,
1423
+ type: "reload",
1424
+ });
1425
+ return;
1426
+ }
1427
+ const detail = await fetchClient(id);
1428
+ if (!detail) {
1429
+ return;
1430
+ }
1431
+ if (action === "tabs") {
1432
+ openDialog(`Tabs (${detail.tabs.length})`, tabsHtml(detail.tabs));
1433
+ } else if (action === "activity") {
1434
+ openDialog("Recent activity", activitiesHtml(detail.activities));
1435
+ }
1436
+ });
1437
+
1438
+ // This page is cooked through the dev server, so it gets both the jsenv
1439
+ // server-events client (window.__server_events__, injected first — the
1440
+ // same channel autoreload rides) and the monitoring script (so opening
1441
+ // this page counts as a connected client). The server pushes the full
1442
+ // list on every change; no polling. Initial snapshot via the JSON route.
1443
+ (async () => {
1444
+ try {
1445
+ render(await (await fetch("/.internal/clients.json")).json());
1446
+ } catch {
1447
+ // dev server unreachable — the live channel will fill the list in.
1448
+ }
1449
+ })();
1450
+
1451
+ window.__server_events__.listenEvents({
1452
+ clients_list: (event) => render(event.data),
1453
+ });
1454
+
1455
+ // Refresh relative "last activity" times between server pushes.
1456
+ setInterval(() => render(lastClients), 5000);
1457
+ </script>
1458
+ </body>
1459
+ </html>