@farmslot/adapter-web 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ All notable changes to `@farmslot/adapter-web` are tracked here.
6
6
 
7
7
  - Active-development baseline; add user-facing changes here before release or package publication.
8
8
 
9
+ ## 0.4.0 - 2026-10-06
10
+
11
+ - Add `network-observer` (`createExtensionNetworkObserver({ cdpPort, runtimeDir })`) and `performance-observer` (`createExtensionPerformanceBackend({ cdpPort, extensionId, uiPaths, kind, platform, markerPrefix })`), the Extension network and performance observers moved from mm-harness. The network observer attaches to every target of the loaded extension and feeds their Network events to a `@farmslot/recipe-runner/cdp-broker`; it returns an `@farmslot/adapter-sdk` `NetworkCaptureBackend`. The performance observer traces the extension page found at the first of `uiPaths` with exactly one open renderer, through `@farmslot/recipe-runner/runtime/cdp-trace`. Both run on `connectBrowserCdp`; `connectTimeoutMs` and `commandTimeoutMs` default to 10 s. The package now depends on `@farmslot/recipe-runner` and `@farmslot/adapter-sdk`.
12
+ - `browser-cdp`: `connectBrowserCdp` clients gain `onEvent(handler)` (every CDP event, as `{ method, params, sessionId? }`; events were dropped before) and `onClose(handler)` (once, when the socket closes; at once on a client that has already closed), each returning an unsubscribe function. `onEvent` on a closed client keeps nothing. Add `asBrowserCdpTarget` and `extensionIdFromCdpTargets`.
13
+ - `page-target`: add `selectExtensionTarget(targets, extensionId, { paths })`, the extension UI renderer at the first path with any match, or null when that path has several.
14
+ - Publish with adapter-sdk 0.5.1 and recipe-runner 0.26.0.
15
+
16
+ ## 0.3.1 - 2026-10-06
17
+
18
+ - Browser probes no longer leave Chrome for Testing running when the resolver is stopped mid-probe (a hook timeout, a signal, or the resolver killed outright): the probe browser and its temporary profile are removed.
19
+ - A resolver killed outright cannot clean up; the next probe by the same user stops its browser and removes its profile. `browser-resolver` exports that step as `reapOrphanedProbes(tmp?)`.
20
+
9
21
  ## 0.3.0 - 2026-10-05
10
22
 
11
23
  - Add `dapp`, the web3 layer for a dapp under test, moved from `@deeeed/metamask-harness`'s Web Terminal adapter: the strict EIP-1193 test wallet (`createStrictWallet`), the page script that wraps the app's provider and logs its wallet requests or injects a provider the host answers (`pageScriptSource`, `pageReadyExpression`), the host's side of its bindings (`createWalletRequestBinding`: frame and document attribution, `outside-app-frame` and `unattributed` records, refusals) and the wallet request log (`windowSinceCursor`, `resetWindow`, `evaluateSignatureLog`, `awaitSignatureLog`, `writeLogArtifact`). Product policy is passed in: the typed data to refuse (`refuseTypedData`), the injected wallet's EIP-6963 identity and the log entries and typed-data classes a product forbids. Page bindings are `__farmslotWallet{Log,Request,Resolve}` and the page marker `__farmslotDapp`.
package/README.md CHANGED
@@ -12,21 +12,23 @@ Docs: https://farmslot.io/docs/reference/adapter-web
12
12
 
13
13
  ## Source layout
14
14
 
15
- | module | owns |
16
- | ------------------------------ | -------------------------------------------------------------------------------------------------------------- |
17
- | `browser-resolver` | Chrome for Testing vs branded Chrome, probe-launch and its cache, extension loading method; CLI entry |
18
- | `browser-cdp` | browser-level CDP client with deadlines, CDP-port ownership proof, `Extensions.loadUnpacked`, window placement |
19
- | `chrome-args` | remote-debugging flags, isolated profile flags, runtime identity nonce, launch quarantine markers |
20
- | `dapp` | web dapps: strict EIP-1193 test wallet, wallet-request page script and its host binding, wallet request log |
21
- | `extension-id` | Chromium extension id from a manifest key or an unpacked directory |
22
- | `launch-browser` | launch (or release) one isolated, detached, owned Chromium with an unpacked extension |
23
- | `macos-focus` | capture the frontmost app before a headed launch and restore it if one of our browsers took it |
24
- | `origin` | exact app-origin checks, the app top-frame CDP context check, URLs without query strings |
25
- | `page-target` | pick the page on an origin, preferring one whose URL carries a hash |
26
- | `playwright-cdp` | page evaluation over a raw CDP session (safe under LavaMoat scuttling) |
27
- | `slot-title` | prefix the extension home tab's title with the farm slot id, kept across the page's own title resets |
28
- | `validation-process-ownership` | find and stop the processes that own a slot profile |
29
- | `validation-launch-supervisor` | supervised child for one validation launch: port lease, quarantine and cleanup |
15
+ | module | owns |
16
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
17
+ | `browser-resolver` | Chrome for Testing vs branded Chrome, probe-launch and its cache, extension loading method; CLI entry |
18
+ | `browser-cdp` | browser-level CDP client with deadlines and events, CDP-port ownership proof, `Extensions.loadUnpacked`, window placement |
19
+ | `chrome-args` | remote-debugging flags, isolated profile flags, runtime identity nonce, launch quarantine markers |
20
+ | `dapp` | web dapps: strict EIP-1193 test wallet, wallet-request page script and its host binding, wallet request log |
21
+ | `extension-id` | Chromium extension id from a manifest key or an unpacked directory |
22
+ | `launch-browser` | launch (or release) one isolated, detached, owned Chromium with an unpacked extension |
23
+ | `macos-focus` | capture the frontmost app before a headed launch and restore it if one of our browsers took it |
24
+ | `network-observer` | network capture for every target of a loaded extension, through recipe-runner's CDP broker |
25
+ | `origin` | exact app-origin checks, the app top-frame CDP context check, URLs without query strings |
26
+ | `page-target` | pick the page on an origin, or an extension's UI renderer by path |
27
+ | `performance-observer` | CDP performance traces of an extension's UI renderer, through recipe-runner's trace collector |
28
+ | `playwright-cdp` | page evaluation over a raw CDP session (safe under LavaMoat scuttling) |
29
+ | `slot-title` | prefix the extension home tab's title with the farm slot id, kept across the page's own title resets |
30
+ | `validation-process-ownership` | find and stop the processes that own a slot profile |
31
+ | `validation-launch-supervisor` | supervised child for one validation launch: port lease, quarantine and cleanup |
30
32
 
31
33
  Sources are CommonJS (`src/**/*.cjs`) and ship as is; `yarn build` emits `.d.cts` declarations. Every library subpath can be `require`d or `import`ed with named imports, except `validation-launch-supervisor`: it is a child-process entry (it runs when loaded), so hosts `require.resolve` it and fork it.
32
34
 
@@ -1,3 +1,13 @@
1
+ export type BrowserCdpEvent = {
2
+ method: string;
3
+ params: Record<string, any>;
4
+ sessionId?: string;
5
+ };
6
+ export type BrowserCdpTarget = {
7
+ targetId: string;
8
+ type: string;
9
+ url: string;
10
+ };
1
11
  export const DEFAULT_COMMAND_TIMEOUT_MS: 15000;
2
12
  export const START_TIME_SLACK_MS: 2000;
3
13
  /**
@@ -14,6 +24,11 @@ export function assertCdpOwnedByProfile(port: number, profile: string, options?:
14
24
  profile: string;
15
25
  cdpPort: number;
16
26
  };
27
+ /**
28
+ * @param {unknown} value
29
+ * @returns {BrowserCdpTarget | null}
30
+ */
31
+ export function asBrowserCdpTarget(value: unknown): BrowserCdpTarget | null;
17
32
  /**
18
33
  * @param {{ pid: number, startedAt: string, profile: string, cdpPort: number }} owner
19
34
  * @param {{ exec?: typeof defaultExec, timeoutMs?: number }} [options]
@@ -57,14 +72,33 @@ export function waitForCdpOwner(port: any, profile: any, { timeoutMs, exec }?: {
57
72
  profile: string;
58
73
  cdpPort: number;
59
74
  };
75
+ /**
76
+ * @typedef {{ method: string, params: Record<string, any>, sessionId?: string }} BrowserCdpEvent
77
+ * @typedef {{ targetId: string, type: string, url: string }} BrowserCdpTarget
78
+ */
60
79
  export function connectBrowserCdp(port: any, { timeoutMs, commandTimeoutMs }?: {
61
80
  timeoutMs?: number | undefined;
62
81
  commandTimeoutMs?: number | undefined;
63
82
  }): Promise<{
64
- send: (method: any, params: {} | undefined, sessionId: any, timeout?: number) => Promise<any>;
83
+ send: (method: string, params?: Record<string, any>, sessionId?: string, timeout?: number) => Promise<any>;
84
+ /**
85
+ * @param {(event: BrowserCdpEvent) => void} handler
86
+ * @returns {() => void}
87
+ */
88
+ onEvent: (handler: (event: BrowserCdpEvent) => void) => () => void;
89
+ /**
90
+ * @param {() => void} handler
91
+ * @returns {() => void}
92
+ */
93
+ onClose: (handler: () => void) => () => void;
65
94
  close: () => void;
66
95
  }>;
67
96
  export function expectedExtensionId(extensionDir: any): string | null;
97
+ /**
98
+ * @param {unknown} targets
99
+ * @returns {string | null}
100
+ */
101
+ export function extensionIdFromCdpTargets(targets: unknown): string | null;
68
102
  export function loadUnpackedExtension(send: any, extensionDir: any, { expectedId, timeoutMs }?: {
69
103
  expectedId?: null | undefined;
70
104
  timeoutMs?: number | undefined;
@@ -64,6 +64,7 @@ export function probeLaunch(executable: any, { launchMethod, display, settleMs,
64
64
  durationMs: number;
65
65
  }>;
66
66
  export function readProbeCache(cachePath: any): any;
67
+ export function reapOrphanedProbes(tmp?: string): void;
67
68
  export function renderCheck(port: any): Promise<{
68
69
  ok: boolean;
69
70
  reason?: undefined;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @param {{ cdpPort: number, runtimeDir: string, connectTimeoutMs?: number, commandTimeoutMs?: number }} options
3
+ * `runtimeDir` keys the broker socket; the timeouts bound the CDP connection
4
+ * and every command, broker call and capture control.
5
+ * @returns {Promise<import('@farmslot/adapter-sdk').NetworkCaptureBackend>}
6
+ */
7
+ export function createExtensionNetworkObserver({ cdpPort, runtimeDir, connectTimeoutMs, commandTimeoutMs, }: {
8
+ cdpPort: number;
9
+ runtimeDir: string;
10
+ connectTimeoutMs?: number;
11
+ commandTimeoutMs?: number;
12
+ }): Promise<import("@farmslot/adapter-sdk").NetworkCaptureBackend>;
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @param {unknown} targets
3
+ * @param {string} extensionId
4
+ * @param {{ paths: readonly string[] }} options
5
+ * @returns {import('./browser-cdp.cjs').BrowserCdpTarget | null}
6
+ */
7
+ export function selectExtensionTarget(targets: unknown, extensionId: string, { paths }: {
8
+ paths: readonly string[];
9
+ }): import("./browser-cdp.cjs").BrowserCdpTarget | null;
1
10
  export function selectPageTarget(targets: any, { origin, hash }: {
2
11
  origin: any;
3
12
  hash?: string | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @template {string} [P=string]
3
+ * @param {{
4
+ * cdpPort: number,
5
+ * extensionId: string,
6
+ * uiPaths: readonly string[],
7
+ * kind: import('@farmslot/recipe-runner/runtime/cdp-trace').TraceKind,
8
+ * platform: P,
9
+ * markerPrefix?: string,
10
+ * connectTimeoutMs?: number,
11
+ * commandTimeoutMs?: number,
12
+ * }} options
13
+ * `uiPaths` are the extension pages to trace, in order of preference (see
14
+ * `selectExtensionTarget`); `kind`, `platform` and `markerPrefix` go to the collector.
15
+ * @returns {Promise<import('@farmslot/recipe-runner/runtime/cdp-trace').PerformanceBackend<P>>}
16
+ */
17
+ export function createExtensionPerformanceBackend<P extends string = string>({ cdpPort, extensionId, uiPaths, kind, platform, markerPrefix, connectTimeoutMs, commandTimeoutMs, }: {
18
+ cdpPort: number;
19
+ extensionId: string;
20
+ uiPaths: readonly string[];
21
+ kind: import("@farmslot/recipe-runner/runtime/cdp-trace").TraceKind;
22
+ platform: P;
23
+ markerPrefix?: string;
24
+ connectTimeoutMs?: number;
25
+ commandTimeoutMs?: number;
26
+ }): Promise<import("@farmslot/recipe-runner/runtime/cdp-trace").PerformanceBackend<P>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmslot/adapter-web",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Web platform adapter for Farmslot recipes: resolve and launch an isolated Chromium, own its CDP port, load unpacked extensions, and keep macOS focus.",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -32,6 +32,10 @@
32
32
  "types": "./dist/macos-focus.d.cts",
33
33
  "default": "./src/macos-focus.cjs"
34
34
  },
35
+ "./network-observer": {
36
+ "types": "./dist/network-observer.d.cts",
37
+ "default": "./src/network-observer.cjs"
38
+ },
35
39
  "./origin": {
36
40
  "types": "./dist/origin.d.cts",
37
41
  "default": "./src/origin.cjs"
@@ -41,6 +45,10 @@
41
45
  "types": "./dist/page-target.d.cts",
42
46
  "default": "./src/page-target.cjs"
43
47
  },
48
+ "./performance-observer": {
49
+ "types": "./dist/performance-observer.d.cts",
50
+ "default": "./src/performance-observer.cjs"
51
+ },
44
52
  "./playwright-cdp": {
45
53
  "types": "./dist/playwright-cdp.d.cts",
46
54
  "default": "./src/playwright-cdp.cjs"
@@ -66,11 +74,13 @@
66
74
  "lint": "eslint \"src/**/*.cjs\" \"test/**/*.cjs\" \"test/**/*.ts\"",
67
75
  "lint:fix": "eslint \"src/**/*.cjs\" \"test/**/*.cjs\" \"test/**/*.ts\" --fix",
68
76
  "quality": "prettier --check --ignore-path ../../.prettierignore . && eslint \"src/**/*.cjs\" \"test/**/*.cjs\" \"test/**/*.ts\" && yarn typecheck && yarn test",
69
- "build": "rm -rf dist && tsc --project tsconfig.build.json",
77
+ "build": "yarn workspace @farmslot/adapter-sdk build && rm -rf dist && tsc --project tsconfig.build.json",
70
78
  "prepack": "cd ../.. && node scripts/quality/check-farmslot-package-readiness.mjs --require-changelog --packages @farmslot/adapter-web && yarn workspace @farmslot/adapter-web build",
71
79
  "prepublishOnly": "node ../../scripts/quality/check-farmslot-package-readiness.mjs --publish --packages @farmslot/adapter-web"
72
80
  },
73
81
  "dependencies": {
82
+ "@farmslot/adapter-sdk": "0.5.1",
83
+ "@farmslot/recipe-runner": "0.26.0",
74
84
  "ws": "^8.18.0"
75
85
  },
76
86
  "devDependencies": {
@@ -5,6 +5,8 @@
5
5
  //
6
6
  // Every command has a deadline and every pending command fails when the
7
7
  // socket closes, so a browser that dies or stalls never hangs a launcher.
8
+ // Observers subscribe to CDP events (`onEvent`) and to the socket closing
9
+ // (`onClose`) on the same client.
8
10
  'use strict';
9
11
 
10
12
  const { execFileSync } = require('node:child_process');
@@ -21,6 +23,11 @@ function deadlineError(what, ms) {
21
23
  return Object.assign(new Error(`${what} did not answer within ${ms}ms`), { code: 'CDP_TIMEOUT' });
22
24
  }
23
25
 
26
+ /**
27
+ * @typedef {{ method: string, params: Record<string, any>, sessionId?: string }} BrowserCdpEvent
28
+ * @typedef {{ targetId: string, type: string, url: string }} BrowserCdpTarget
29
+ */
30
+
24
31
  async function connectBrowserCdp(
25
32
  port,
26
33
  { timeoutMs = 30000, commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = {},
@@ -45,6 +52,10 @@ async function connectBrowserCdp(
45
52
  const WebSocketImpl = globalThis.WebSocket ?? require('ws');
46
53
  const socket = new WebSocketImpl(url);
47
54
  const pending = new Map();
55
+ /** @type {Set<(event: BrowserCdpEvent) => void>} */
56
+ const eventHandlers = new Set();
57
+ /** @type {Set<() => void>} */
58
+ const closeHandlers = new Set();
48
59
  let closedError = null;
49
60
  const failAll = (error) => {
50
61
  closedError ??= error;
@@ -78,10 +89,24 @@ async function connectBrowserCdp(
78
89
  const lost = () =>
79
90
  failAll(new Error('Browser CDP socket closed (the browser exited or disconnected).'));
80
91
  socket.onerror = lost;
81
- socket.onclose = lost;
92
+ socket.onclose = () => {
93
+ lost();
94
+ for (const handler of [...closeHandlers]) handler();
95
+ closeHandlers.clear();
96
+ eventHandlers.clear();
97
+ };
82
98
  let nextId = 0;
83
99
  socket.onmessage = (event) => {
84
100
  const message = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data));
101
+ if (message.id === undefined && message.method) {
102
+ const cdpEvent = {
103
+ method: message.method,
104
+ params: message.params ?? {},
105
+ ...(message.sessionId ? { sessionId: message.sessionId } : {}),
106
+ };
107
+ for (const handler of [...eventHandlers]) handler(cdpEvent);
108
+ return;
109
+ }
85
110
  const entry = message.id === undefined ? null : pending.get(message.id);
86
111
  if (!entry) return;
87
112
  pending.delete(message.id);
@@ -89,6 +114,13 @@ async function connectBrowserCdp(
89
114
  if (message.error) entry.reject(new Error(`${entry.method}: ${message.error.message}`));
90
115
  else entry.resolve(message.result);
91
116
  };
117
+ /**
118
+ * @param {string} method
119
+ * @param {Record<string, any>} [params]
120
+ * @param {string} [sessionId]
121
+ * @param {number} [timeout]
122
+ * @returns {Promise<any>}
123
+ */
92
124
  const send = (method, params = {}, sessionId, timeout = commandTimeoutMs) =>
93
125
  new Promise((resolve, reject) => {
94
126
  if (closedError) {
@@ -109,6 +141,36 @@ async function connectBrowserCdp(
109
141
  });
110
142
  return {
111
143
  send,
144
+ // Every CDP event (a message without an id), from the browser or an
145
+ // attached session. Returns the unsubscribe function; a closed client
146
+ // has no more events and keeps no handler.
147
+ /**
148
+ * @param {(event: BrowserCdpEvent) => void} handler
149
+ * @returns {() => void}
150
+ */
151
+ onEvent: (handler) => {
152
+ if (closedError) return () => {};
153
+ eventHandlers.add(handler);
154
+ return () => {
155
+ eventHandlers.delete(handler);
156
+ };
157
+ },
158
+ // Once, when the socket closes: the browser exited or `close()` was called.
159
+ // On a client that is already closed, the handler runs at once.
160
+ /**
161
+ * @param {() => void} handler
162
+ * @returns {() => void}
163
+ */
164
+ onClose: (handler) => {
165
+ if (closedError) {
166
+ handler();
167
+ return () => {};
168
+ }
169
+ closeHandlers.add(handler);
170
+ return () => {
171
+ closeHandlers.delete(handler);
172
+ };
173
+ },
112
174
  close: () => {
113
175
  failAll(new Error('Browser CDP client closed.'));
114
176
  try {
@@ -712,10 +774,48 @@ function expectedExtensionId(extensionDir) {
712
774
  return extensionIdFromExtensionDir(extensionDir) || null;
713
775
  }
714
776
 
777
+ // ---- target lists ---------------------------------------------------------
778
+
779
+ // One entry of `Target.getTargets` (`targetId`) or of the `/json` list (`id`),
780
+ // or null when it lacks an id, type or URL.
781
+ /**
782
+ * @param {unknown} value
783
+ * @returns {BrowserCdpTarget | null}
784
+ */
785
+ function asBrowserCdpTarget(value) {
786
+ const target = /** @type {Record<string, unknown>} */ (
787
+ value && typeof value === 'object' && !Array.isArray(value) ? value : {}
788
+ );
789
+ const targetId = String(target.targetId ?? target.id ?? '');
790
+ const type = String(target.type ?? '');
791
+ const url = String(target.url ?? '');
792
+ return targetId && type && url ? { targetId, type, url } : null;
793
+ }
794
+
795
+ // The id of the first extension that has a target in the list.
796
+ /**
797
+ * @param {unknown} targets
798
+ * @returns {string | null}
799
+ */
800
+ function extensionIdFromCdpTargets(targets) {
801
+ for (const candidate of Array.isArray(targets) ? targets : []) {
802
+ const target = asBrowserCdpTarget(candidate);
803
+ if (!target) continue;
804
+ try {
805
+ const url = new URL(target.url);
806
+ if (url.protocol === 'chrome-extension:' && url.hostname) return url.hostname;
807
+ } catch {
808
+ // Not a URL: not an extension target.
809
+ }
810
+ }
811
+ return null;
812
+ }
813
+
715
814
  module.exports = {
716
815
  DEFAULT_COMMAND_TIMEOUT_MS,
717
816
  START_TIME_SLACK_MS,
718
817
  assertCdpOwnedByProfile,
818
+ asBrowserCdpTarget,
719
819
  assertSameOwner,
720
820
  canonicalProfile,
721
821
  cdpListenerPids,
@@ -724,6 +824,7 @@ module.exports = {
724
824
  waitForCdpOwner,
725
825
  connectBrowserCdp,
726
826
  expectedExtensionId,
827
+ extensionIdFromCdpTargets,
727
828
  loadUnpackedExtension,
728
829
  loadUnpackedOverPort,
729
830
  ensureBackgroundWindow,
@@ -529,8 +529,12 @@ async function withProbeLock(
529
529
  }
530
530
  }
531
531
 
532
- // Probes in flight, so a CLI watchdog can stop their browsers before exiting.
533
- const activeProbeProfiles = new Set();
532
+ // Probes in flight (profile -> spawned browser pid, or null for LaunchServices),
533
+ // so a watchdog, a signal or process exit can stop their browsers first.
534
+ const activeProbes = new Map();
535
+ const PROBE_PROFILE_PREFIX = 'farmslot-browser-probe-';
536
+ const PROBE_PROFILE_OWNER = new RegExp(`^${PROBE_PROFILE_PREFIX}(\\d+)-`, 'u');
537
+ const PROBE_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'];
534
538
 
535
539
  function profilePids(profile) {
536
540
  try {
@@ -556,6 +560,72 @@ function killProfile(profile) {
556
560
  }
557
561
  }
558
562
 
563
+ // Synchronous, so it also runs from 'exit' and signal handlers. The browser is
564
+ // outside the caller's process group (LaunchServices parents it; a spawned
565
+ // probe is detached), so a group kill of the caller never reaches it.
566
+ function stopProbe(profile) {
567
+ const pid = activeProbes.get(profile);
568
+ try {
569
+ if (pid) process.kill(-pid, 'SIGKILL');
570
+ } catch {
571
+ // Already exited.
572
+ }
573
+ killProfile(profile);
574
+ fs.rmSync(profile, { recursive: true, force: true });
575
+ activeProbes.delete(profile);
576
+ }
577
+
578
+ function stopActiveProbes() {
579
+ for (const profile of [...activeProbes.keys()]) stopProbe(profile);
580
+ }
581
+
582
+ // A hook timeout sends SIGTERM to the caller's process group. Stop the probe
583
+ // browsers, then let the signal end the process as it would have; a host with
584
+ // its own handler for that signal (a `once` handler is already gone by now, so
585
+ // it is noted at install) keeps control of the exit.
586
+ function onProbeSignal(signal) {
587
+ stopActiveProbes();
588
+ const hostHandles = hostSignals.has(signal) || process.listenerCount(signal) > 1;
589
+ setProbeExitHandlers(false);
590
+ if (!hostHandles) process.kill(process.pid, signal);
591
+ }
592
+
593
+ let probeExitHandlers = false;
594
+ let hostSignals = new Set();
595
+
596
+ function setProbeExitHandlers(on) {
597
+ if (probeExitHandlers === on) return;
598
+ probeExitHandlers = on;
599
+ if (on) hostSignals = new Set(PROBE_SIGNALS.filter((signal) => process.listenerCount(signal)));
600
+ for (const signal of PROBE_SIGNALS) {
601
+ if (on) process.on(signal, onProbeSignal);
602
+ else process.off(signal, onProbeSignal);
603
+ }
604
+ if (on) process.on('exit', stopActiveProbes);
605
+ else process.off('exit', stopActiveProbes);
606
+ }
607
+
608
+ function trackProbe(profile, pid = null) {
609
+ activeProbes.set(profile, pid);
610
+ setProbeExitHandlers(true);
611
+ }
612
+
613
+ // A resolver killed outright (SIGKILL) cannot clean up. Its profile name
614
+ // carries its pid, so the next probe by the same user stops that browser and
615
+ // removes the profile. Another user's profiles (a shared /tmp) are left alone.
616
+ function reapOrphanedProbes(tmp = os.tmpdir()) {
617
+ for (const name of fs.readdirSync(tmp)) {
618
+ const owner = PROBE_PROFILE_OWNER.exec(name);
619
+ if (!owner || Number(owner[1]) === process.pid || pidAlive(Number(owner[1]))) continue;
620
+ const profile = path.join(tmp, name);
621
+ // Another resolver's reaper may have removed it already.
622
+ const stat = fs.lstatSync(profile, { throwIfNoEntry: false });
623
+ if (!stat || stat.uid !== process.getuid()) continue;
624
+ killProfile(profile);
625
+ fs.rmSync(profile, { recursive: true, force: true });
626
+ }
627
+ }
628
+
559
629
  // `--version` succeeds even for a build that crashes at startup, so a browser
560
630
  // is accepted only when a start answers on DevTools, paints a frame, and
561
631
  // survives a settle without dying. The Chrome for Testing crash on macOS 26+
@@ -574,8 +644,9 @@ async function probeLaunch(
574
644
  } = {},
575
645
  ) {
576
646
  const method = effectiveLaunchMethod(executable, launchMethod);
577
- const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'farmslot-browser-probe-'));
578
- activeProbeProfiles.add(profile);
647
+ reapOrphanedProbes();
648
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), `${PROBE_PROFILE_PREFIX}${process.pid}-`));
649
+ trackProbe(profile);
579
650
  const startedAt = Date.now();
580
651
  const verdict = (ok, reason, transient = false) => ({
581
652
  ok,
@@ -589,6 +660,8 @@ async function probeLaunch(
589
660
  let exited = null;
590
661
  try {
591
662
  const port = await freePort();
663
+ // A host that survives a signal resumes here after stopProbe ran.
664
+ if (!activeProbes.has(profile)) return verdict(false, 'probe stopped by its caller', true);
592
665
  // A headful probe starts like the slot browsers (the launchers and the
593
666
  // library autolaunch): in the background with no startup window (Chrome
594
667
  // activates itself when it opens one); renderCheck opens a background
@@ -615,6 +688,7 @@ async function probeLaunch(
615
688
  });
616
689
  } else {
617
690
  child = spawn(executable, args, { detached: true, stdio: 'ignore' });
691
+ if (child.pid) trackProbe(profile, child.pid);
618
692
  child.on('exit', (code, signal) => {
619
693
  exited = signal ?? `code ${code}`;
620
694
  });
@@ -624,12 +698,16 @@ async function probeLaunch(
624
698
  }
625
699
  const alive = () =>
626
700
  method === LAUNCH_SERVICES ? profilePids(profile).length > 0 : exited === null;
701
+ // stopProbe removes the profile: the caller stopped the browser, so its
702
+ // exit says nothing about the binary.
627
703
  const died = (when) =>
628
- verdict(
629
- false,
630
- `exited ${when}${exited ? ` (${exited})` : ''}`,
631
- String(exited).startsWith('spawn failed'),
632
- );
704
+ activeProbes.has(profile)
705
+ ? verdict(
706
+ false,
707
+ `exited ${when}${exited ? ` (${exited})` : ''}`,
708
+ String(exited).startsWith('spawn failed'),
709
+ )
710
+ : verdict(false, 'probe stopped by its caller', true);
633
711
  let up = false;
634
712
  while (!up && Date.now() - startedAt < startupMs) {
635
713
  if (exited) return died('during startup');
@@ -655,7 +733,9 @@ async function probeLaunch(
655
733
  if (!alive()) return died('after DevTools started');
656
734
  await sleep(250);
657
735
  }
658
- if (await devtoolsAnswers(port)) return verdict(true, 'started and rendered');
736
+ const answers = await devtoolsAnswers(port);
737
+ if (!activeProbes.has(profile)) return verdict(false, 'probe stopped by its caller', true);
738
+ if (answers) return verdict(true, 'started and rendered');
659
739
  // Only a proven exit is a property of the binary.
660
740
  return alive()
661
741
  ? verdict(false, 'DevTools stopped answering after the settle', true)
@@ -663,15 +743,11 @@ async function probeLaunch(
663
743
  } catch (error) {
664
744
  return verdict(false, `launch failed: ${error.message}`, true);
665
745
  } finally {
666
- try {
667
- if (child?.pid) process.kill(-child.pid, 'SIGKILL');
668
- } catch {
669
- // Already exited.
670
- }
671
- killProfile(profile);
746
+ stopProbe(profile);
747
+ if (activeProbes.size === 0) setProbeExitHandlers(false);
748
+ // A dying browser can still write into its profile; remove it once more.
672
749
  await sleep(300);
673
750
  fs.rmSync(profile, { recursive: true, force: true });
674
- activeProbeProfiles.delete(profile);
675
751
  }
676
752
  }
677
753
 
@@ -1093,10 +1169,7 @@ async function main(argv) {
1093
1169
  }
1094
1170
  const args = parseCliArgs(rest);
1095
1171
  const watchdog = setTimeout(() => {
1096
- for (const profile of activeProbeProfiles) {
1097
- killProfile(profile);
1098
- fs.rmSync(profile, { recursive: true, force: true });
1099
- }
1172
+ stopActiveProbes();
1100
1173
  process.stderr.write(
1101
1174
  `browser-resolver ${command} did not finish within ${CLI_DEADLINE_MS}ms.\n`,
1102
1175
  );
@@ -1200,6 +1273,7 @@ module.exports = {
1200
1273
  probeCacheKey,
1201
1274
  probeLaunch,
1202
1275
  readProbeCache,
1276
+ reapOrphanedProbes,
1203
1277
  renderCheck,
1204
1278
  resolveBrowser,
1205
1279
  withProbeLock,
@@ -0,0 +1,221 @@
1
+ // network-observer.cjs — network capture for a browser extension: attach to
2
+ // every target of the loaded extension (page, background, service worker) on
3
+ // one browser CDP connection, follow targets as they come and go, and feed
4
+ // their Network events to a recipe-runner CDP broker that owns the captures.
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+
10
+ const {
11
+ brokerSocketPath,
12
+ createBrokerClient,
13
+ createCdpBroker,
14
+ } = require('@farmslot/recipe-runner/cdp-broker');
15
+
16
+ const {
17
+ asBrowserCdpTarget,
18
+ connectBrowserCdp,
19
+ extensionIdFromCdpTargets,
20
+ } = require('./browser-cdp.cjs');
21
+
22
+ const DEVICE_ID = 'extension';
23
+ const DEFAULT_TIMEOUT_MS = 10000;
24
+ const EXTENSION_TARGET_TYPES = new Set(['background_page', 'other', 'page', 'service_worker']);
25
+
26
+ const asRecord = (value) =>
27
+ value && typeof value === 'object' && !Array.isArray(value) ? value : {};
28
+
29
+ /**
30
+ * @param {{ cdpPort: number, runtimeDir: string, connectTimeoutMs?: number, commandTimeoutMs?: number }} options
31
+ * `runtimeDir` keys the broker socket; the timeouts bound the CDP connection
32
+ * and every command, broker call and capture control.
33
+ * @returns {Promise<import('@farmslot/adapter-sdk').NetworkCaptureBackend>}
34
+ */
35
+ async function createExtensionNetworkObserver({
36
+ cdpPort,
37
+ runtimeDir,
38
+ connectTimeoutMs = DEFAULT_TIMEOUT_MS,
39
+ commandTimeoutMs = DEFAULT_TIMEOUT_MS,
40
+ }) {
41
+ if (!Number.isInteger(cdpPort) || cdpPort <= 0) {
42
+ throw new Error('Extension network observation requires a valid CDP port.');
43
+ }
44
+
45
+ const connection = await connectBrowserCdp(cdpPort, {
46
+ timeoutMs: connectTimeoutMs,
47
+ commandTimeoutMs,
48
+ });
49
+
50
+ let closed = false;
51
+ let extensionId = null;
52
+ let broker = null;
53
+ const targetSessions = new Map();
54
+ const attaching = new Set();
55
+
56
+ const send = (method, params = {}, sessionId, timeoutMs = commandTimeoutMs) =>
57
+ connection.send(method, params, sessionId, timeoutMs);
58
+
59
+ const relevantTarget = (target) => {
60
+ if (!extensionId || !EXTENSION_TARGET_TYPES.has(target.type)) return false;
61
+ try {
62
+ return new URL(target.url).hostname === extensionId;
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
68
+ const refreshBrokerSession = () => {
69
+ if (!broker) return;
70
+ broker.onSessionClose(DEVICE_ID);
71
+ broker.onSessionOpen(DEVICE_ID);
72
+ };
73
+
74
+ const attachTarget = async (target) => {
75
+ if (
76
+ closed ||
77
+ !relevantTarget(target) ||
78
+ targetSessions.has(target.targetId) ||
79
+ attaching.has(target.targetId)
80
+ ) {
81
+ return;
82
+ }
83
+ attaching.add(target.targetId);
84
+ try {
85
+ const result = asRecord(
86
+ await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }),
87
+ );
88
+ const sessionId = String(result.sessionId ?? '');
89
+ if (sessionId) {
90
+ targetSessions.set(target.targetId, sessionId);
91
+ refreshBrokerSession();
92
+ }
93
+ } catch (error) {
94
+ if (!/already attached/iu.test(String(error))) broker?.onSessionClose(DEVICE_ID);
95
+ } finally {
96
+ attaching.delete(target.targetId);
97
+ }
98
+ };
99
+
100
+ connection.onEvent(({ method, params, sessionId }) => {
101
+ if (method === 'Target.targetCreated') {
102
+ const target = asBrowserCdpTarget(params.targetInfo);
103
+ if (target) void attachTarget(target);
104
+ return;
105
+ }
106
+ if (method === 'Target.attachedToTarget') {
107
+ const target = asBrowserCdpTarget(params.targetInfo);
108
+ const attachedSessionId = String(params.sessionId ?? '');
109
+ if (target && attachedSessionId && relevantTarget(target)) {
110
+ targetSessions.set(target.targetId, attachedSessionId);
111
+ refreshBrokerSession();
112
+ }
113
+ return;
114
+ }
115
+ if (method === 'Target.detachedFromTarget') {
116
+ const detachedSessionId = String(params.sessionId ?? '');
117
+ for (const [targetId, activeSessionId] of targetSessions) {
118
+ if (activeSessionId !== detachedSessionId) continue;
119
+ targetSessions.delete(targetId);
120
+ refreshBrokerSession();
121
+ }
122
+ return;
123
+ }
124
+ if (sessionId && method.startsWith('Network.')) {
125
+ broker?.onCdpEvent(DEVICE_ID, method, params);
126
+ }
127
+ });
128
+
129
+ connection.onClose(() => {
130
+ if (!closed) broker?.onSessionClose(DEVICE_ID);
131
+ });
132
+
133
+ await send('Target.setDiscoverTargets', { discover: true });
134
+ const targetInfos = asRecord(await send('Target.getTargets')).targetInfos;
135
+ const targets = Array.isArray(targetInfos) ? targetInfos : [];
136
+ extensionId = extensionIdFromCdpTargets(targets);
137
+ if (!extensionId) {
138
+ connection.close();
139
+ throw new Error('Extension CDP browser or loaded extension target is unavailable.');
140
+ }
141
+ await Promise.all(targets.map(asBrowserCdpTarget).filter(Boolean).map(attachTarget));
142
+ if (targetSessions.size === 0) {
143
+ connection.close();
144
+ throw new Error('Extension CDP exposes no attachable extension targets.');
145
+ }
146
+
147
+ const sessions = new Map([[DEVICE_ID, { brokerReady: true }]]);
148
+ const socketPath = brokerSocketPath(path.join(runtimeDir, 'extension-network'));
149
+ broker = createCdpBroker({
150
+ socketPath,
151
+ sessions,
152
+ async sendCommand(_session, method, params, timeoutMs) {
153
+ const sessionIds = [...new Set(targetSessions.values())];
154
+ if (sessionIds.length === 0) {
155
+ throw new Error('Extension CDP has no active extension target sessions.');
156
+ }
157
+ const results = await Promise.allSettled(
158
+ sessionIds.map((sessionId) => send(method, params, sessionId, timeoutMs)),
159
+ );
160
+ const successes = results.filter((result) => result.status === 'fulfilled');
161
+ if (successes.length === 0) {
162
+ const firstFailure = results.find((result) => result.status === 'rejected');
163
+ throw new Error(
164
+ `Extension CDP command failed on every target: ${String(firstFailure?.reason ?? method)}`,
165
+ );
166
+ }
167
+ if (successes.length !== results.length) {
168
+ results.forEach((result, index) => {
169
+ if (result.status === 'fulfilled') return;
170
+ const failedSession = sessionIds[index];
171
+ for (const [targetId, sessionId] of targetSessions) {
172
+ if (sessionId === failedSession) targetSessions.delete(targetId);
173
+ }
174
+ });
175
+ broker?.onSessionClose(DEVICE_ID);
176
+ }
177
+ return successes[0].value;
178
+ },
179
+ requestDiscovery() {
180
+ void send('Target.getTargets')
181
+ .then((result) => {
182
+ const infos = asRecord(result).targetInfos;
183
+ if (!Array.isArray(infos)) return;
184
+ for (const value of infos) {
185
+ const target = asBrowserCdpTarget(value);
186
+ if (target) void attachTarget(target);
187
+ }
188
+ })
189
+ .catch(() => broker?.onSessionClose(DEVICE_ID));
190
+ },
191
+ });
192
+ broker.onSessionOpen(DEVICE_ID);
193
+ await waitForSocket(socketPath, commandTimeoutMs);
194
+ const client = await createBrokerClient(socketPath, DEVICE_ID, commandTimeoutMs);
195
+
196
+ return {
197
+ start(params) {
198
+ return client.control('capture-start', params, commandTimeoutMs);
199
+ },
200
+ async end(id) {
201
+ return asRecord(await client.control('capture-end', { id }, commandTimeoutMs));
202
+ },
203
+ async close() {
204
+ closed = true;
205
+ client.close();
206
+ broker?.close();
207
+ connection.close();
208
+ },
209
+ };
210
+ }
211
+
212
+ async function waitForSocket(socketPath, timeoutMs) {
213
+ const deadline = Date.now() + timeoutMs;
214
+ while (Date.now() < deadline) {
215
+ if (fs.existsSync(socketPath)) return;
216
+ await new Promise((resolve) => setTimeout(resolve, 25));
217
+ }
218
+ throw new Error('Extension network observer broker did not start.');
219
+ }
220
+
221
+ module.exports = { createExtensionNetworkObserver };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { asBrowserCdpTarget } = require('./browser-cdp.cjs');
4
+
3
5
  // Choose the page a recipe drives from a CDP target list: a page on `origin`,
4
6
  // preferring one whose URL carries `hash`. Returns null when no page matches;
5
7
  // the caller owns the error it reports.
@@ -22,4 +24,35 @@ function selectPageTarget(targets, { origin, hash = '' }) {
22
24
  return pages[0];
23
25
  }
24
26
 
25
- module.exports = { selectPageTarget };
27
+ // Choose an extension's UI renderer from a CDP target list (`Target.getTargets`
28
+ // or `/json`): a page or `other` target of `extensionId` whose path is one of
29
+ // `paths`, tried in order. The first path with any match decides: exactly one
30
+ // match is returned, several are ambiguous and return null.
31
+ /**
32
+ * @param {unknown} targets
33
+ * @param {string} extensionId
34
+ * @param {{ paths: readonly string[] }} options
35
+ * @returns {import('./browser-cdp.cjs').BrowserCdpTarget | null}
36
+ */
37
+ function selectExtensionTarget(targets, extensionId, { paths }) {
38
+ const candidates = [];
39
+ for (const value of Array.isArray(targets) ? targets : []) {
40
+ const target = asBrowserCdpTarget(value);
41
+ if (!target || !['page', 'other'].includes(target.type)) continue;
42
+ try {
43
+ const url = new URL(target.url);
44
+ if (url.protocol === 'chrome-extension:' && url.hostname === extensionId) {
45
+ candidates.push({ target, pathname: url.pathname });
46
+ }
47
+ } catch {
48
+ // Not a URL: not an extension target.
49
+ }
50
+ }
51
+ for (const pathname of paths) {
52
+ const matches = candidates.filter((candidate) => candidate.pathname === pathname);
53
+ if (matches.length > 0) return matches.length === 1 ? matches[0].target : null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ module.exports = { selectExtensionTarget, selectPageTarget };
@@ -0,0 +1,94 @@
1
+ // performance-observer.cjs — CDP performance traces of a browser extension's
2
+ // UI renderer: attach to the extension page the host names, write the clock
3
+ // marker there with `performance.mark`, and trace through recipe-runner's
4
+ // CDP trace collector.
5
+ 'use strict';
6
+
7
+ const { connectBrowserCdp } = require('./browser-cdp.cjs');
8
+ const { selectExtensionTarget } = require('./page-target.cjs');
9
+
10
+ const DEFAULT_TIMEOUT_MS = 10000;
11
+
12
+ /**
13
+ * @template {string} [P=string]
14
+ * @param {{
15
+ * cdpPort: number,
16
+ * extensionId: string,
17
+ * uiPaths: readonly string[],
18
+ * kind: import('@farmslot/recipe-runner/runtime/cdp-trace').TraceKind,
19
+ * platform: P,
20
+ * markerPrefix?: string,
21
+ * connectTimeoutMs?: number,
22
+ * commandTimeoutMs?: number,
23
+ * }} options
24
+ * `uiPaths` are the extension pages to trace, in order of preference (see
25
+ * `selectExtensionTarget`); `kind`, `platform` and `markerPrefix` go to the collector.
26
+ * @returns {Promise<import('@farmslot/recipe-runner/runtime/cdp-trace').PerformanceBackend<P>>}
27
+ */
28
+ async function createExtensionPerformanceBackend({
29
+ cdpPort,
30
+ extensionId,
31
+ uiPaths,
32
+ kind,
33
+ platform,
34
+ markerPrefix,
35
+ connectTimeoutMs = DEFAULT_TIMEOUT_MS,
36
+ commandTimeoutMs = DEFAULT_TIMEOUT_MS,
37
+ }) {
38
+ if (!extensionId) {
39
+ throw new Error('Extension performance observation requires the extension id.');
40
+ }
41
+ // Loaded on use: recipe-runner's trace engine is an ES module built to dist,
42
+ // and the other adapter-web modules must load without it.
43
+ const { createCdpTraceCollector } = require('@farmslot/recipe-runner/runtime/cdp-trace');
44
+ const browser = await connectBrowserCdp(cdpPort, {
45
+ timeoutMs: connectTimeoutMs,
46
+ commandTimeoutMs,
47
+ });
48
+ let sessionId = '';
49
+ try {
50
+ const { targetInfos } = await browser.send('Target.getTargets', {});
51
+ const uiTarget = selectExtensionTarget(targetInfos, extensionId, { paths: uiPaths });
52
+ if (!uiTarget) {
53
+ throw new Error('Extension performance observation requires an open extension UI target.');
54
+ }
55
+ const attached = await browser.send('Target.attachToTarget', {
56
+ targetId: uiTarget.targetId,
57
+ flatten: true,
58
+ });
59
+ sessionId = String(attached?.sessionId ?? '');
60
+ if (!sessionId) {
61
+ throw new Error(
62
+ 'Extension performance observation could not attach to the extension UI target.',
63
+ );
64
+ }
65
+ } catch (error) {
66
+ browser.close();
67
+ throw error;
68
+ }
69
+ const client = {
70
+ send: (method, params = {}, timeoutMs) => browser.send(method, params, undefined, timeoutMs),
71
+ on: (method, handler) =>
72
+ browser.onEvent((event) => {
73
+ if (event.method === method) handler(event.params);
74
+ }),
75
+ close: () => browser.close(),
76
+ };
77
+ return createCdpTraceCollector(client, {
78
+ kind,
79
+ platform,
80
+ markerPrefix,
81
+ marker: async (name) => {
82
+ const before = Date.now();
83
+ await browser.send(
84
+ 'Runtime.evaluate',
85
+ { expression: `performance.mark(${JSON.stringify(name)})`, returnByValue: true },
86
+ sessionId,
87
+ );
88
+ const after = Date.now();
89
+ return { hostEpochMs: (before + after) / 2, uncertaintyMs: (after - before) / 2 };
90
+ },
91
+ });
92
+ }
93
+
94
+ module.exports = { createExtensionPerformanceBackend };