@jsenv/core 41.4.1 → 41.4.2

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.
@@ -6,6 +6,15 @@
6
6
  * cooked one of our pages and reports back; we can only see clients that execute
7
7
  * our injected script, not arbitrary HTTP clients of the dev server.
8
8
  *
9
+ * The MAIN client — the machine the dev server runs on, browsing via
10
+ * localhost — is listed but not watched: it reports presence only (heartbeat,
11
+ * tabs), no console logs and no activity. Its devtools are already at hand,
12
+ * and the person reading the dashboard is that client. Watching is for the
13
+ * clients that reach the server over the network (a phone on the LAN address,
14
+ * acceptAnyIp: true), and every client record carries the ip it reports from —
15
+ * that ip is what tells the two kinds apart. See isLocalClient in
16
+ * client_reporter.js (the client side of the same rule).
17
+ *
9
18
  * Transport reuses what the dev server already has instead of opening a second
10
19
  * websocket:
11
20
  * - server → clients uses the jsenv "server events" channel (the same websocket
@@ -42,8 +51,6 @@
42
51
  */
43
52
 
44
53
  import { injectJsenvScript, parseHtml, stringifyHtmlAst } from "@jsenv/ast";
45
- import { urlToRelativeUrl } from "@jsenv/urls";
46
- import { readdirSync } from "node:fs";
47
54
  import { getRuntimeFromRequest } from "../../dev/dev_server_plugins/runtime_from_request.js";
48
55
 
49
56
  // Normalize the dev server's { runtimeName, runtimeVersion } to the { name,
@@ -79,6 +86,18 @@ const ACTIVITY_MAX_PER_CLIENT = 50;
79
86
  const INACTIVITY_MS = 60 * 1000;
80
87
  // A tab not heard from for this long is considered closed and dropped.
81
88
  const TAB_TTL_MS = 2 * 60 * 1000;
89
+ // What a browser sends is not to be trusted with the server's memory: a log
90
+ // line is cut at the source too (see client_reporter.js), and cut again here so
91
+ // a hand-made POST cannot park megabytes in the buffer — which the
92
+ // server-events history would then keep a second time.
93
+ // A little above the client's own cut, so the "… (N more characters)" it adds
94
+ // survives this one — the reader needs to know something was left out.
95
+ const LOG_TEXT_MAX = 10_064;
96
+ // Clients seen since the server started, at most. One per browser profile in
97
+ // practice, but every private window and every cleared storage adds one that
98
+ // never comes back, each carrying its own buffer — so the oldest ones that are
99
+ // no longer online are let go.
100
+ const CLIENT_MAX = 50;
82
101
 
83
102
  // The dev server already parses browser + version from a request (sec-ch-ua or
84
103
  // user-agent) via getRuntimeFromRequest; it does not cover the OS, so this fills
@@ -119,7 +138,14 @@ const osFromUserAgent = (userAgent) => {
119
138
  return { name: "unknown", version: "" };
120
139
  };
121
140
 
122
- export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
141
+ // The machine the dev server runs on, talking to itself: the main client. A
142
+ // phone (or any other device) reaching the server over the network reports
143
+ // with the machine's LAN address instead — which is why the ip is kept on
144
+ // every client record: it is what tells the main client from the others.
145
+ const isLocalIp = (ip) =>
146
+ ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
147
+
148
+ export const jsenvPluginClientMonitoring = () => {
123
149
  // id -> client record
124
150
  const clients = new Map();
125
151
 
@@ -145,6 +171,9 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
145
171
  userAgent,
146
172
  runtime: runtimeFromRequest(request),
147
173
  os: osFromUserAgent(userAgent),
174
+ // The address the reports come from; request.ipForwarded when a proxy
175
+ // sits in between, so the client's own address is kept, not the proxy's.
176
+ ip: request.ipForwarded || request.ip,
148
177
  firstSeen: now(),
149
178
  lastSeen: now(),
150
179
  everSeen: false,
@@ -156,14 +185,38 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
156
185
  tabs: new Map(),
157
186
  };
158
187
  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);
188
+ } else {
189
+ if (userAgent && userAgent !== client.userAgent) {
190
+ client.userAgent = userAgent;
191
+ client.runtime = runtimeFromRequest(request);
192
+ client.os = osFromUserAgent(userAgent);
193
+ }
194
+ // A device changes address (wifi drop, DHCP): the record follows it.
195
+ const ip = request.ipForwarded || request.ip;
196
+ if (ip && ip !== client.ip) {
197
+ client.ip = ip;
198
+ }
163
199
  }
164
200
  return client;
165
201
  };
166
202
 
203
+ // The oldest silent ones first: a client still reporting is one someone is
204
+ // looking at, whatever its age.
205
+ const pruneClients = () => {
206
+ if (clients.size <= CLIENT_MAX) {
207
+ return;
208
+ }
209
+ const droppable = [...clients.values()]
210
+ .filter((client) => !isOnline(client))
211
+ .sort((a, b) => a.lastSeen - b.lastSeen);
212
+ for (const client of droppable) {
213
+ if (clients.size <= CLIENT_MAX) {
214
+ return;
215
+ }
216
+ clients.delete(client.id);
217
+ }
218
+ };
219
+
167
220
  const pruneLogs = (client) => {
168
221
  const cutoff = now() - LOG_TTL_MS;
169
222
  while (client.logs.length && client.logs[0].ts < cutoff) {
@@ -255,6 +308,10 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
255
308
  // parsed { name, version } so pages can show a friendly browser/OS
256
309
  runtime: client.runtime,
257
310
  os: client.os,
311
+ ip: client.ip,
312
+ // The main client — the machine the dev server runs on, talking to
313
+ // itself over localhost.
314
+ local: isLocalIp(client.ip),
258
315
  firstSeen: client.firstSeen,
259
316
  lastSeen: client.lastSeen,
260
317
  online: isOnline(client),
@@ -311,6 +368,7 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
311
368
 
312
369
  updateTab(client, body.tab);
313
370
  pruneTabs(client);
371
+ pruneClients();
314
372
 
315
373
  if (firstEver) {
316
374
  sendClientHere({ reason: "new", client: serializeClient(client) });
@@ -331,13 +389,22 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
331
389
  for (const rawLog of logs) {
332
390
  const entry = {
333
391
  level: rawLog.level || "log",
334
- text: typeof rawLog.text === "string" ? rawLog.text : "",
392
+ text:
393
+ typeof rawLog.text === "string"
394
+ ? rawLog.text.slice(0, LOG_TEXT_MAX)
395
+ : "",
335
396
  ts: rawLog.ts || now(),
336
397
  };
337
398
  // styled console segments ({ text, css } per %c run), when present, so a
338
399
  // monitor can render colors; the plain text stays for copy/paste.
339
400
  if (Array.isArray(rawLog.segments)) {
340
- entry.segments = rawLog.segments;
401
+ entry.segments = rawLog.segments.map((segment) => ({
402
+ ...segment,
403
+ text:
404
+ typeof segment?.text === "string"
405
+ ? segment.text.slice(0, LOG_TEXT_MAX)
406
+ : "",
407
+ }));
341
408
  }
342
409
  client.logs.push(entry);
343
410
  sendClientLog({ clientId, ...entry });
@@ -361,57 +428,6 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
361
428
  };
362
429
  };
363
430
 
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
431
  // Desktop pilots a client: validate a { clientId, tabId?, type, url? } command
416
432
  // and broadcast it as a "client_command" server event. The reporter runs it
417
433
  // only if the id (and tabId, when given) matches. type: "navigate" | "reload".
@@ -536,14 +552,6 @@ export const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
536
552
  declarationSource: import.meta.url,
537
553
  fetch: (request) => ingestCommand(request),
538
554
  },
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
555
  {
548
556
  endpoint: "GET /.internal/clients.json",
549
557
  description: "Snapshot of every client seen since the server started.",