@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,568 @@
1
+ /*
2
+ * Client monitoring plugin (dev only)
3
+ * -----------------------------------
4
+ * Lets you watch one client from another — e.g. read a phone's console logs and
5
+ * activity from the desktop. A "client" here is a browser/navigator context that
6
+ * cooked one of our pages and reports back; we can only see clients that execute
7
+ * our injected script, not arbitrary HTTP clients of the dev server.
8
+ *
9
+ * Transport reuses what the dev server already has instead of opening a second
10
+ * websocket:
11
+ * - server → clients uses the jsenv "server events" channel (the same websocket
12
+ * the autoreload feature rides on). This plugin declares these server events:
13
+ * - "clients_list" the whole registry (the dashboard renders it)
14
+ * - "client_log" a single log line (a monitor appends it)
15
+ * - "client_activity" a single qualified activity (a monitor appends it)
16
+ * - "client_here" a client just appeared/resumed (every page can toast it)
17
+ * - "client_command" pilot one client (navigate/reload its tab) from the
18
+ * dashboard; the matching reporter runs it
19
+ * Server events are broadcast, so consumers filter what they care about.
20
+ * - clients → server uses plain HTTP POSTs. /.internal/clients/report carries the
21
+ * tab (id, url, title, visibility), recent qualified activities (click, request,
22
+ * navigation, …) and buffered console logs; a periodic heartbeat keeps it fresh.
23
+ * /.internal/clients/command lets the dashboard pilot a client. No extra socket.
24
+ *
25
+ * A client aggregates its open tabs and a short activity history. It is "online"
26
+ * when any report arrived within INACTIVITY_MS — there is no dedicated
27
+ * connection to track.
28
+ *
29
+ * The monitoring script is injected into EVERY cooked page, including our own
30
+ * dashboard and monitor pages — so opening one of those counts as a connected
31
+ * client, and any open page gets toasted when another client appears.
32
+ *
33
+ * Pages:
34
+ * - /.internal/clients → dashboard listing every client seen
35
+ * - /.internal/client?id=… → live console-log monitor for one client
36
+ *
37
+ * Both pages are served THROUGH the graph (via redirectReference to a real HTML
38
+ * file) rather than as a raw route response, so they get cooked like any app
39
+ * page — which is what injects window.__server_events__ into them. They consume
40
+ * the server events directly, no bespoke socket. See the "dev-server" skill next
41
+ * to @jsenv/core for how internal pages get script injection.
42
+ */
43
+
44
+ import { injectJsenvScript, parseHtml, stringifyHtmlAst } from "@jsenv/ast";
45
+ import { urlToRelativeUrl } from "@jsenv/urls";
46
+ import { readdirSync } from "node:fs";
47
+ import { getRuntimeFromRequest } from "../../dev/dev_server_plugins/runtime_from_request.js";
48
+
49
+ // Normalize the dev server's { runtimeName, runtimeVersion } to the { name,
50
+ // version } shape the pages use for both browser and OS.
51
+ const runtimeFromRequest = (request) => {
52
+ const { runtimeName, runtimeVersion } = getRuntimeFromRequest(request);
53
+ return { name: runtimeName, version: runtimeVersion };
54
+ };
55
+
56
+ const clientReporterFileUrl = new URL(
57
+ "./client/client_reporter.js",
58
+ import.meta.url,
59
+ ).href;
60
+ const clientsPageFileUrl = new URL(
61
+ "./client/clients_page.html",
62
+ import.meta.url,
63
+ ).href;
64
+ const clientMonitorPageFileUrl = new URL(
65
+ "./client/client_monitor_page.html",
66
+ import.meta.url,
67
+ ).href;
68
+
69
+ // Keep at most this many log entries per client, and drop entries older than
70
+ // LOG_TTL_MS — the buffer only exists so a monitor opened a bit late can still
71
+ // show recent history; it is not a persistent store.
72
+ const LOG_MAX_PER_CLIENT = 1000;
73
+ const LOG_TTL_MS = 60 * 60 * 1000; // 1h
74
+ // Recent qualified activities kept per client (click, mousemove, request, …),
75
+ // so a page can show "what the client was last doing" and a short history.
76
+ const ACTIVITY_MAX_PER_CLIENT = 50;
77
+ // A client silent (no report at all) for longer than this, then reporting
78
+ // again, is treated as "resumed" and defines "online".
79
+ const INACTIVITY_MS = 60 * 1000;
80
+ // A tab not heard from for this long is considered closed and dropped.
81
+ const TAB_TTL_MS = 2 * 60 * 1000;
82
+
83
+ // The dev server already parses browser + version from a request (sec-ch-ua or
84
+ // user-agent) via getRuntimeFromRequest; it does not cover the OS, so this fills
85
+ // that gap from the user-agent string.
86
+ const osFromUserAgent = (userAgent) => {
87
+ const iosMatch = userAgent.match(/iPhone OS (\d+)[._](\d+)/);
88
+ if (iosMatch) {
89
+ return { name: "iOS", version: `${iosMatch[1]}.${iosMatch[2]}` };
90
+ }
91
+ if (/iPad/.test(userAgent)) {
92
+ const ipadMatch = userAgent.match(/OS (\d+)[._](\d+)/);
93
+ return {
94
+ name: "iPadOS",
95
+ version: ipadMatch ? `${ipadMatch[1]}.${ipadMatch[2]}` : "",
96
+ };
97
+ }
98
+ const androidMatch = userAgent.match(/Android (\d+(?:\.\d+)*)/);
99
+ if (androidMatch) {
100
+ return { name: "Android", version: androidMatch[1] };
101
+ }
102
+ if (/Windows NT/.test(userAgent)) {
103
+ const winMatch = userAgent.match(/Windows NT (\d+\.\d+)/);
104
+ const version =
105
+ winMatch && winMatch[1] === "10.0" ? "10/11" : winMatch?.[1];
106
+ return { name: "Windows", version: version || "" };
107
+ }
108
+ const macMatch = userAgent.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/);
109
+ if (macMatch) {
110
+ const patch = macMatch[3] ? `.${macMatch[3]}` : "";
111
+ return { name: "macOS", version: `${macMatch[1]}.${macMatch[2]}${patch}` };
112
+ }
113
+ if (/CrOS/.test(userAgent)) {
114
+ return { name: "ChromeOS", version: "" };
115
+ }
116
+ if (/Linux/.test(userAgent)) {
117
+ return { name: "Linux", version: "" };
118
+ }
119
+ return { name: "unknown", version: "" };
120
+ };
121
+
122
+ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
123
+ // id -> client record
124
+ const clients = new Map();
125
+
126
+ // Assigned when the server-event channel is set up; broadcast helpers.
127
+ let sendClientsList = () => {};
128
+ let sendClientLog = () => {};
129
+ let sendClientActivity = () => {};
130
+ let sendClientHere = () => {};
131
+ // Pilot a client from the desktop: a targeted command (navigate/reload) the
132
+ // matching reporter runs. Broadcast like the rest; the reporter filters by id.
133
+ let sendClientCommand = () => {};
134
+
135
+ const now = () => Date.now();
136
+ // "online" = we heard from the client (any report) recently.
137
+ const isOnline = (client) => now() - client.lastSeen < INACTIVITY_MS;
138
+
139
+ const getOrCreateClient = (id, request) => {
140
+ const userAgent = request.headers["user-agent"] || "";
141
+ let client = clients.get(id);
142
+ if (!client) {
143
+ client = {
144
+ id,
145
+ userAgent,
146
+ runtime: runtimeFromRequest(request),
147
+ os: osFromUserAgent(userAgent),
148
+ firstSeen: now(),
149
+ lastSeen: now(),
150
+ everSeen: false,
151
+ logs: [],
152
+ activities: [],
153
+ // most recent qualified activity { type, detail, ts, tabId } or null
154
+ lastActivity: null,
155
+ // tabId -> { id, url, title, visible, lastSeen }
156
+ tabs: new Map(),
157
+ };
158
+ clients.set(id, client);
159
+ } else if (userAgent && userAgent !== client.userAgent) {
160
+ client.userAgent = userAgent;
161
+ client.runtime = runtimeFromRequest(request);
162
+ client.os = osFromUserAgent(userAgent);
163
+ }
164
+ return client;
165
+ };
166
+
167
+ const pruneLogs = (client) => {
168
+ const cutoff = now() - LOG_TTL_MS;
169
+ while (client.logs.length && client.logs[0].ts < cutoff) {
170
+ client.logs.shift();
171
+ }
172
+ while (client.logs.length > LOG_MAX_PER_CLIENT) {
173
+ client.logs.shift();
174
+ }
175
+ };
176
+
177
+ const updateTab = (client, tab) => {
178
+ if (!tab || typeof tab.id !== "string") {
179
+ return;
180
+ }
181
+ if (tab.closing) {
182
+ client.tabs.delete(tab.id);
183
+ return;
184
+ }
185
+ client.tabs.set(tab.id, {
186
+ id: tab.id,
187
+ url: typeof tab.url === "string" ? tab.url : "",
188
+ title: typeof tab.title === "string" ? tab.title : "",
189
+ visible: Boolean(tab.visible),
190
+ lastSeen: now(),
191
+ });
192
+ };
193
+
194
+ const pruneTabs = (client) => {
195
+ const cutoff = now() - TAB_TTL_MS;
196
+ for (const [id, tab] of client.tabs) {
197
+ if (tab.lastSeen < cutoff) {
198
+ client.tabs.delete(id);
199
+ }
200
+ }
201
+ };
202
+
203
+ // The tab to show as "current": a visible one wins, otherwise the most
204
+ // recently seen. No Math.max — a single scan keeping the best.
205
+ const activeTabOf = (client) => {
206
+ let best = null;
207
+ for (const tab of client.tabs.values()) {
208
+ if (!best) {
209
+ best = tab;
210
+ continue;
211
+ }
212
+ if (tab.visible && !best.visible) {
213
+ best = tab;
214
+ continue;
215
+ }
216
+ if (tab.visible === best.visible && tab.lastSeen > best.lastSeen) {
217
+ best = tab;
218
+ }
219
+ }
220
+ return best;
221
+ };
222
+
223
+ const recordActivity = (client, rawActivity) => {
224
+ const activity = {
225
+ type:
226
+ typeof rawActivity.type === "string" ? rawActivity.type : "activity",
227
+ detail: typeof rawActivity.detail === "string" ? rawActivity.detail : "",
228
+ ts: rawActivity.ts || now(),
229
+ tabId: typeof rawActivity.tabId === "string" ? rawActivity.tabId : "",
230
+ };
231
+ client.activities.push(activity);
232
+ while (client.activities.length > ACTIVITY_MAX_PER_CLIENT) {
233
+ client.activities.shift();
234
+ }
235
+ client.lastActivity = activity;
236
+ return activity;
237
+ };
238
+
239
+ const serializeTab = (tab) => ({
240
+ id: tab.id,
241
+ url: tab.url,
242
+ title: tab.title,
243
+ visible: tab.visible,
244
+ lastSeen: tab.lastSeen,
245
+ });
246
+
247
+ // Summary sent in the (broadcast) list — kept lean: the active tab and a tab
248
+ // count rather than every tab, the last activity rather than the whole
249
+ // history. Pages fetch the full record for their dialogs.
250
+ const serializeClient = (client) => {
251
+ const activeTab = activeTabOf(client);
252
+ return {
253
+ id: client.id,
254
+ userAgent: client.userAgent,
255
+ // parsed { name, version } so pages can show a friendly browser/OS
256
+ runtime: client.runtime,
257
+ os: client.os,
258
+ firstSeen: client.firstSeen,
259
+ lastSeen: client.lastSeen,
260
+ online: isOnline(client),
261
+ logCount: client.logs.length,
262
+ tabCount: client.tabs.size,
263
+ activeTab: activeTab ? serializeTab(activeTab) : null,
264
+ lastActivity: client.lastActivity,
265
+ };
266
+ };
267
+
268
+ const snapshot = () => [...clients.values()].map(serializeClient);
269
+
270
+ // A single client's full record: the summary plus everything a dialog needs
271
+ // (all tabs, recent activities, buffered logs). Client-scoped rather than
272
+ // named after logs because the shape is meant to grow.
273
+ const clientDetail = (client) => ({
274
+ ...serializeClient(client),
275
+ tabs: [...client.tabs.values()].map(serializeTab),
276
+ activities: client.activities.slice(),
277
+ logs: client.logs,
278
+ });
279
+
280
+ // Broadcasting the full list on every log line would be wasteful, so a
281
+ // log-driven refresh (logCount/lastActivity) is throttled; structural changes
282
+ // (a client appears/resumes) push immediately.
283
+ let listThrottleTimer = null;
284
+ const pushListThrottled = () => {
285
+ if (listThrottleTimer) {
286
+ return;
287
+ }
288
+ listThrottleTimer = setTimeout(() => {
289
+ listThrottleTimer = null;
290
+ sendClientsList();
291
+ }, 1000);
292
+ };
293
+
294
+ const ingest = async (request) => {
295
+ let body;
296
+ try {
297
+ body = await request.json();
298
+ } catch {
299
+ return { status: 400 };
300
+ }
301
+ const clientId = body.clientId;
302
+ if (!clientId || typeof clientId !== "string") {
303
+ return { status: 400 };
304
+ }
305
+ const client = getOrCreateClient(clientId, request);
306
+
307
+ const firstEver = !client.everSeen;
308
+ const wasOnline = !firstEver && isOnline(client);
309
+ client.everSeen = true;
310
+ client.lastSeen = now();
311
+
312
+ updateTab(client, body.tab);
313
+ pruneTabs(client);
314
+
315
+ if (firstEver) {
316
+ sendClientHere({ reason: "new", client: serializeClient(client) });
317
+ sendClientsList();
318
+ } else if (!wasOnline) {
319
+ // A report after a long quiet spell means the client was picked back up.
320
+ sendClientHere({ reason: "resumed", client: serializeClient(client) });
321
+ sendClientsList();
322
+ }
323
+
324
+ const activities = Array.isArray(body.activities) ? body.activities : [];
325
+ for (const rawActivity of activities) {
326
+ const activity = recordActivity(client, rawActivity);
327
+ sendClientActivity({ clientId, ...activity });
328
+ }
329
+
330
+ const logs = Array.isArray(body.logs) ? body.logs : [];
331
+ for (const rawLog of logs) {
332
+ const entry = {
333
+ level: rawLog.level || "log",
334
+ text: typeof rawLog.text === "string" ? rawLog.text : "",
335
+ ts: rawLog.ts || now(),
336
+ };
337
+ // styled console segments ({ text, css } per %c run), when present, so a
338
+ // monitor can render colors; the plain text stays for copy/paste.
339
+ if (Array.isArray(rawLog.segments)) {
340
+ entry.segments = rawLog.segments;
341
+ }
342
+ client.logs.push(entry);
343
+ sendClientLog({ clientId, ...entry });
344
+ }
345
+ if (logs.length) {
346
+ pruneLogs(client);
347
+ }
348
+ pushListThrottled();
349
+ return { status: 204 };
350
+ };
351
+
352
+ const jsonResponse = (data) => {
353
+ const json = JSON.stringify(data);
354
+ return {
355
+ status: 200,
356
+ headers: {
357
+ "content-type": "application/json",
358
+ "content-length": Buffer.byteLength(json),
359
+ },
360
+ body: json,
361
+ };
362
+ };
363
+
364
+ // The .html pages served under the source directory, as server-relative URLs,
365
+ // so the dashboard can offer them as "navigate this client to…" targets. Skips
366
+ // node_modules, build output and dot-dirs. Cached briefly — one scan per
367
+ // dialog-open is plenty; it needn't be fresh to the second.
368
+ const PAGE_SCAN_TTL_MS = 3000;
369
+ const PAGE_SCAN_SKIP_DIRS = new Set([
370
+ "node_modules",
371
+ "dist",
372
+ "git_ignored",
373
+ "old",
374
+ ]);
375
+ let pageScanCache = null;
376
+ let pageScanAt = 0;
377
+ const listNavigablePages = () => {
378
+ if (!rootDirectoryUrl) {
379
+ return [];
380
+ }
381
+ if (pageScanCache && now() - pageScanAt < PAGE_SCAN_TTL_MS) {
382
+ return pageScanCache;
383
+ }
384
+ const pages = [];
385
+ const walk = (dirUrl) => {
386
+ let entries;
387
+ try {
388
+ entries = readdirSync(new URL(dirUrl), { withFileTypes: true });
389
+ } catch {
390
+ return;
391
+ }
392
+ for (const entry of entries) {
393
+ const name = entry.name;
394
+ if (name[0] === ".") {
395
+ continue; // .git, .agents, dot-files…
396
+ }
397
+ if (entry.isDirectory()) {
398
+ if (!PAGE_SCAN_SKIP_DIRS.has(name)) {
399
+ walk(`${dirUrl}${name}/`);
400
+ }
401
+ } else if (name.endsWith(".html")) {
402
+ pages.push(
403
+ `/${urlToRelativeUrl(`${dirUrl}${name}`, rootDirectoryUrl)}`,
404
+ );
405
+ }
406
+ }
407
+ };
408
+ walk(String(rootDirectoryUrl));
409
+ pages.sort();
410
+ pageScanCache = pages;
411
+ pageScanAt = now();
412
+ return pages;
413
+ };
414
+
415
+ // Desktop pilots a client: validate a { clientId, tabId?, type, url? } command
416
+ // and broadcast it as a "client_command" server event. The reporter runs it
417
+ // only if the id (and tabId, when given) matches. type: "navigate" | "reload".
418
+ const ingestCommand = async (request) => {
419
+ let body;
420
+ try {
421
+ body = await request.json();
422
+ } catch {
423
+ return { status: 400 };
424
+ }
425
+ const { clientId, tabId, type, url } = body;
426
+ if (!clientId || typeof clientId !== "string") {
427
+ return { status: 400 };
428
+ }
429
+ if (type !== "navigate" && type !== "reload") {
430
+ return { status: 400 };
431
+ }
432
+ if (type === "navigate" && (!url || typeof url !== "string")) {
433
+ return { status: 400 };
434
+ }
435
+ sendClientCommand({
436
+ clientId,
437
+ tabId: typeof tabId === "string" ? tabId : null,
438
+ type,
439
+ url: type === "navigate" ? url : null,
440
+ });
441
+ return { status: 204 };
442
+ };
443
+
444
+ // Map our two page URLs onto their real HTML files. Returning a graph URL
445
+ // here (instead of serving the file from a raw route) makes the dev server
446
+ // cook the page, which is what gets window.__server_events__ injected into it.
447
+ const redirectToPage = (reference) => {
448
+ if (reference.isInline || !reference.url.startsWith("file:")) {
449
+ return null;
450
+ }
451
+ const { pathname, search } = new URL(reference.url);
452
+ if (pathname.endsWith("/.internal/clients")) {
453
+ return clientsPageFileUrl;
454
+ }
455
+ if (pathname.endsWith("/.internal/client")) {
456
+ // carry ?id=… so the monitor page knows which client to watch
457
+ return `${clientMonitorPageFileUrl}${search}`;
458
+ }
459
+ return null;
460
+ };
461
+
462
+ return {
463
+ name: "jsenv:client_monitoring",
464
+ appliesDuring: "dev",
465
+ redirectReference: redirectToPage,
466
+ serverEvents: {
467
+ clients_list: (serverEventInfo) => {
468
+ sendClientsList = () => serverEventInfo.sendServerEvent(snapshot());
469
+ },
470
+ client_log: (serverEventInfo) => {
471
+ sendClientLog = (payload) => serverEventInfo.sendServerEvent(payload);
472
+ },
473
+ client_activity: (serverEventInfo) => {
474
+ sendClientActivity = (payload) =>
475
+ serverEventInfo.sendServerEvent(payload);
476
+ },
477
+ client_here: (serverEventInfo) => {
478
+ sendClientHere = (payload) => serverEventInfo.sendServerEvent(payload);
479
+ },
480
+ client_command: (serverEventInfo) => {
481
+ sendClientCommand = (payload) =>
482
+ serverEventInfo.sendServerEvent(payload);
483
+ },
484
+ },
485
+ transformUrlContent: {
486
+ html: (urlInfo) => {
487
+ // Injected into every page, including our own dashboard/monitor pages,
488
+ // so opening one registers that browser as a client and any open page
489
+ // gets toasted when another client appears.
490
+ const htmlAst = parseHtml({ html: urlInfo.content, url: urlInfo.url });
491
+ injectJsenvScript(htmlAst, {
492
+ src: clientReporterFileUrl,
493
+ initCall: {
494
+ callee: "window.__client_monitoring__.setup",
495
+ params: {},
496
+ },
497
+ pluginName: "jsenv:client_monitoring",
498
+ });
499
+ return stringifyHtmlAst(htmlAst);
500
+ },
501
+ },
502
+ serverRoutes: [
503
+ // The two pages are actually served THROUGH the graph (see
504
+ // redirectReference above) so they get window.__server_events__ injected.
505
+ // These entries exist only to make the pages discoverable in the route
506
+ // inspector (/.internal/route_inspector): their fetch returns null, which
507
+ // makes the router fall through to the dev server's catch-all "GET *",
508
+ // which does the real cooking + injection.
509
+ {
510
+ endpoint: "GET /.internal/clients",
511
+ description:
512
+ "Dashboard of every browser (client) connected to this dev server since it started.",
513
+ availableMediaTypes: ["text/html"],
514
+ declarationSource: import.meta.url,
515
+ fetch: () => null,
516
+ },
517
+ {
518
+ endpoint: "GET /.internal/client",
519
+ description:
520
+ "Live monitor (console logs + activity) for one client — pass ?id=<clientId>.",
521
+ availableMediaTypes: ["text/html"],
522
+ declarationSource: import.meta.url,
523
+ fetch: () => null,
524
+ },
525
+ {
526
+ endpoint: "POST /.internal/clients/report",
527
+ description:
528
+ "A browser reports its console output and activity heartbeat here.",
529
+ declarationSource: import.meta.url,
530
+ fetch: (request) => ingest(request),
531
+ },
532
+ {
533
+ endpoint: "POST /.internal/clients/command",
534
+ description:
535
+ "Pilot a client from the dashboard: { clientId, tabId?, type: 'navigate'|'reload', url? }.",
536
+ declarationSource: import.meta.url,
537
+ fetch: (request) => ingestCommand(request),
538
+ },
539
+ {
540
+ endpoint: "GET /.internal/clients/pages.json",
541
+ description:
542
+ "The .html pages under the source directory, offered as navigation targets for a client.",
543
+ availableMediaTypes: ["application/json"],
544
+ declarationSource: import.meta.url,
545
+ fetch: () => jsonResponse(listNavigablePages()),
546
+ },
547
+ {
548
+ endpoint: "GET /.internal/clients.json",
549
+ description: "Snapshot of every client seen since the server started.",
550
+ availableMediaTypes: ["application/json"],
551
+ declarationSource: import.meta.url,
552
+ fetch: () => jsonResponse(snapshot()),
553
+ },
554
+ {
555
+ endpoint: "GET /.internal/client.json",
556
+ description:
557
+ "One client's record — info plus buffered logs (?id=…), so a monitor opened late still has recent history.",
558
+ availableMediaTypes: ["application/json"],
559
+ declarationSource: import.meta.url,
560
+ fetch: (request) => {
561
+ const id = request.searchParams.get("id");
562
+ const client = id && clients.get(id);
563
+ return jsonResponse(client ? clientDetail(client) : { id, logs: [] });
564
+ },
565
+ },
566
+ ],
567
+ };
568
+ };