@jsenv/core 41.2.14 → 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/dist/html/client_monitor_page.html +508 -0
- package/dist/html/clients_page.html +660 -0
- package/dist/js/client_reporter.js +502 -0
- package/dist/start_dev_server/start_dev_server.js +731 -143
- package/package.json +2 -2
- package/src/dev/start_dev_server.js +37 -13
- package/src/plugins/client_monitoring/client/client_monitor_page.html +487 -0
- package/src/plugins/client_monitoring/client/client_reporter.js +502 -0
- package/src/plugins/client_monitoring/client/clients_page.html +634 -0
- package/src/plugins/client_monitoring/jsenv_plugin_client_monitoring.js +568 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
|
|
3
3
|
import { lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, urlToRelativeUrl, errorToHTML, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, URL_META, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, registerDirectoryLifecycle, asUrlWithoutSearch, readEntryStatSync, ensurePathnameTrailingSlash, compareFileUrls, urlToFilename, applyFileSystemMagicResolution, getExtensionsToTry, setUrlExtension, createDetailedMessage, stringifyUrlSite, injectQueryParamsIntoSpecifier, isSpecifierForNodeBuiltin, injectQueryParams, urlToFileSystemPath, writeFileSync, moveUrl, ensureWindowsDriveLetter, validateResponseIntegrity, setUrlFilename, getCallerPosition, asSpecifierWithoutSearch, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, createLogger, normalizeUrl, ANSI, RUNTIME_COMPAT, formatError, assertAndNormalizeDirectoryUrl, createTaskLog } from "./jsenv_core_packages.js";
|
|
4
4
|
import { createPluginsController } from "@jsenv/server/src/plugins_controller.js";
|
|
5
|
-
import { parseHtml, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode,
|
|
5
|
+
import { parseHtml, injectJsenvScript, stringifyHtmlAst, parseCssUrls, getHtmlNodeAttribute, getHtmlNodePosition, getHtmlNodeAttributePosition, setHtmlNodeAttributes, parseSrcSet, getUrlForContentInsideHtml, removeHtmlNodeText, setHtmlNodeText, getHtmlNodeText, analyzeScriptNode, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, applyBabelPlugins, analyzeLinkNode, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, generateUrlForInlineContent, parseJsWithAcorn } from "@jsenv/ast";
|
|
6
6
|
import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
|
|
7
7
|
import { jsenvPluginTranspilation } from "@jsenv/plugin-transpilation";
|
|
8
8
|
import { createMagicSource, composeTwoSourcemaps, generateSourcemapFileUrl, generateSourcemapDataUrl, SOURCEMAP } from "@jsenv/sourcemap";
|
|
@@ -348,6 +348,699 @@ const testAppliesDuring = (plugin, kitchen) => {
|
|
|
348
348
|
);
|
|
349
349
|
};
|
|
350
350
|
|
|
351
|
+
const runtimeBySecChUa = new Map();
|
|
352
|
+
const runtimeByUserAgent = new Map();
|
|
353
|
+
|
|
354
|
+
const getRuntimeFromRequest = (request) => {
|
|
355
|
+
const secChUa = request.headers["sec-ch-ua"];
|
|
356
|
+
if (secChUa) {
|
|
357
|
+
const cached = runtimeBySecChUa.get(secChUa);
|
|
358
|
+
if (cached) {
|
|
359
|
+
return cached;
|
|
360
|
+
}
|
|
361
|
+
const result = parseSecChUaHeader(secChUa);
|
|
362
|
+
if (result) {
|
|
363
|
+
runtimeBySecChUa.set(secChUa, result);
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const userAgent = request.headers["user-agent"] || "";
|
|
368
|
+
const cached = runtimeByUserAgent.get(userAgent);
|
|
369
|
+
if (cached) {
|
|
370
|
+
return cached;
|
|
371
|
+
}
|
|
372
|
+
const result = parseUserAgentHeader(userAgent);
|
|
373
|
+
runtimeByUserAgent.set(userAgent, result);
|
|
374
|
+
return result;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const parseSecChUaHeader = (secChUa) => {
|
|
378
|
+
// sec-ch-ua format: "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"
|
|
379
|
+
const brands = [];
|
|
380
|
+
const regex = /"([^"]+)";v="([^"]+)"/g;
|
|
381
|
+
let match;
|
|
382
|
+
while ((match = regex.exec(secChUa)) !== null) {
|
|
383
|
+
const name = match[1];
|
|
384
|
+
const version = match[2];
|
|
385
|
+
// skip "Not X;Brand" noise entries
|
|
386
|
+
if (!name.includes("Not") && !name.includes("Brand")) {
|
|
387
|
+
brands.push({ name, version });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (brands.length === 0) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
// Prefer the non-Chromium brand (e.g. "Google Chrome", "Microsoft Edge")
|
|
394
|
+
// Fall back to "Chromium" if no specific brand found
|
|
395
|
+
let brand = brands.find((b) => b.name !== "Chromium");
|
|
396
|
+
if (!brand) {
|
|
397
|
+
brand = brands[0];
|
|
398
|
+
}
|
|
399
|
+
const runtimeName = brandNameToRuntimeName(brand.name);
|
|
400
|
+
const runtimeVersion = `${brand.version}.0.0`;
|
|
401
|
+
return { runtimeName, runtimeVersion };
|
|
402
|
+
};
|
|
403
|
+
const brandNameToRuntimeName = (brandName) => {
|
|
404
|
+
const lower = brandName.toLowerCase();
|
|
405
|
+
if (lower === "google chrome") {
|
|
406
|
+
return "chrome";
|
|
407
|
+
}
|
|
408
|
+
if (lower === "headlesschrome") {
|
|
409
|
+
return "chrome";
|
|
410
|
+
}
|
|
411
|
+
if (lower === "microsoft edge") {
|
|
412
|
+
return "edge";
|
|
413
|
+
}
|
|
414
|
+
if (lower === "opera") {
|
|
415
|
+
return "opera";
|
|
416
|
+
}
|
|
417
|
+
if (lower === "samsung internet") {
|
|
418
|
+
return "samsung";
|
|
419
|
+
}
|
|
420
|
+
if (lower === "chromium") {
|
|
421
|
+
return "chrome";
|
|
422
|
+
}
|
|
423
|
+
// other Chromium-based browsers share Chrome's compatibility
|
|
424
|
+
return "chrome";
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const parseUserAgentHeader = (userAgent) => {
|
|
428
|
+
if (userAgent.includes("node-fetch/")) {
|
|
429
|
+
// it's not really node and conceptually we can't assume the node version
|
|
430
|
+
// but good enough for now
|
|
431
|
+
return {
|
|
432
|
+
runtimeName: "node",
|
|
433
|
+
runtimeVersion: process.version.slice(1),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
// iOS Safari must be checked before Safari (UA contains both)
|
|
437
|
+
if (userAgent.includes("Mobile") && userAgent.includes("Safari")) {
|
|
438
|
+
const iosSafariMatch = userAgent.match(/\bOS (\d+)[._](\d+)(?:[._](\d+))?/);
|
|
439
|
+
if (iosSafariMatch) {
|
|
440
|
+
const major = iosSafariMatch[1];
|
|
441
|
+
const minor = iosSafariMatch[2] || "0";
|
|
442
|
+
const patch = iosSafariMatch[3] || "0";
|
|
443
|
+
return {
|
|
444
|
+
runtimeName: "ios_safari",
|
|
445
|
+
runtimeVersion: `${major}.${minor}.${patch}`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (!userAgent.includes("Chrome") && userAgent.includes("Safari")) {
|
|
450
|
+
const safariMatch = userAgent.match(/\bVersion\/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
451
|
+
if (safariMatch) {
|
|
452
|
+
const major = safariMatch[1];
|
|
453
|
+
const minor = safariMatch[2] || "0";
|
|
454
|
+
const patch = safariMatch[3] || "0";
|
|
455
|
+
return {
|
|
456
|
+
runtimeName: "safari",
|
|
457
|
+
runtimeVersion: `${major}.${minor}.${patch}`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const firefoxMatch = userAgent.match(/\bFirefox\/(\d+)\.(\d+)\b/);
|
|
462
|
+
if (firefoxMatch) {
|
|
463
|
+
const major = firefoxMatch[1];
|
|
464
|
+
const minor = firefoxMatch[2] || "0";
|
|
465
|
+
return { runtimeName: "firefox", runtimeVersion: `${major}.${minor}.0` };
|
|
466
|
+
}
|
|
467
|
+
// generic Chromium-based fallback (should normally be handled by sec-ch-ua)
|
|
468
|
+
const chromeMatch = userAgent.match(
|
|
469
|
+
/(?:HeadlessChrome|Chrome)\/(\d+)\.(\d+)\b/,
|
|
470
|
+
);
|
|
471
|
+
if (chromeMatch) {
|
|
472
|
+
const major = chromeMatch[1];
|
|
473
|
+
const minor = chromeMatch[2] || "0";
|
|
474
|
+
return { runtimeName: "chrome", runtimeVersion: `${major}.${minor}.0` };
|
|
475
|
+
}
|
|
476
|
+
return { runtimeName: "unknown", runtimeVersion: "unknown" };
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/*
|
|
480
|
+
* Client monitoring plugin (dev only)
|
|
481
|
+
* -----------------------------------
|
|
482
|
+
* Lets you watch one client from another — e.g. read a phone's console logs and
|
|
483
|
+
* activity from the desktop. A "client" here is a browser/navigator context that
|
|
484
|
+
* cooked one of our pages and reports back; we can only see clients that execute
|
|
485
|
+
* our injected script, not arbitrary HTTP clients of the dev server.
|
|
486
|
+
*
|
|
487
|
+
* Transport reuses what the dev server already has instead of opening a second
|
|
488
|
+
* websocket:
|
|
489
|
+
* - server → clients uses the jsenv "server events" channel (the same websocket
|
|
490
|
+
* the autoreload feature rides on). This plugin declares these server events:
|
|
491
|
+
* - "clients_list" the whole registry (the dashboard renders it)
|
|
492
|
+
* - "client_log" a single log line (a monitor appends it)
|
|
493
|
+
* - "client_activity" a single qualified activity (a monitor appends it)
|
|
494
|
+
* - "client_here" a client just appeared/resumed (every page can toast it)
|
|
495
|
+
* - "client_command" pilot one client (navigate/reload its tab) from the
|
|
496
|
+
* dashboard; the matching reporter runs it
|
|
497
|
+
* Server events are broadcast, so consumers filter what they care about.
|
|
498
|
+
* - clients → server uses plain HTTP POSTs. /.internal/clients/report carries the
|
|
499
|
+
* tab (id, url, title, visibility), recent qualified activities (click, request,
|
|
500
|
+
* navigation, …) and buffered console logs; a periodic heartbeat keeps it fresh.
|
|
501
|
+
* /.internal/clients/command lets the dashboard pilot a client. No extra socket.
|
|
502
|
+
*
|
|
503
|
+
* A client aggregates its open tabs and a short activity history. It is "online"
|
|
504
|
+
* when any report arrived within INACTIVITY_MS — there is no dedicated
|
|
505
|
+
* connection to track.
|
|
506
|
+
*
|
|
507
|
+
* The monitoring script is injected into EVERY cooked page, including our own
|
|
508
|
+
* dashboard and monitor pages — so opening one of those counts as a connected
|
|
509
|
+
* client, and any open page gets toasted when another client appears.
|
|
510
|
+
*
|
|
511
|
+
* Pages:
|
|
512
|
+
* - /.internal/clients → dashboard listing every client seen
|
|
513
|
+
* - /.internal/client?id=… → live console-log monitor for one client
|
|
514
|
+
*
|
|
515
|
+
* Both pages are served THROUGH the graph (via redirectReference to a real HTML
|
|
516
|
+
* file) rather than as a raw route response, so they get cooked like any app
|
|
517
|
+
* page — which is what injects window.__server_events__ into them. They consume
|
|
518
|
+
* the server events directly, no bespoke socket. See the "dev-server" skill next
|
|
519
|
+
* to @jsenv/core for how internal pages get script injection.
|
|
520
|
+
*/
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
// Normalize the dev server's { runtimeName, runtimeVersion } to the { name,
|
|
524
|
+
// version } shape the pages use for both browser and OS.
|
|
525
|
+
const runtimeFromRequest = (request) => {
|
|
526
|
+
const { runtimeName, runtimeVersion } = getRuntimeFromRequest(request);
|
|
527
|
+
return { name: runtimeName, version: runtimeVersion };
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const clientReporterFileUrl = new URL(
|
|
531
|
+
"../js/client_reporter.js",
|
|
532
|
+
import.meta.url,
|
|
533
|
+
).href;
|
|
534
|
+
const clientsPageFileUrl = new URL(
|
|
535
|
+
"../html/clients_page.html",
|
|
536
|
+
import.meta.url,
|
|
537
|
+
).href;
|
|
538
|
+
const clientMonitorPageFileUrl = new URL(
|
|
539
|
+
"../html/client_monitor_page.html",
|
|
540
|
+
import.meta.url,
|
|
541
|
+
).href;
|
|
542
|
+
|
|
543
|
+
// Keep at most this many log entries per client, and drop entries older than
|
|
544
|
+
// LOG_TTL_MS — the buffer only exists so a monitor opened a bit late can still
|
|
545
|
+
// show recent history; it is not a persistent store.
|
|
546
|
+
const LOG_MAX_PER_CLIENT = 1000;
|
|
547
|
+
const LOG_TTL_MS = 60 * 60 * 1000; // 1h
|
|
548
|
+
// Recent qualified activities kept per client (click, mousemove, request, …),
|
|
549
|
+
// so a page can show "what the client was last doing" and a short history.
|
|
550
|
+
const ACTIVITY_MAX_PER_CLIENT = 50;
|
|
551
|
+
// A client silent (no report at all) for longer than this, then reporting
|
|
552
|
+
// again, is treated as "resumed" and defines "online".
|
|
553
|
+
const INACTIVITY_MS = 60 * 1000;
|
|
554
|
+
// A tab not heard from for this long is considered closed and dropped.
|
|
555
|
+
const TAB_TTL_MS = 2 * 60 * 1000;
|
|
556
|
+
|
|
557
|
+
// The dev server already parses browser + version from a request (sec-ch-ua or
|
|
558
|
+
// user-agent) via getRuntimeFromRequest; it does not cover the OS, so this fills
|
|
559
|
+
// that gap from the user-agent string.
|
|
560
|
+
const osFromUserAgent = (userAgent) => {
|
|
561
|
+
const iosMatch = userAgent.match(/iPhone OS (\d+)[._](\d+)/);
|
|
562
|
+
if (iosMatch) {
|
|
563
|
+
return { name: "iOS", version: `${iosMatch[1]}.${iosMatch[2]}` };
|
|
564
|
+
}
|
|
565
|
+
if (/iPad/.test(userAgent)) {
|
|
566
|
+
const ipadMatch = userAgent.match(/OS (\d+)[._](\d+)/);
|
|
567
|
+
return {
|
|
568
|
+
name: "iPadOS",
|
|
569
|
+
version: ipadMatch ? `${ipadMatch[1]}.${ipadMatch[2]}` : "",
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const androidMatch = userAgent.match(/Android (\d+(?:\.\d+)*)/);
|
|
573
|
+
if (androidMatch) {
|
|
574
|
+
return { name: "Android", version: androidMatch[1] };
|
|
575
|
+
}
|
|
576
|
+
if (/Windows NT/.test(userAgent)) {
|
|
577
|
+
const winMatch = userAgent.match(/Windows NT (\d+\.\d+)/);
|
|
578
|
+
const version =
|
|
579
|
+
winMatch && winMatch[1] === "10.0" ? "10/11" : winMatch?.[1];
|
|
580
|
+
return { name: "Windows", version: version || "" };
|
|
581
|
+
}
|
|
582
|
+
const macMatch = userAgent.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/);
|
|
583
|
+
if (macMatch) {
|
|
584
|
+
const patch = macMatch[3] ? `.${macMatch[3]}` : "";
|
|
585
|
+
return { name: "macOS", version: `${macMatch[1]}.${macMatch[2]}${patch}` };
|
|
586
|
+
}
|
|
587
|
+
if (/CrOS/.test(userAgent)) {
|
|
588
|
+
return { name: "ChromeOS", version: "" };
|
|
589
|
+
}
|
|
590
|
+
if (/Linux/.test(userAgent)) {
|
|
591
|
+
return { name: "Linux", version: "" };
|
|
592
|
+
}
|
|
593
|
+
return { name: "unknown", version: "" };
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const jsenvPluginClientMonitoring = ({ rootDirectoryUrl } = {}) => {
|
|
597
|
+
// id -> client record
|
|
598
|
+
const clients = new Map();
|
|
599
|
+
|
|
600
|
+
// Assigned when the server-event channel is set up; broadcast helpers.
|
|
601
|
+
let sendClientsList = () => {};
|
|
602
|
+
let sendClientLog = () => {};
|
|
603
|
+
let sendClientActivity = () => {};
|
|
604
|
+
let sendClientHere = () => {};
|
|
605
|
+
// Pilot a client from the desktop: a targeted command (navigate/reload) the
|
|
606
|
+
// matching reporter runs. Broadcast like the rest; the reporter filters by id.
|
|
607
|
+
let sendClientCommand = () => {};
|
|
608
|
+
|
|
609
|
+
const now = () => Date.now();
|
|
610
|
+
// "online" = we heard from the client (any report) recently.
|
|
611
|
+
const isOnline = (client) => now() - client.lastSeen < INACTIVITY_MS;
|
|
612
|
+
|
|
613
|
+
const getOrCreateClient = (id, request) => {
|
|
614
|
+
const userAgent = request.headers["user-agent"] || "";
|
|
615
|
+
let client = clients.get(id);
|
|
616
|
+
if (!client) {
|
|
617
|
+
client = {
|
|
618
|
+
id,
|
|
619
|
+
userAgent,
|
|
620
|
+
runtime: runtimeFromRequest(request),
|
|
621
|
+
os: osFromUserAgent(userAgent),
|
|
622
|
+
firstSeen: now(),
|
|
623
|
+
lastSeen: now(),
|
|
624
|
+
everSeen: false,
|
|
625
|
+
logs: [],
|
|
626
|
+
activities: [],
|
|
627
|
+
// most recent qualified activity { type, detail, ts, tabId } or null
|
|
628
|
+
lastActivity: null,
|
|
629
|
+
// tabId -> { id, url, title, visible, lastSeen }
|
|
630
|
+
tabs: new Map(),
|
|
631
|
+
};
|
|
632
|
+
clients.set(id, client);
|
|
633
|
+
} else if (userAgent && userAgent !== client.userAgent) {
|
|
634
|
+
client.userAgent = userAgent;
|
|
635
|
+
client.runtime = runtimeFromRequest(request);
|
|
636
|
+
client.os = osFromUserAgent(userAgent);
|
|
637
|
+
}
|
|
638
|
+
return client;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const pruneLogs = (client) => {
|
|
642
|
+
const cutoff = now() - LOG_TTL_MS;
|
|
643
|
+
while (client.logs.length && client.logs[0].ts < cutoff) {
|
|
644
|
+
client.logs.shift();
|
|
645
|
+
}
|
|
646
|
+
while (client.logs.length > LOG_MAX_PER_CLIENT) {
|
|
647
|
+
client.logs.shift();
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const updateTab = (client, tab) => {
|
|
652
|
+
if (!tab || typeof tab.id !== "string") {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (tab.closing) {
|
|
656
|
+
client.tabs.delete(tab.id);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
client.tabs.set(tab.id, {
|
|
660
|
+
id: tab.id,
|
|
661
|
+
url: typeof tab.url === "string" ? tab.url : "",
|
|
662
|
+
title: typeof tab.title === "string" ? tab.title : "",
|
|
663
|
+
visible: Boolean(tab.visible),
|
|
664
|
+
lastSeen: now(),
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const pruneTabs = (client) => {
|
|
669
|
+
const cutoff = now() - TAB_TTL_MS;
|
|
670
|
+
for (const [id, tab] of client.tabs) {
|
|
671
|
+
if (tab.lastSeen < cutoff) {
|
|
672
|
+
client.tabs.delete(id);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// The tab to show as "current": a visible one wins, otherwise the most
|
|
678
|
+
// recently seen. No Math.max — a single scan keeping the best.
|
|
679
|
+
const activeTabOf = (client) => {
|
|
680
|
+
let best = null;
|
|
681
|
+
for (const tab of client.tabs.values()) {
|
|
682
|
+
if (!best) {
|
|
683
|
+
best = tab;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (tab.visible && !best.visible) {
|
|
687
|
+
best = tab;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (tab.visible === best.visible && tab.lastSeen > best.lastSeen) {
|
|
691
|
+
best = tab;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return best;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const recordActivity = (client, rawActivity) => {
|
|
698
|
+
const activity = {
|
|
699
|
+
type:
|
|
700
|
+
typeof rawActivity.type === "string" ? rawActivity.type : "activity",
|
|
701
|
+
detail: typeof rawActivity.detail === "string" ? rawActivity.detail : "",
|
|
702
|
+
ts: rawActivity.ts || now(),
|
|
703
|
+
tabId: typeof rawActivity.tabId === "string" ? rawActivity.tabId : "",
|
|
704
|
+
};
|
|
705
|
+
client.activities.push(activity);
|
|
706
|
+
while (client.activities.length > ACTIVITY_MAX_PER_CLIENT) {
|
|
707
|
+
client.activities.shift();
|
|
708
|
+
}
|
|
709
|
+
client.lastActivity = activity;
|
|
710
|
+
return activity;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const serializeTab = (tab) => ({
|
|
714
|
+
id: tab.id,
|
|
715
|
+
url: tab.url,
|
|
716
|
+
title: tab.title,
|
|
717
|
+
visible: tab.visible,
|
|
718
|
+
lastSeen: tab.lastSeen,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Summary sent in the (broadcast) list — kept lean: the active tab and a tab
|
|
722
|
+
// count rather than every tab, the last activity rather than the whole
|
|
723
|
+
// history. Pages fetch the full record for their dialogs.
|
|
724
|
+
const serializeClient = (client) => {
|
|
725
|
+
const activeTab = activeTabOf(client);
|
|
726
|
+
return {
|
|
727
|
+
id: client.id,
|
|
728
|
+
userAgent: client.userAgent,
|
|
729
|
+
// parsed { name, version } so pages can show a friendly browser/OS
|
|
730
|
+
runtime: client.runtime,
|
|
731
|
+
os: client.os,
|
|
732
|
+
firstSeen: client.firstSeen,
|
|
733
|
+
lastSeen: client.lastSeen,
|
|
734
|
+
online: isOnline(client),
|
|
735
|
+
logCount: client.logs.length,
|
|
736
|
+
tabCount: client.tabs.size,
|
|
737
|
+
activeTab: activeTab ? serializeTab(activeTab) : null,
|
|
738
|
+
lastActivity: client.lastActivity,
|
|
739
|
+
};
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const snapshot = () => [...clients.values()].map(serializeClient);
|
|
743
|
+
|
|
744
|
+
// A single client's full record: the summary plus everything a dialog needs
|
|
745
|
+
// (all tabs, recent activities, buffered logs). Client-scoped rather than
|
|
746
|
+
// named after logs because the shape is meant to grow.
|
|
747
|
+
const clientDetail = (client) => ({
|
|
748
|
+
...serializeClient(client),
|
|
749
|
+
tabs: [...client.tabs.values()].map(serializeTab),
|
|
750
|
+
activities: client.activities.slice(),
|
|
751
|
+
logs: client.logs,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Broadcasting the full list on every log line would be wasteful, so a
|
|
755
|
+
// log-driven refresh (logCount/lastActivity) is throttled; structural changes
|
|
756
|
+
// (a client appears/resumes) push immediately.
|
|
757
|
+
let listThrottleTimer = null;
|
|
758
|
+
const pushListThrottled = () => {
|
|
759
|
+
if (listThrottleTimer) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
listThrottleTimer = setTimeout(() => {
|
|
763
|
+
listThrottleTimer = null;
|
|
764
|
+
sendClientsList();
|
|
765
|
+
}, 1000);
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const ingest = async (request) => {
|
|
769
|
+
let body;
|
|
770
|
+
try {
|
|
771
|
+
body = await request.json();
|
|
772
|
+
} catch {
|
|
773
|
+
return { status: 400 };
|
|
774
|
+
}
|
|
775
|
+
const clientId = body.clientId;
|
|
776
|
+
if (!clientId || typeof clientId !== "string") {
|
|
777
|
+
return { status: 400 };
|
|
778
|
+
}
|
|
779
|
+
const client = getOrCreateClient(clientId, request);
|
|
780
|
+
|
|
781
|
+
const firstEver = !client.everSeen;
|
|
782
|
+
const wasOnline = !firstEver && isOnline(client);
|
|
783
|
+
client.everSeen = true;
|
|
784
|
+
client.lastSeen = now();
|
|
785
|
+
|
|
786
|
+
updateTab(client, body.tab);
|
|
787
|
+
pruneTabs(client);
|
|
788
|
+
|
|
789
|
+
if (firstEver) {
|
|
790
|
+
sendClientHere({ reason: "new", client: serializeClient(client) });
|
|
791
|
+
sendClientsList();
|
|
792
|
+
} else if (!wasOnline) {
|
|
793
|
+
// A report after a long quiet spell means the client was picked back up.
|
|
794
|
+
sendClientHere({ reason: "resumed", client: serializeClient(client) });
|
|
795
|
+
sendClientsList();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const activities = Array.isArray(body.activities) ? body.activities : [];
|
|
799
|
+
for (const rawActivity of activities) {
|
|
800
|
+
const activity = recordActivity(client, rawActivity);
|
|
801
|
+
sendClientActivity({ clientId, ...activity });
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const logs = Array.isArray(body.logs) ? body.logs : [];
|
|
805
|
+
for (const rawLog of logs) {
|
|
806
|
+
const entry = {
|
|
807
|
+
level: rawLog.level || "log",
|
|
808
|
+
text: typeof rawLog.text === "string" ? rawLog.text : "",
|
|
809
|
+
ts: rawLog.ts || now(),
|
|
810
|
+
};
|
|
811
|
+
// styled console segments ({ text, css } per %c run), when present, so a
|
|
812
|
+
// monitor can render colors; the plain text stays for copy/paste.
|
|
813
|
+
if (Array.isArray(rawLog.segments)) {
|
|
814
|
+
entry.segments = rawLog.segments;
|
|
815
|
+
}
|
|
816
|
+
client.logs.push(entry);
|
|
817
|
+
sendClientLog({ clientId, ...entry });
|
|
818
|
+
}
|
|
819
|
+
if (logs.length) {
|
|
820
|
+
pruneLogs(client);
|
|
821
|
+
}
|
|
822
|
+
pushListThrottled();
|
|
823
|
+
return { status: 204 };
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const jsonResponse = (data) => {
|
|
827
|
+
const json = JSON.stringify(data);
|
|
828
|
+
return {
|
|
829
|
+
status: 200,
|
|
830
|
+
headers: {
|
|
831
|
+
"content-type": "application/json",
|
|
832
|
+
"content-length": Buffer.byteLength(json),
|
|
833
|
+
},
|
|
834
|
+
body: json,
|
|
835
|
+
};
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// The .html pages served under the source directory, as server-relative URLs,
|
|
839
|
+
// so the dashboard can offer them as "navigate this client to…" targets. Skips
|
|
840
|
+
// node_modules, build output and dot-dirs. Cached briefly — one scan per
|
|
841
|
+
// dialog-open is plenty; it needn't be fresh to the second.
|
|
842
|
+
const PAGE_SCAN_TTL_MS = 3000;
|
|
843
|
+
const PAGE_SCAN_SKIP_DIRS = new Set([
|
|
844
|
+
"node_modules",
|
|
845
|
+
"dist",
|
|
846
|
+
"git_ignored",
|
|
847
|
+
"old",
|
|
848
|
+
]);
|
|
849
|
+
let pageScanCache = null;
|
|
850
|
+
let pageScanAt = 0;
|
|
851
|
+
const listNavigablePages = () => {
|
|
852
|
+
if (!rootDirectoryUrl) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
if (pageScanCache && now() - pageScanAt < PAGE_SCAN_TTL_MS) {
|
|
856
|
+
return pageScanCache;
|
|
857
|
+
}
|
|
858
|
+
const pages = [];
|
|
859
|
+
const walk = (dirUrl) => {
|
|
860
|
+
let entries;
|
|
861
|
+
try {
|
|
862
|
+
entries = readdirSync(new URL(dirUrl), { withFileTypes: true });
|
|
863
|
+
} catch {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
for (const entry of entries) {
|
|
867
|
+
const name = entry.name;
|
|
868
|
+
if (name[0] === ".") {
|
|
869
|
+
continue; // .git, .agents, dot-files…
|
|
870
|
+
}
|
|
871
|
+
if (entry.isDirectory()) {
|
|
872
|
+
if (!PAGE_SCAN_SKIP_DIRS.has(name)) {
|
|
873
|
+
walk(`${dirUrl}${name}/`);
|
|
874
|
+
}
|
|
875
|
+
} else if (name.endsWith(".html")) {
|
|
876
|
+
pages.push(
|
|
877
|
+
`/${urlToRelativeUrl(`${dirUrl}${name}`, rootDirectoryUrl)}`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
walk(String(rootDirectoryUrl));
|
|
883
|
+
pages.sort();
|
|
884
|
+
pageScanCache = pages;
|
|
885
|
+
pageScanAt = now();
|
|
886
|
+
return pages;
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Desktop pilots a client: validate a { clientId, tabId?, type, url? } command
|
|
890
|
+
// and broadcast it as a "client_command" server event. The reporter runs it
|
|
891
|
+
// only if the id (and tabId, when given) matches. type: "navigate" | "reload".
|
|
892
|
+
const ingestCommand = async (request) => {
|
|
893
|
+
let body;
|
|
894
|
+
try {
|
|
895
|
+
body = await request.json();
|
|
896
|
+
} catch {
|
|
897
|
+
return { status: 400 };
|
|
898
|
+
}
|
|
899
|
+
const { clientId, tabId, type, url } = body;
|
|
900
|
+
if (!clientId || typeof clientId !== "string") {
|
|
901
|
+
return { status: 400 };
|
|
902
|
+
}
|
|
903
|
+
if (type !== "navigate" && type !== "reload") {
|
|
904
|
+
return { status: 400 };
|
|
905
|
+
}
|
|
906
|
+
if (type === "navigate" && (!url || typeof url !== "string")) {
|
|
907
|
+
return { status: 400 };
|
|
908
|
+
}
|
|
909
|
+
sendClientCommand({
|
|
910
|
+
clientId,
|
|
911
|
+
tabId: typeof tabId === "string" ? tabId : null,
|
|
912
|
+
type,
|
|
913
|
+
url: type === "navigate" ? url : null,
|
|
914
|
+
});
|
|
915
|
+
return { status: 204 };
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
// Map our two page URLs onto their real HTML files. Returning a graph URL
|
|
919
|
+
// here (instead of serving the file from a raw route) makes the dev server
|
|
920
|
+
// cook the page, which is what gets window.__server_events__ injected into it.
|
|
921
|
+
const redirectToPage = (reference) => {
|
|
922
|
+
if (reference.isInline || !reference.url.startsWith("file:")) {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
const { pathname, search } = new URL(reference.url);
|
|
926
|
+
if (pathname.endsWith("/.internal/clients")) {
|
|
927
|
+
return clientsPageFileUrl;
|
|
928
|
+
}
|
|
929
|
+
if (pathname.endsWith("/.internal/client")) {
|
|
930
|
+
// carry ?id=… so the monitor page knows which client to watch
|
|
931
|
+
return `${clientMonitorPageFileUrl}${search}`;
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
name: "jsenv:client_monitoring",
|
|
938
|
+
appliesDuring: "dev",
|
|
939
|
+
redirectReference: redirectToPage,
|
|
940
|
+
serverEvents: {
|
|
941
|
+
clients_list: (serverEventInfo) => {
|
|
942
|
+
sendClientsList = () => serverEventInfo.sendServerEvent(snapshot());
|
|
943
|
+
},
|
|
944
|
+
client_log: (serverEventInfo) => {
|
|
945
|
+
sendClientLog = (payload) => serverEventInfo.sendServerEvent(payload);
|
|
946
|
+
},
|
|
947
|
+
client_activity: (serverEventInfo) => {
|
|
948
|
+
sendClientActivity = (payload) =>
|
|
949
|
+
serverEventInfo.sendServerEvent(payload);
|
|
950
|
+
},
|
|
951
|
+
client_here: (serverEventInfo) => {
|
|
952
|
+
sendClientHere = (payload) => serverEventInfo.sendServerEvent(payload);
|
|
953
|
+
},
|
|
954
|
+
client_command: (serverEventInfo) => {
|
|
955
|
+
sendClientCommand = (payload) =>
|
|
956
|
+
serverEventInfo.sendServerEvent(payload);
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
transformUrlContent: {
|
|
960
|
+
html: (urlInfo) => {
|
|
961
|
+
// Injected into every page, including our own dashboard/monitor pages,
|
|
962
|
+
// so opening one registers that browser as a client and any open page
|
|
963
|
+
// gets toasted when another client appears.
|
|
964
|
+
const htmlAst = parseHtml({ html: urlInfo.content, url: urlInfo.url });
|
|
965
|
+
injectJsenvScript(htmlAst, {
|
|
966
|
+
src: clientReporterFileUrl,
|
|
967
|
+
initCall: {
|
|
968
|
+
callee: "window.__client_monitoring__.setup",
|
|
969
|
+
params: {},
|
|
970
|
+
},
|
|
971
|
+
pluginName: "jsenv:client_monitoring",
|
|
972
|
+
});
|
|
973
|
+
return stringifyHtmlAst(htmlAst);
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
serverRoutes: [
|
|
977
|
+
// The two pages are actually served THROUGH the graph (see
|
|
978
|
+
// redirectReference above) so they get window.__server_events__ injected.
|
|
979
|
+
// These entries exist only to make the pages discoverable in the route
|
|
980
|
+
// inspector (/.internal/route_inspector): their fetch returns null, which
|
|
981
|
+
// makes the router fall through to the dev server's catch-all "GET *",
|
|
982
|
+
// which does the real cooking + injection.
|
|
983
|
+
{
|
|
984
|
+
endpoint: "GET /.internal/clients",
|
|
985
|
+
description:
|
|
986
|
+
"Dashboard of every browser (client) connected to this dev server since it started.",
|
|
987
|
+
availableMediaTypes: ["text/html"],
|
|
988
|
+
declarationSource: import.meta.url,
|
|
989
|
+
fetch: () => null,
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
endpoint: "GET /.internal/client",
|
|
993
|
+
description:
|
|
994
|
+
"Live monitor (console logs + activity) for one client — pass ?id=<clientId>.",
|
|
995
|
+
availableMediaTypes: ["text/html"],
|
|
996
|
+
declarationSource: import.meta.url,
|
|
997
|
+
fetch: () => null,
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
endpoint: "POST /.internal/clients/report",
|
|
1001
|
+
description:
|
|
1002
|
+
"A browser reports its console output and activity heartbeat here.",
|
|
1003
|
+
declarationSource: import.meta.url,
|
|
1004
|
+
fetch: (request) => ingest(request),
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
endpoint: "POST /.internal/clients/command",
|
|
1008
|
+
description:
|
|
1009
|
+
"Pilot a client from the dashboard: { clientId, tabId?, type: 'navigate'|'reload', url? }.",
|
|
1010
|
+
declarationSource: import.meta.url,
|
|
1011
|
+
fetch: (request) => ingestCommand(request),
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
endpoint: "GET /.internal/clients/pages.json",
|
|
1015
|
+
description:
|
|
1016
|
+
"The .html pages under the source directory, offered as navigation targets for a client.",
|
|
1017
|
+
availableMediaTypes: ["application/json"],
|
|
1018
|
+
declarationSource: import.meta.url,
|
|
1019
|
+
fetch: () => jsonResponse(listNavigablePages()),
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
endpoint: "GET /.internal/clients.json",
|
|
1023
|
+
description: "Snapshot of every client seen since the server started.",
|
|
1024
|
+
availableMediaTypes: ["application/json"],
|
|
1025
|
+
declarationSource: import.meta.url,
|
|
1026
|
+
fetch: () => jsonResponse(snapshot()),
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
endpoint: "GET /.internal/client.json",
|
|
1030
|
+
description:
|
|
1031
|
+
"One client's record — info plus buffered logs (?id=…), so a monitor opened late still has recent history.",
|
|
1032
|
+
availableMediaTypes: ["application/json"],
|
|
1033
|
+
declarationSource: import.meta.url,
|
|
1034
|
+
fetch: (request) => {
|
|
1035
|
+
const id = request.searchParams.get("id");
|
|
1036
|
+
const client = id && clients.get(id);
|
|
1037
|
+
return jsonResponse(client ? clientDetail(client) : { id, logs: [] });
|
|
1038
|
+
},
|
|
1039
|
+
},
|
|
1040
|
+
],
|
|
1041
|
+
};
|
|
1042
|
+
};
|
|
1043
|
+
|
|
351
1044
|
/*
|
|
352
1045
|
* https://github.com/parcel-bundler/parcel/blob/v2/packages/transformers/css/src/CSSTransformer.js
|
|
353
1046
|
*/
|
|
@@ -9622,134 +10315,6 @@ const inferUrlInfoType = (urlInfo) => {
|
|
|
9622
10315
|
return expectedType || "other";
|
|
9623
10316
|
};
|
|
9624
10317
|
|
|
9625
|
-
const runtimeBySecChUa = new Map();
|
|
9626
|
-
const runtimeByUserAgent = new Map();
|
|
9627
|
-
|
|
9628
|
-
const getRuntimeFromRequest = (request) => {
|
|
9629
|
-
const secChUa = request.headers["sec-ch-ua"];
|
|
9630
|
-
if (secChUa) {
|
|
9631
|
-
const cached = runtimeBySecChUa.get(secChUa);
|
|
9632
|
-
if (cached) {
|
|
9633
|
-
return cached;
|
|
9634
|
-
}
|
|
9635
|
-
const result = parseSecChUaHeader(secChUa);
|
|
9636
|
-
if (result) {
|
|
9637
|
-
runtimeBySecChUa.set(secChUa, result);
|
|
9638
|
-
return result;
|
|
9639
|
-
}
|
|
9640
|
-
}
|
|
9641
|
-
const userAgent = request.headers["user-agent"] || "";
|
|
9642
|
-
const cached = runtimeByUserAgent.get(userAgent);
|
|
9643
|
-
if (cached) {
|
|
9644
|
-
return cached;
|
|
9645
|
-
}
|
|
9646
|
-
const result = parseUserAgentHeader(userAgent);
|
|
9647
|
-
runtimeByUserAgent.set(userAgent, result);
|
|
9648
|
-
return result;
|
|
9649
|
-
};
|
|
9650
|
-
|
|
9651
|
-
const parseSecChUaHeader = (secChUa) => {
|
|
9652
|
-
// sec-ch-ua format: "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"
|
|
9653
|
-
const brands = [];
|
|
9654
|
-
const regex = /"([^"]+)";v="([^"]+)"/g;
|
|
9655
|
-
let match;
|
|
9656
|
-
while ((match = regex.exec(secChUa)) !== null) {
|
|
9657
|
-
const name = match[1];
|
|
9658
|
-
const version = match[2];
|
|
9659
|
-
// skip "Not X;Brand" noise entries
|
|
9660
|
-
if (!name.includes("Not") && !name.includes("Brand")) {
|
|
9661
|
-
brands.push({ name, version });
|
|
9662
|
-
}
|
|
9663
|
-
}
|
|
9664
|
-
if (brands.length === 0) {
|
|
9665
|
-
return null;
|
|
9666
|
-
}
|
|
9667
|
-
// Prefer the non-Chromium brand (e.g. "Google Chrome", "Microsoft Edge")
|
|
9668
|
-
// Fall back to "Chromium" if no specific brand found
|
|
9669
|
-
let brand = brands.find((b) => b.name !== "Chromium");
|
|
9670
|
-
if (!brand) {
|
|
9671
|
-
brand = brands[0];
|
|
9672
|
-
}
|
|
9673
|
-
const runtimeName = brandNameToRuntimeName(brand.name);
|
|
9674
|
-
const runtimeVersion = `${brand.version}.0.0`;
|
|
9675
|
-
return { runtimeName, runtimeVersion };
|
|
9676
|
-
};
|
|
9677
|
-
const brandNameToRuntimeName = (brandName) => {
|
|
9678
|
-
const lower = brandName.toLowerCase();
|
|
9679
|
-
if (lower === "google chrome") {
|
|
9680
|
-
return "chrome";
|
|
9681
|
-
}
|
|
9682
|
-
if (lower === "headlesschrome") {
|
|
9683
|
-
return "chrome";
|
|
9684
|
-
}
|
|
9685
|
-
if (lower === "microsoft edge") {
|
|
9686
|
-
return "edge";
|
|
9687
|
-
}
|
|
9688
|
-
if (lower === "opera") {
|
|
9689
|
-
return "opera";
|
|
9690
|
-
}
|
|
9691
|
-
if (lower === "samsung internet") {
|
|
9692
|
-
return "samsung";
|
|
9693
|
-
}
|
|
9694
|
-
if (lower === "chromium") {
|
|
9695
|
-
return "chrome";
|
|
9696
|
-
}
|
|
9697
|
-
// other Chromium-based browsers share Chrome's compatibility
|
|
9698
|
-
return "chrome";
|
|
9699
|
-
};
|
|
9700
|
-
|
|
9701
|
-
const parseUserAgentHeader = (userAgent) => {
|
|
9702
|
-
if (userAgent.includes("node-fetch/")) {
|
|
9703
|
-
// it's not really node and conceptually we can't assume the node version
|
|
9704
|
-
// but good enough for now
|
|
9705
|
-
return {
|
|
9706
|
-
runtimeName: "node",
|
|
9707
|
-
runtimeVersion: process.version.slice(1),
|
|
9708
|
-
};
|
|
9709
|
-
}
|
|
9710
|
-
// iOS Safari must be checked before Safari (UA contains both)
|
|
9711
|
-
if (userAgent.includes("Mobile") && userAgent.includes("Safari")) {
|
|
9712
|
-
const iosSafariMatch = userAgent.match(/\bOS (\d+)[._](\d+)(?:[._](\d+))?/);
|
|
9713
|
-
if (iosSafariMatch) {
|
|
9714
|
-
const major = iosSafariMatch[1];
|
|
9715
|
-
const minor = iosSafariMatch[2] || "0";
|
|
9716
|
-
const patch = iosSafariMatch[3] || "0";
|
|
9717
|
-
return {
|
|
9718
|
-
runtimeName: "ios_safari",
|
|
9719
|
-
runtimeVersion: `${major}.${minor}.${patch}`,
|
|
9720
|
-
};
|
|
9721
|
-
}
|
|
9722
|
-
}
|
|
9723
|
-
if (!userAgent.includes("Chrome") && userAgent.includes("Safari")) {
|
|
9724
|
-
const safariMatch = userAgent.match(/\bVersion\/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
9725
|
-
if (safariMatch) {
|
|
9726
|
-
const major = safariMatch[1];
|
|
9727
|
-
const minor = safariMatch[2] || "0";
|
|
9728
|
-
const patch = safariMatch[3] || "0";
|
|
9729
|
-
return {
|
|
9730
|
-
runtimeName: "safari",
|
|
9731
|
-
runtimeVersion: `${major}.${minor}.${patch}`,
|
|
9732
|
-
};
|
|
9733
|
-
}
|
|
9734
|
-
}
|
|
9735
|
-
const firefoxMatch = userAgent.match(/\bFirefox\/(\d+)\.(\d+)\b/);
|
|
9736
|
-
if (firefoxMatch) {
|
|
9737
|
-
const major = firefoxMatch[1];
|
|
9738
|
-
const minor = firefoxMatch[2] || "0";
|
|
9739
|
-
return { runtimeName: "firefox", runtimeVersion: `${major}.${minor}.0` };
|
|
9740
|
-
}
|
|
9741
|
-
// generic Chromium-based fallback (should normally be handled by sec-ch-ua)
|
|
9742
|
-
const chromeMatch = userAgent.match(
|
|
9743
|
-
/(?:HeadlessChrome|Chrome)\/(\d+)\.(\d+)\b/,
|
|
9744
|
-
);
|
|
9745
|
-
if (chromeMatch) {
|
|
9746
|
-
const major = chromeMatch[1];
|
|
9747
|
-
const minor = chromeMatch[2] || "0";
|
|
9748
|
-
return { runtimeName: "chrome", runtimeVersion: `${major}.${minor}.0` };
|
|
9749
|
-
}
|
|
9750
|
-
return { runtimeName: "unknown", runtimeVersion: "unknown" };
|
|
9751
|
-
};
|
|
9752
|
-
|
|
9753
10318
|
const devServerPluginServeSourceFiles = ({
|
|
9754
10319
|
packageDirectory,
|
|
9755
10320
|
sourceDirectoryUrl,
|
|
@@ -10149,26 +10714,41 @@ const cacheIsDisabledInResponseHeader = (urlInfo) => {
|
|
|
10149
10714
|
const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
|
|
10150
10715
|
|
|
10151
10716
|
/**
|
|
10152
|
-
* Starts the development server
|
|
10717
|
+
* Starts the jsenv development server: serves files from a source directory,
|
|
10718
|
+
* transforming (cooking) them on the fly through a plugin pipeline, with live
|
|
10719
|
+
* reload. Built on top of `@jsenv/server`.
|
|
10153
10720
|
*
|
|
10154
|
-
*
|
|
10155
|
-
*
|
|
10156
|
-
* @param {string} [params.hostname="localhost"] - Hostname to bind the server to.
|
|
10157
|
-
* @param {boolean} [params.https=false] - Whether to use HTTPS.
|
|
10721
|
+
* See the "dev-server" skill (.agents/skills/dev-server) for the plugin system,
|
|
10722
|
+
* server events, and how internal pages get script injection.
|
|
10158
10723
|
*
|
|
10159
|
-
* @
|
|
10160
|
-
* @
|
|
10724
|
+
* @param {Object} [params={}]
|
|
10725
|
+
* @param {string|URL} params.sourceDirectoryUrl - Root directory to serve (required; must exist).
|
|
10726
|
+
* @param {string} [params.sourceMainFilePath="./index.html"] - File served for "/".
|
|
10727
|
+
* @param {number} [params.port=3456] - Port to listen on (0 = a free port).
|
|
10728
|
+
* @param {string} [params.hostname] - Hostname to bind to.
|
|
10729
|
+
* @param {boolean} [params.acceptAnyIp=true] - Also accept connections on the machine's IPs.
|
|
10730
|
+
* @param {boolean|object} [params.https=false] - HTTPS as `{ certificate, privateKey }`.
|
|
10731
|
+
* @param {boolean} [params.http2=false] - HTTP/2 (requires https).
|
|
10732
|
+
* @param {Array} [params.plugins=[]] - jsenv plugins (transformUrlContent, serverRoutes, serverEvents, effect, …).
|
|
10733
|
+
* @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
|
|
10734
|
+
* @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
|
|
10735
|
+
* @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
|
|
10736
|
+
* @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
|
|
10737
|
+
* @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
|
|
10738
|
+
* @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
|
|
10739
|
+
* @param {string} [params.sourcemaps="inline"] - Sourcemap mode.
|
|
10740
|
+
* @param {AbortSignal} [params.signal] - Abort to stop the server.
|
|
10741
|
+
* @param {boolean} [params.handleSIGINT=true] - Stop on SIGINT.
|
|
10742
|
+
* @param {boolean} [params.keepProcessAlive=true] - Keep the process alive while running.
|
|
10161
10743
|
*
|
|
10162
|
-
* @
|
|
10163
|
-
*
|
|
10164
|
-
* const server = await startDevServer();
|
|
10165
|
-
* console.log(`Server started at ${server.origin}`);
|
|
10744
|
+
* @returns {Promise<{origin: string, sourceDirectoryUrl: URL, stop: () => Promise<void>, kitchenCache: object}>}
|
|
10745
|
+
* @throws {TypeError} On unknown params.
|
|
10166
10746
|
*
|
|
10167
10747
|
* @example
|
|
10168
|
-
* // Start a server with custom params
|
|
10169
10748
|
* const server = await startDevServer({
|
|
10170
|
-
*
|
|
10749
|
+
* sourceDirectoryUrl: new URL("./src/", import.meta.url),
|
|
10171
10750
|
* });
|
|
10751
|
+
* console.log(`Server started at ${server.origin}`);
|
|
10172
10752
|
*/
|
|
10173
10753
|
const startDevServer = async ({
|
|
10174
10754
|
sourceDirectoryUrl,
|
|
@@ -10303,6 +10883,14 @@ const startDevServer = async ({
|
|
|
10303
10883
|
|
|
10304
10884
|
const devServerJsenvPluginStore = await createJsenvPluginStore([
|
|
10305
10885
|
jsenvPluginServerEvents({ clientAutoreload }),
|
|
10886
|
+
// The client-monitoring dashboard is a dev-time convenience; a test-plan run
|
|
10887
|
+
// doesn't use it and shouldn't pay for the reporter being injected into
|
|
10888
|
+
// every page.
|
|
10889
|
+
...(EXECUTED_BY_TEST_PLAN
|
|
10890
|
+
? []
|
|
10891
|
+
: [
|
|
10892
|
+
jsenvPluginClientMonitoring({ rootDirectoryUrl: sourceDirectoryUrl }),
|
|
10893
|
+
]),
|
|
10306
10894
|
...plugins,
|
|
10307
10895
|
...getCorePlugins({
|
|
10308
10896
|
packageDirectory,
|