@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.2.13",
3
+ "version": "41.3.0",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "./packages/related/cli/*",
23
23
  "./packages/tooling/*"
24
24
  ],
25
- "packageManager": "npm@11.6.2",
25
+ "packageManager": "npm@11.17.0",
26
26
  "engines": {
27
27
  "node": ">=20.8.0"
28
28
  },
@@ -73,13 +73,13 @@
73
73
  },
74
74
  "dependencies": {
75
75
  "@jsenv/ast": "6.8.4",
76
- "@jsenv/js-module-fallback": "1.4.35",
77
- "@jsenv/plugin-bundling": "2.10.15",
76
+ "@jsenv/js-module-fallback": "1.4.37",
77
+ "@jsenv/plugin-bundling": "2.10.16",
78
78
  "@jsenv/plugin-minification": "1.7.5",
79
- "@jsenv/plugin-supervisor": "1.8.6",
80
- "@jsenv/plugin-transpilation": "1.5.77",
81
- "@jsenv/server": "17.3.1",
82
- "@jsenv/sourcemap": "1.4.1",
79
+ "@jsenv/plugin-supervisor": "1.8.7",
80
+ "@jsenv/plugin-transpilation": "1.5.78",
81
+ "@jsenv/server": "17.4.0",
82
+ "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
84
84
  },
85
85
  "devDependencies": {
@@ -132,6 +132,7 @@
132
132
  "prettier-plugin-organize-imports": "4.3.0",
133
133
  "prettier-plugin-pkg": "0.22.1",
134
134
  "prettier-plugin-sql": "0.20.0",
135
+ "source-map": "0.8.0",
135
136
  "strip-ansi": "7.2.0"
136
137
  },
137
138
  "publishConfig": {
@@ -144,7 +145,13 @@
144
145
  "./dist/client/directory_listing/jsenv_core_node_modules.js"
145
146
  ],
146
147
  "volta": {
147
- "node": "25.8.1",
148
- "npm": "11.6.2"
148
+ "node": "26.5.0",
149
+ "npm": "11.17.0"
150
+ },
151
+ "allowScripts": {
152
+ "unrs-resolver": true,
153
+ "@playwright/browser-chromium": true,
154
+ "@playwright/browser-firefox": true,
155
+ "@playwright/browser-webkit": true
149
156
  }
150
157
  }
@@ -16,6 +16,7 @@ import { createEventEmitter } from "../helpers/event_emitter.js";
16
16
  import { jsenvCoreDirectoryUrl } from "../jsenv_core_directory_url.js";
17
17
  import { createPackageDirectory } from "../kitchen/package_directory.js";
18
18
  import { createJsenvPluginStore } from "../plugins/jsenv_plugins_controller.js";
19
+ import { jsenvPluginClientMonitoring } from "../plugins/client_monitoring/jsenv_plugin_client_monitoring.js";
19
20
  import { getCorePlugins } from "../plugins/plugins.js";
20
21
  import { jsenvPluginServerEvents } from "../plugins/server_events/jsenv_plugin_server_events.js";
21
22
  import { devServerPluginChromeDevToolsJson } from "./dev_server_plugins/dev_server_plugin_chrome_devtools_json.js";
@@ -26,26 +27,41 @@ import { devServerPluginServeSourceFiles } from "./dev_server_plugins/dev_server
26
27
  const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
27
28
 
28
29
  /**
29
- * Starts the development server.
30
+ * Starts the jsenv development server: serves files from a source directory,
31
+ * transforming (cooking) them on the fly through a plugin pipeline, with live
32
+ * reload. Built on top of `@jsenv/server`.
30
33
  *
31
- * @param {Object} [params={}] - Configuration params for the dev server.
32
- * @param {number} [params.port=3456] - Port number the server should listen on.
33
- * @param {string} [params.hostname="localhost"] - Hostname to bind the server to.
34
- * @param {boolean} [params.https=false] - Whether to use HTTPS.
34
+ * See the "dev-server" skill (.agents/skills/dev-server) for the plugin system,
35
+ * server events, and how internal pages get script injection.
35
36
  *
36
- * @returns {Promise<Object>} A promise that resolves to the server instance.
37
- * @throws {Error} Will throw an error if the server fails to start or is called with unexpected params.
37
+ * @param {Object} [params={}]
38
+ * @param {string|URL} params.sourceDirectoryUrl - Root directory to serve (required; must exist).
39
+ * @param {string} [params.sourceMainFilePath="./index.html"] - File served for "/".
40
+ * @param {number} [params.port=3456] - Port to listen on (0 = a free port).
41
+ * @param {string} [params.hostname] - Hostname to bind to.
42
+ * @param {boolean} [params.acceptAnyIp=true] - Also accept connections on the machine's IPs.
43
+ * @param {boolean|object} [params.https=false] - HTTPS as `{ certificate, privateKey }`.
44
+ * @param {boolean} [params.http2=false] - HTTP/2 (requires https).
45
+ * @param {Array} [params.plugins=[]] - jsenv plugins (transformUrlContent, serverRoutes, serverEvents, effect, …).
46
+ * @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
47
+ * @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
48
+ * @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
49
+ * @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
50
+ * @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
51
+ * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
52
+ * @param {string} [params.sourcemaps="inline"] - Sourcemap mode.
53
+ * @param {AbortSignal} [params.signal] - Abort to stop the server.
54
+ * @param {boolean} [params.handleSIGINT=true] - Stop on SIGINT.
55
+ * @param {boolean} [params.keepProcessAlive=true] - Keep the process alive while running.
38
56
  *
39
- * @example
40
- * // Start a basic dev server
41
- * const server = await startDevServer();
42
- * console.log(`Server started at ${server.origin}`);
57
+ * @returns {Promise<{origin: string, sourceDirectoryUrl: URL, stop: () => Promise<void>, kitchenCache: object}>}
58
+ * @throws {TypeError} On unknown params.
43
59
  *
44
60
  * @example
45
- * // Start a server with custom params
46
61
  * const server = await startDevServer({
47
- * port: 8080,
62
+ * sourceDirectoryUrl: new URL("./src/", import.meta.url),
48
63
  * });
64
+ * console.log(`Server started at ${server.origin}`);
49
65
  */
50
66
  export const startDevServer = async ({
51
67
  sourceDirectoryUrl,
@@ -181,6 +197,14 @@ export const startDevServer = async ({
181
197
 
182
198
  const devServerJsenvPluginStore = await createJsenvPluginStore([
183
199
  jsenvPluginServerEvents({ clientAutoreload }),
200
+ // The client-monitoring dashboard is a dev-time convenience; a test-plan run
201
+ // doesn't use it and shouldn't pay for the reporter being injected into
202
+ // every page.
203
+ ...(EXECUTED_BY_TEST_PLAN
204
+ ? []
205
+ : [
206
+ jsenvPluginClientMonitoring({ rootDirectoryUrl: sourceDirectoryUrl }),
207
+ ]),
184
208
  ...plugins,
185
209
  ...getCorePlugins({
186
210
  packageDirectory,
@@ -0,0 +1,487 @@
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 client monitor</title>
7
+ <style>
8
+ html,
9
+ body {
10
+ height: 100%;
11
+ margin: 0;
12
+ }
13
+ body {
14
+ display: flex;
15
+ flex-direction: column;
16
+ color: #e5e7eb;
17
+ font:
18
+ 13px/1.5 -apple-system,
19
+ BlinkMacSystemFont,
20
+ "Segoe UI",
21
+ Roboto,
22
+ sans-serif;
23
+ background: #0b1020;
24
+ }
25
+ header {
26
+ display: flex;
27
+ padding: 12px 16px;
28
+ align-items: center;
29
+ gap: 12px;
30
+ background: #111827;
31
+ border-bottom: 1px solid #1f2937;
32
+ }
33
+ header h1 {
34
+ margin: 0;
35
+ font-size: 1rem;
36
+ }
37
+ .back {
38
+ display: inline-flex;
39
+ width: 26px;
40
+ height: 26px;
41
+ align-items: center;
42
+ justify-content: center;
43
+ color: #9ca3af;
44
+ font-size: 1.1rem;
45
+ text-decoration: none;
46
+ border-radius: 6px;
47
+ }
48
+ .back:hover {
49
+ color: #e5e7eb;
50
+ background: #1f2937;
51
+ }
52
+ .status {
53
+ color: #9ca3af;
54
+ font-size: 0.8rem;
55
+ }
56
+ .status .dot {
57
+ display: inline-block;
58
+ width: 8px;
59
+ height: 8px;
60
+ margin-right: 5px;
61
+ vertical-align: middle;
62
+ background: #9ca3af;
63
+ border-radius: 50%;
64
+ }
65
+ .status.on .dot {
66
+ background: #22c55e;
67
+ }
68
+ .spacer {
69
+ flex: 1;
70
+ }
71
+ button {
72
+ padding: 4px 10px;
73
+ color: #e5e7eb;
74
+ font-size: 0.78rem;
75
+ background: #374151;
76
+ border: 1px solid #4b5563;
77
+ border-radius: 6px;
78
+ cursor: pointer;
79
+ }
80
+ button:hover {
81
+ background: #4b5563;
82
+ }
83
+ /* Each stream is a card floating on the page background so logs and
84
+ activity read as two distinct things. They share the remaining height;
85
+ each grows when open, shrinks to just its header when collapsed — so you
86
+ can monitor one, both, or none. */
87
+ .panels {
88
+ display: flex;
89
+ min-height: 0;
90
+ padding: 12px;
91
+ flex: 1;
92
+ flex-direction: column;
93
+ gap: 12px;
94
+ }
95
+ .panel {
96
+ display: flex;
97
+ min-height: 0;
98
+ flex: 0 0 auto;
99
+ flex-direction: column;
100
+ background: #111a2e;
101
+ border: 1px solid #1f2937;
102
+ border-radius: 8px;
103
+ overflow: hidden;
104
+ }
105
+ .panel[data-open] {
106
+ flex: 1;
107
+ }
108
+ .panel-head {
109
+ display: flex;
110
+ padding: 8px 12px;
111
+ align-items: center;
112
+ gap: 8px;
113
+ border-left: 3px solid transparent;
114
+ cursor: pointer;
115
+ user-select: none;
116
+ }
117
+ .panel-head:hover {
118
+ background: rgba(255, 255, 255, 0.03);
119
+ }
120
+ /* Distinct accent per stream so they don't blur together. */
121
+ #logs-panel .panel-head {
122
+ background: rgba(167, 139, 250, 0.08);
123
+ border-left-color: #a78bfa;
124
+ }
125
+ #logs-panel .panel-title {
126
+ color: #c4b5fd;
127
+ }
128
+ #activity-panel .panel-head {
129
+ background: rgba(52, 211, 153, 0.08);
130
+ border-left-color: #34d399;
131
+ }
132
+ #activity-panel .panel-title {
133
+ color: #6ee7b7;
134
+ }
135
+ .chevron::before {
136
+ display: inline-block;
137
+ color: #6b7280;
138
+ transition: transform 0.1s;
139
+ content: "▸";
140
+ }
141
+ .panel[data-open] .chevron::before {
142
+ transform: rotate(90deg);
143
+ }
144
+ .panel-title {
145
+ font-weight: 600;
146
+ }
147
+ .count {
148
+ color: #6b7280;
149
+ font-size: 0.78rem;
150
+ }
151
+ .panel-body {
152
+ margin: 0;
153
+ padding: 8px 12px;
154
+ flex: 1;
155
+ font-size: 12px;
156
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
157
+ white-space: pre-wrap;
158
+ word-break: break-word;
159
+ overflow: auto;
160
+ }
161
+ .panel:not([data-open]) .panel-body {
162
+ display: none;
163
+ }
164
+ .entry {
165
+ padding: 2px 4px;
166
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
167
+ }
168
+ .entry .time {
169
+ margin-right: 8px;
170
+ color: #6b7280;
171
+ }
172
+ .entry .lvl {
173
+ display: inline-block;
174
+ min-width: 42px;
175
+ margin-right: 8px;
176
+ font-weight: 600;
177
+ }
178
+ .lvl-error {
179
+ color: #f87171;
180
+ }
181
+ .lvl-warn {
182
+ color: #fbbf24;
183
+ }
184
+ .lvl-info {
185
+ color: #60a5fa;
186
+ }
187
+ .lvl-debug {
188
+ color: #a78bfa;
189
+ }
190
+ .lvl-log {
191
+ color: #9ca3af;
192
+ }
193
+ .act-type {
194
+ display: inline-block;
195
+ min-width: 84px;
196
+ margin-right: 8px;
197
+ color: #93c5fd;
198
+ font-weight: 600;
199
+ }
200
+ .empty {
201
+ padding: 20px;
202
+ color: #6b7280;
203
+ }
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <header>
208
+ <a class="back" id="back" title="Back to clients">←</a>
209
+ <h1>Monitor <span id="clientinfo"></span></h1>
210
+ <span class="status" id="status"
211
+ ><span class="dot"></span>connecting…</span
212
+ >
213
+ </header>
214
+
215
+ <div class="panels">
216
+ <section class="panel" id="logs-panel" data-open>
217
+ <div class="panel-head">
218
+ <span class="chevron"></span>
219
+ <span class="panel-title">Logs</span>
220
+ <span class="count" id="logs-count"></span>
221
+ <span class="spacer"></span>
222
+ <button data-copy="logs">Copy</button>
223
+ <button data-clear="logs">Clear</button>
224
+ </div>
225
+ <div class="panel-body" id="logs">
226
+ <div class="empty">Waiting for logs…</div>
227
+ </div>
228
+ </section>
229
+
230
+ <section class="panel" id="activity-panel">
231
+ <div class="panel-head">
232
+ <span class="chevron"></span>
233
+ <span class="panel-title">Activity</span>
234
+ <span class="count" id="activity-count"></span>
235
+ <span class="spacer"></span>
236
+ <button data-copy="activity">Copy</button>
237
+ <button data-clear="activity">Clear</button>
238
+ </div>
239
+ <div class="panel-body" id="activities">
240
+ <div class="empty">Waiting for activity…</div>
241
+ </div>
242
+ </section>
243
+ </div>
244
+
245
+ <script type="module">
246
+ const params = new URLSearchParams(window.location.search);
247
+ const clientId = params.get("id") || "";
248
+ const statusEl = document.getElementById("status");
249
+ const clientinfoEl = document.getElementById("clientinfo");
250
+ clientinfoEl.textContent = clientId.slice(0, 8);
251
+ // Set at runtime so the dev server doesn't rewrite the static href to the
252
+ // internal graph URL of the dashboard page.
253
+ document.getElementById("back").href = "/.internal/clients";
254
+
255
+ const pad = (n) => String(n).padStart(2, "0");
256
+ const fmtTime = (ts) => {
257
+ const d = new Date(ts);
258
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
259
+ };
260
+ // Trim noisy sub-pixel precision for display (e.g. scroll "0/432.333" →
261
+ // "0/432"); the full value stays in the stored/reported data.
262
+ const roundNumbers = (text) =>
263
+ String(text).replace(/-?\d+\.\d+/g, (n) => String(Math.round(n)));
264
+
265
+ const RUNTIME_LABELS = {
266
+ ios_safari: "iOS Safari",
267
+ chrome: "Chrome",
268
+ edge: "Edge",
269
+ opera: "Opera",
270
+ samsung: "Samsung Internet",
271
+ firefox: "Firefox",
272
+ safari: "Safari",
273
+ node: "Node",
274
+ };
275
+ // "Chrome 149 · iOS 17.2" from the client's parsed runtime/os.
276
+ const describeClient = (client) => {
277
+ const part = ({ name, version } = {}, labels) => {
278
+ if (!name || name === "unknown") {
279
+ return "";
280
+ }
281
+ const label = labels ? labels[name] || name : name;
282
+ const v = version && version !== "unknown" ? ` ${version}` : "";
283
+ return `${label}${v}`;
284
+ };
285
+ return (
286
+ [part(client.runtime, RUNTIME_LABELS), part(client.os)]
287
+ .filter(Boolean)
288
+ .join(" · ") || clientId.slice(0, 8)
289
+ );
290
+ };
291
+
292
+ // Filled from client.json; drives the copied header so a pasted log always
293
+ // says WHERE it came from (browser, OS, full user-agent) — e.g. so it's
294
+ // obvious it's Android Chrome and not iOS.
295
+ let clientInfo = null;
296
+ const copyHeader = (label) => {
297
+ const info = clientInfo || {};
298
+ const ua = info.userAgent || window.navigator.userAgent || "unknown";
299
+ return `${[
300
+ `# jsenv client monitor — ${label}`,
301
+ `# client: ${describeClient(info)}`,
302
+ `# user-agent: ${ua}`,
303
+ `# client id: ${clientId}`,
304
+ `# copied: ${new Date().toISOString()}`,
305
+ ].join("\n")}\n\n`;
306
+ };
307
+
308
+ // A stream = one collapsible panel with its own entries, rendering + a
309
+ // clipboard/clear pair. Logs render colored console segments; activity
310
+ // renders "type: detail". Both keep a plain-text form for Copy.
311
+ const createStream = (bodyId, countId, { render, toText, label }) => {
312
+ const bodyEl = document.getElementById(bodyId);
313
+ const countEl = document.getElementById(countId);
314
+ const entries = [];
315
+ let empty = true;
316
+ const add = (entry) => {
317
+ entries.push(entry);
318
+ countEl.textContent = `(${entries.length})`;
319
+ if (empty) {
320
+ bodyEl.innerHTML = "";
321
+ empty = false;
322
+ }
323
+ const atBottom =
324
+ bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
325
+ bodyEl.appendChild(render(entry));
326
+ if (atBottom) {
327
+ bodyEl.scrollTop = bodyEl.scrollHeight;
328
+ }
329
+ };
330
+ const clear = () => {
331
+ entries.length = 0;
332
+ countEl.textContent = "";
333
+ bodyEl.innerHTML = `<div class="empty">Cleared.</div>`;
334
+ empty = true;
335
+ };
336
+ const copy = async (button) => {
337
+ try {
338
+ const text = copyHeader(label) + entries.map(toText).join("\n");
339
+ await window.navigator.clipboard.writeText(text);
340
+ const prev = button.textContent;
341
+ button.textContent = "Copied!";
342
+ setTimeout(() => {
343
+ button.textContent = prev;
344
+ }, 1200);
345
+ } catch {
346
+ // clipboard blocked — ignore
347
+ }
348
+ };
349
+ return { add, clear, copy };
350
+ };
351
+
352
+ // The %c styling comes from the reported page; applying it via cssText
353
+ // (not innerHTML) can't inject markup, but drop the obvious escape hatches.
354
+ const sanitizeCss = (css) =>
355
+ String(css)
356
+ .replace(/url\(/gi, "")
357
+ .replace(/expression/gi, "")
358
+ .replace(/[<>]/g, "");
359
+
360
+ const renderLog = (entry) => {
361
+ const row = document.createElement("div");
362
+ row.className = "entry";
363
+ const time = document.createElement("span");
364
+ time.className = "time";
365
+ time.textContent = fmtTime(entry.ts);
366
+ const lvl = document.createElement("span");
367
+ lvl.className = `lvl lvl-${entry.level}`;
368
+ lvl.textContent = entry.level;
369
+ row.append(time, lvl);
370
+ if (entry.segments && entry.segments.length) {
371
+ for (const segment of entry.segments) {
372
+ const span = document.createElement("span");
373
+ span.textContent = segment.text;
374
+ if (segment.css) {
375
+ span.style.cssText = sanitizeCss(segment.css);
376
+ }
377
+ row.appendChild(span);
378
+ }
379
+ } else {
380
+ row.appendChild(document.createTextNode(entry.text));
381
+ }
382
+ return row;
383
+ };
384
+ const renderActivity = (a) => {
385
+ const row = document.createElement("div");
386
+ row.className = "entry";
387
+ const time = document.createElement("span");
388
+ time.className = "time";
389
+ time.textContent = fmtTime(a.ts);
390
+ const type = document.createElement("span");
391
+ type.className = "act-type";
392
+ type.textContent = a.type;
393
+ row.append(
394
+ time,
395
+ type,
396
+ document.createTextNode(roundNumbers(a.detail || "")),
397
+ );
398
+ return row;
399
+ };
400
+
401
+ const logs = createStream("logs", "logs-count", {
402
+ label: "Logs",
403
+ render: renderLog,
404
+ toText: (e) => `${fmtTime(e.ts)} [${e.level}] ${e.text}`,
405
+ });
406
+ const activity = createStream("activities", "activity-count", {
407
+ label: "Activity",
408
+ render: renderActivity,
409
+ toText: (a) =>
410
+ `${fmtTime(a.ts)} ${a.type}${a.detail ? `: ${roundNumbers(a.detail)}` : ""}`,
411
+ });
412
+
413
+ // Collapse/expand: click a panel header (but not its buttons).
414
+ for (const head of document.querySelectorAll(".panel-head")) {
415
+ head.addEventListener("click", (event) => {
416
+ if (event.target.closest("button")) {
417
+ return;
418
+ }
419
+ const panel = head.closest(".panel");
420
+ if (panel.hasAttribute("data-open")) {
421
+ panel.removeAttribute("data-open");
422
+ } else {
423
+ panel.setAttribute("data-open", "");
424
+ const body = panel.querySelector(".panel-body");
425
+ body.scrollTop = body.scrollHeight;
426
+ }
427
+ });
428
+ }
429
+ const streamByName = { logs, activity };
430
+ for (const button of document.querySelectorAll("button[data-copy]")) {
431
+ button.addEventListener("click", () =>
432
+ streamByName[button.getAttribute("data-copy")].copy(button),
433
+ );
434
+ }
435
+ for (const button of document.querySelectorAll("button[data-clear]")) {
436
+ button.addEventListener("click", () =>
437
+ streamByName[button.getAttribute("data-clear")].clear(),
438
+ );
439
+ }
440
+
441
+ const setStatus = (on, label) => {
442
+ statusEl.className = on ? "status on" : "status";
443
+ statusEl.innerHTML = `<span class="dot"></span>${label}`;
444
+ };
445
+
446
+ // Backlog first (a monitor opened late still sees recent history), and
447
+ // fill the header with the client's parsed browser/OS.
448
+ (async () => {
449
+ try {
450
+ const client = await (
451
+ await fetch(
452
+ `/.internal/client.json?id=${encodeURIComponent(clientId)}`,
453
+ )
454
+ ).json();
455
+ clientInfo = client;
456
+ clientinfoEl.textContent = describeClient(client);
457
+ for (const entry of client.logs || []) {
458
+ logs.add(entry);
459
+ }
460
+ for (const entry of client.activities || []) {
461
+ activity.add(entry);
462
+ }
463
+ } catch {
464
+ // dev server unreachable — the live channel still delivers updates.
465
+ }
466
+ })();
467
+
468
+ // …then live. This page is cooked through the dev server, so it gets the
469
+ // jsenv server-events client injected (window.__server_events__, ready
470
+ // before this script — the same channel autoreload rides). Everything is
471
+ // broadcast there, so keep only what belongs to the client we monitor.
472
+ setStatus(true, "monitoring");
473
+ window.__server_events__.listenEvents({
474
+ client_log: (event) => {
475
+ if (event.data.clientId === clientId) {
476
+ logs.add(event.data);
477
+ }
478
+ },
479
+ client_activity: (event) => {
480
+ if (event.data.clientId === clientId) {
481
+ activity.add(event.data);
482
+ }
483
+ },
484
+ });
485
+ </script>
486
+ </body>
487
+ </html>