@farmslot/adapter-web 0.3.1 → 0.4.1
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 +12 -0
- package/README.md +17 -15
- package/dist/browser-cdp.d.cts +35 -1
- package/dist/network-observer.d.cts +15 -0
- package/dist/page-target.d.cts +9 -0
- package/dist/performance-observer.d.cts +26 -0
- package/package.json +12 -2
- package/src/browser-cdp.cjs +102 -1
- package/src/network-observer.cjs +228 -0
- package/src/page-target.cjs +34 -1
- package/src/performance-observer.cjs +94 -0
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.1 - 2026-10-07
|
|
10
|
+
|
|
11
|
+
- `network-observer`: `createExtensionNetworkObserver` takes an optional `extensionId`, the extension to capture. Without it the observer still takes the first extension with a target, which in a branded Chrome can be a component or policy extension listed before the one under test.
|
|
12
|
+
- Publish with adapter-sdk 0.6.0 and recipe-runner 0.26.0.
|
|
13
|
+
|
|
14
|
+
## 0.4.0 - 2026-10-06
|
|
15
|
+
|
|
16
|
+
- 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`.
|
|
17
|
+
- `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`.
|
|
18
|
+
- `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.
|
|
19
|
+
- Publish with adapter-sdk 0.5.1 and recipe-runner 0.26.0.
|
|
20
|
+
|
|
9
21
|
## 0.3.1 - 2026-10-06
|
|
10
22
|
|
|
11
23
|
- 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.
|
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
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
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
|
|
package/dist/browser-cdp.d.cts
CHANGED
|
@@ -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:
|
|
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;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{ cdpPort: number, runtimeDir: string, extensionId?: 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. `extensionId` names the
|
|
5
|
+
* extension to capture; without it, the first extension with a target is
|
|
6
|
+
* used, which can be a component or policy extension in a branded Chrome.
|
|
7
|
+
* @returns {Promise<import('@farmslot/adapter-sdk').NetworkCaptureBackend>}
|
|
8
|
+
*/
|
|
9
|
+
export function createExtensionNetworkObserver({ cdpPort, runtimeDir, extensionId: requestedExtensionId, connectTimeoutMs, commandTimeoutMs, }: {
|
|
10
|
+
cdpPort: number;
|
|
11
|
+
runtimeDir: string;
|
|
12
|
+
extensionId?: string;
|
|
13
|
+
connectTimeoutMs?: number;
|
|
14
|
+
commandTimeoutMs?: number;
|
|
15
|
+
}): Promise<import("@farmslot/adapter-sdk").NetworkCaptureBackend>;
|
package/dist/page-target.d.cts
CHANGED
|
@@ -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
|
+
"version": "0.4.1",
|
|
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.6.0",
|
|
83
|
+
"@farmslot/recipe-runner": "0.26.0",
|
|
74
84
|
"ws": "^8.18.0"
|
|
75
85
|
},
|
|
76
86
|
"devDependencies": {
|
package/src/browser-cdp.cjs
CHANGED
|
@@ -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 =
|
|
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,
|
|
@@ -0,0 +1,228 @@
|
|
|
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, extensionId?: 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. `extensionId` names the
|
|
33
|
+
* extension to capture; without it, the first extension with a target is
|
|
34
|
+
* used, which can be a component or policy extension in a branded Chrome.
|
|
35
|
+
* @returns {Promise<import('@farmslot/adapter-sdk').NetworkCaptureBackend>}
|
|
36
|
+
*/
|
|
37
|
+
async function createExtensionNetworkObserver({
|
|
38
|
+
cdpPort,
|
|
39
|
+
runtimeDir,
|
|
40
|
+
extensionId: requestedExtensionId,
|
|
41
|
+
connectTimeoutMs = DEFAULT_TIMEOUT_MS,
|
|
42
|
+
commandTimeoutMs = DEFAULT_TIMEOUT_MS,
|
|
43
|
+
}) {
|
|
44
|
+
if (!Number.isInteger(cdpPort) || cdpPort <= 0) {
|
|
45
|
+
throw new Error('Extension network observation requires a valid CDP port.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const connection = await connectBrowserCdp(cdpPort, {
|
|
49
|
+
timeoutMs: connectTimeoutMs,
|
|
50
|
+
commandTimeoutMs,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let closed = false;
|
|
54
|
+
let extensionId = null;
|
|
55
|
+
let broker = null;
|
|
56
|
+
const targetSessions = new Map();
|
|
57
|
+
const attaching = new Set();
|
|
58
|
+
|
|
59
|
+
const send = (method, params = {}, sessionId, timeoutMs = commandTimeoutMs) =>
|
|
60
|
+
connection.send(method, params, sessionId, timeoutMs);
|
|
61
|
+
|
|
62
|
+
const relevantTarget = (target) => {
|
|
63
|
+
if (!extensionId || !EXTENSION_TARGET_TYPES.has(target.type)) return false;
|
|
64
|
+
try {
|
|
65
|
+
return new URL(target.url).hostname === extensionId;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const refreshBrokerSession = () => {
|
|
72
|
+
if (!broker) return;
|
|
73
|
+
broker.onSessionClose(DEVICE_ID);
|
|
74
|
+
broker.onSessionOpen(DEVICE_ID);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const attachTarget = async (target) => {
|
|
78
|
+
if (
|
|
79
|
+
closed ||
|
|
80
|
+
!relevantTarget(target) ||
|
|
81
|
+
targetSessions.has(target.targetId) ||
|
|
82
|
+
attaching.has(target.targetId)
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
attaching.add(target.targetId);
|
|
87
|
+
try {
|
|
88
|
+
const result = asRecord(
|
|
89
|
+
await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }),
|
|
90
|
+
);
|
|
91
|
+
const sessionId = String(result.sessionId ?? '');
|
|
92
|
+
if (sessionId) {
|
|
93
|
+
targetSessions.set(target.targetId, sessionId);
|
|
94
|
+
refreshBrokerSession();
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!/already attached/iu.test(String(error))) broker?.onSessionClose(DEVICE_ID);
|
|
98
|
+
} finally {
|
|
99
|
+
attaching.delete(target.targetId);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
connection.onEvent(({ method, params, sessionId }) => {
|
|
104
|
+
if (method === 'Target.targetCreated') {
|
|
105
|
+
const target = asBrowserCdpTarget(params.targetInfo);
|
|
106
|
+
if (target) void attachTarget(target);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (method === 'Target.attachedToTarget') {
|
|
110
|
+
const target = asBrowserCdpTarget(params.targetInfo);
|
|
111
|
+
const attachedSessionId = String(params.sessionId ?? '');
|
|
112
|
+
if (target && attachedSessionId && relevantTarget(target)) {
|
|
113
|
+
targetSessions.set(target.targetId, attachedSessionId);
|
|
114
|
+
refreshBrokerSession();
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (method === 'Target.detachedFromTarget') {
|
|
119
|
+
const detachedSessionId = String(params.sessionId ?? '');
|
|
120
|
+
for (const [targetId, activeSessionId] of targetSessions) {
|
|
121
|
+
if (activeSessionId !== detachedSessionId) continue;
|
|
122
|
+
targetSessions.delete(targetId);
|
|
123
|
+
refreshBrokerSession();
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (sessionId && method.startsWith('Network.')) {
|
|
128
|
+
broker?.onCdpEvent(DEVICE_ID, method, params);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
connection.onClose(() => {
|
|
133
|
+
if (!closed) broker?.onSessionClose(DEVICE_ID);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await send('Target.setDiscoverTargets', { discover: true });
|
|
137
|
+
const targetInfos = asRecord(await send('Target.getTargets')).targetInfos;
|
|
138
|
+
const targets = Array.isArray(targetInfos) ? targetInfos : [];
|
|
139
|
+
extensionId = requestedExtensionId || extensionIdFromCdpTargets(targets);
|
|
140
|
+
if (!extensionId) {
|
|
141
|
+
connection.close();
|
|
142
|
+
throw new Error('Extension CDP browser or loaded extension target is unavailable.');
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(targets.map(asBrowserCdpTarget).filter(Boolean).map(attachTarget));
|
|
145
|
+
if (targetSessions.size === 0) {
|
|
146
|
+
connection.close();
|
|
147
|
+
throw new Error(
|
|
148
|
+
requestedExtensionId
|
|
149
|
+
? `Extension ${extensionId} has no attachable CDP targets (service worker stopped and no extension page open?).`
|
|
150
|
+
: 'Extension CDP exposes no attachable extension targets.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sessions = new Map([[DEVICE_ID, { brokerReady: true }]]);
|
|
155
|
+
const socketPath = brokerSocketPath(path.join(runtimeDir, 'extension-network'));
|
|
156
|
+
broker = createCdpBroker({
|
|
157
|
+
socketPath,
|
|
158
|
+
sessions,
|
|
159
|
+
async sendCommand(_session, method, params, timeoutMs) {
|
|
160
|
+
const sessionIds = [...new Set(targetSessions.values())];
|
|
161
|
+
if (sessionIds.length === 0) {
|
|
162
|
+
throw new Error('Extension CDP has no active extension target sessions.');
|
|
163
|
+
}
|
|
164
|
+
const results = await Promise.allSettled(
|
|
165
|
+
sessionIds.map((sessionId) => send(method, params, sessionId, timeoutMs)),
|
|
166
|
+
);
|
|
167
|
+
const successes = results.filter((result) => result.status === 'fulfilled');
|
|
168
|
+
if (successes.length === 0) {
|
|
169
|
+
const firstFailure = results.find((result) => result.status === 'rejected');
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Extension CDP command failed on every target: ${String(firstFailure?.reason ?? method)}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (successes.length !== results.length) {
|
|
175
|
+
results.forEach((result, index) => {
|
|
176
|
+
if (result.status === 'fulfilled') return;
|
|
177
|
+
const failedSession = sessionIds[index];
|
|
178
|
+
for (const [targetId, sessionId] of targetSessions) {
|
|
179
|
+
if (sessionId === failedSession) targetSessions.delete(targetId);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
broker?.onSessionClose(DEVICE_ID);
|
|
183
|
+
}
|
|
184
|
+
return successes[0].value;
|
|
185
|
+
},
|
|
186
|
+
requestDiscovery() {
|
|
187
|
+
void send('Target.getTargets')
|
|
188
|
+
.then((result) => {
|
|
189
|
+
const infos = asRecord(result).targetInfos;
|
|
190
|
+
if (!Array.isArray(infos)) return;
|
|
191
|
+
for (const value of infos) {
|
|
192
|
+
const target = asBrowserCdpTarget(value);
|
|
193
|
+
if (target) void attachTarget(target);
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
.catch(() => broker?.onSessionClose(DEVICE_ID));
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
broker.onSessionOpen(DEVICE_ID);
|
|
200
|
+
await waitForSocket(socketPath, commandTimeoutMs);
|
|
201
|
+
const client = await createBrokerClient(socketPath, DEVICE_ID, commandTimeoutMs);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
start(params) {
|
|
205
|
+
return client.control('capture-start', params, commandTimeoutMs);
|
|
206
|
+
},
|
|
207
|
+
async end(id) {
|
|
208
|
+
return asRecord(await client.control('capture-end', { id }, commandTimeoutMs));
|
|
209
|
+
},
|
|
210
|
+
async close() {
|
|
211
|
+
closed = true;
|
|
212
|
+
client.close();
|
|
213
|
+
broker?.close();
|
|
214
|
+
connection.close();
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function waitForSocket(socketPath, timeoutMs) {
|
|
220
|
+
const deadline = Date.now() + timeoutMs;
|
|
221
|
+
while (Date.now() < deadline) {
|
|
222
|
+
if (fs.existsSync(socketPath)) return;
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
224
|
+
}
|
|
225
|
+
throw new Error('Extension network observer broker did not start.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { createExtensionNetworkObserver };
|
package/src/page-target.cjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|