@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.
@@ -1,8 +1,8 @@
1
1
  import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, composeTwoResponses, fetchDirectory, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
2
- import { readFileSync, existsSync, readdirSync, lstatSync, realpathSync } from "node:fs";
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, stringifyHtmlAst, visitHtmlNodes, parseJsUrls, getUrlForContentInsideJs, injectJsenvScript, applyBabelPlugins, analyzeLinkNode, injectHtmlNodeAsEarlyAsPossible, createHtmlNode, generateUrlForInlineContent, parseJsWithAcorn } from "@jsenv/ast";
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
- * @param {Object} [params={}] - Configuration params for the dev server.
10155
- * @param {number} [params.port=3456] - Port number the server should listen on.
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
- * @returns {Promise<Object>} A promise that resolves to the server instance.
10160
- * @throws {Error} Will throw an error if the server fails to start or is called with unexpected params.
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
- * @example
10163
- * // Start a basic dev server
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
- * port: 8080,
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,