pi-browser-harness 0.2.0 → 0.3.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 +70 -0
- package/package.json +4 -1
- package/skills/pi-browser-harness/SKILL.md +17 -4
- package/src/cdp/discovery.ts +95 -0
- package/src/cdp/errors.ts +19 -0
- package/src/cdp/session.ts +139 -0
- package/src/cdp/transport.ts +221 -0
- package/src/cdp/types.ts +72 -0
- package/src/client.ts +232 -0
- package/src/domains/click.ts +73 -0
- package/src/domains/dialog.ts +31 -0
- package/src/domains/drag.ts +66 -0
- package/src/domains/files.ts +194 -0
- package/src/domains/history.ts +76 -0
- package/src/domains/js.ts +197 -0
- package/src/domains/keyboard.ts +127 -0
- package/src/domains/navigate.ts +146 -0
- package/src/domains/network.ts +80 -0
- package/src/domains/page.ts +88 -0
- package/src/domains/screenshot.ts +114 -0
- package/src/domains/scroll.ts +53 -0
- package/src/domains/tabs.ts +121 -0
- package/src/domains/viewport.ts +40 -0
- package/src/index.ts +65 -142
- package/src/prompt.ts +16 -3
- package/src/registry.ts +52 -0
- package/src/schemas/common.ts +11 -0
- package/src/setup.ts +20 -21
- package/src/state.ts +22 -54
- package/src/util/js-template.ts +27 -0
- package/src/util/keycodes.ts +10 -0
- package/src/util/paths.ts +21 -0
- package/src/util/result.ts +15 -0
- package/src/util/sharp-shim.ts +49 -0
- package/src/util/time.ts +29 -0
- package/src/util/tool.ts +169 -0
- package/src/util/truncate.ts +35 -0
- package/tsconfig.json +10 -1
- package/src/daemon.ts +0 -881
- package/src/protocol.ts +0 -77
- package/src/renderers.ts +0 -21
- package/src/tools.ts +0 -1363
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.3.1 — 2026-05-02
|
|
6
|
+
|
|
7
|
+
### Bug fixes
|
|
8
|
+
|
|
9
|
+
- **`browser_list_tabs`** now shows full 32-character targetIds instead of truncated `BE9DD1DC…` prefixes. The `browser_list_tabs` → `browser_switch_tab` round-trip is repaired.
|
|
10
|
+
- **`browser_switch_tab`** now supports prefix matching: pass a unique hex prefix (≥8 chars) and it resolves to the full targetId automatically. Ambiguous prefixes return a clear error listing all matching tabs.
|
|
11
|
+
- **`browser_download`** auto-creates the download directory with `mkdir -p` if it doesn't exist. Previously it required a pre-existing writable directory.
|
|
12
|
+
- **`ensureAlive()`** now probes the page session with `Runtime.evaluate("1")` after the transport health check. If the page target has crashed (e.g. localhost server died), it reattaches automatically instead of returning a cryptic `session_not_found` error on the next tool call.
|
|
13
|
+
|
|
14
|
+
### Docs & metadata
|
|
15
|
+
|
|
16
|
+
- **`browser_dispatch_key`** prompt guidelines now explicitly note it dispatches a synthetic DOM `KeyboardEvent` and does NOT type text. Point users to `browser_type` / `browser_press_key` for actual text input.
|
|
17
|
+
- **`browser_navigate`** prompt guidelines now warn that Google and strict-anti-bot sites may reject CDP navigation. `browser_http_get` is the recommended workaround.
|
|
18
|
+
- **`browser_get_network_log`** prompt snippet updated with explicit workaround (`browser_execute_js` with `PerformanceObserver`).
|
|
19
|
+
- **`sharp`** added to `optionalDependencies` so `npm install` attempts it (enables `browser_screenshot` `maxDim` auto-resize).
|
|
20
|
+
- **`SKILL.md`** script bindings section updated to document the actual daemon API: `daemon.evaluateJs()`, `daemon.pageInfo()`, `daemon.listTabs()`, `daemon.session().call()`. Example script updated to match.
|
|
21
|
+
- **`SKILL.md`** troubleshooting section now covers `sharp`/`maxDim` and Google anti-bot navigation.
|
|
22
|
+
|
|
23
|
+
## 0.3.0 — 2026-05-02
|
|
24
|
+
|
|
25
|
+
### Internal rewrite
|
|
26
|
+
|
|
27
|
+
- Per-domain module split: every tool now lives in its own `src/domains/<name>.ts` file. The 1140-line `daemon.ts` and 2277-line `tools.ts` are gone; the three largest files are now `src/cdp/transport.ts` (~220 LOC), `src/client.ts` (~220 LOC), and `src/domains/js.ts` (~200 LOC).
|
|
28
|
+
- New transport/session/client split: `BrowserDaemon` class replaced with `createBrowserClient()` factory composing a `CdpTransport` (factory) and `CdpSession` (factory).
|
|
29
|
+
- All tool handlers now return `Result<T, E>`; one `defineBrowserTool` helper converts to pi's `ToolDefinition` and supplies a uniform `details` shape: `{ ok: true, ... }` on success, `{ ok: false, kind, message, ... }` on error.
|
|
30
|
+
- Strict TypeScript flags enabled (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`, `noUnusedLocals`, `noUnusedParameters`). Zero `any` in the codebase. All boundary `as` casts documented.
|
|
31
|
+
|
|
32
|
+
### Bug fixes (predictability)
|
|
33
|
+
|
|
34
|
+
- `browser_navigate` no longer silently creates a new tab when navigation fails; outcome is reported explicitly via `details.outcome.kind` (`"in_place"` | `"new_tab_created"`).
|
|
35
|
+
- Page-info cache invalidation is now automatic: the session subscribes to CDP `Page.frameNavigated` / `Page.loadEventFired` events. The 5 manual `invalidatePageInfoCache()` calls are gone.
|
|
36
|
+
- Dialog read no longer mutates state on `Page.javascriptDialogClosed` — the dialog persists in the buffer until `takeDialog()` is called, fixing a fast-dismiss race that dropped dialogs.
|
|
37
|
+
- `browser_dispatch_key` now returns `details.matched` (count of elements the synthetic event was dispatched to). Zero matches is an `invalid_state` error instead of a false success.
|
|
38
|
+
- `browser_http_get` timeout now covers the response body read (legacy aborted headers but `await response.text()` could hang indefinitely).
|
|
39
|
+
- `sharp` failures are distinguished from "sharp not installed" — actual errors no longer masked by the install hint.
|
|
40
|
+
- Screenshot paths use `randomUUID()` per-namespace — no more concurrent-write collisions from `Date.now() + global counter`.
|
|
41
|
+
- Reconnect is lazy (via `ensureAlive`) — no background reconnect, no stacked Chrome consent popups.
|
|
42
|
+
- WebSocket events are routed through an `AsyncIterable` bound to each connection — stale events from a previous connection can no longer leak across reconnects.
|
|
43
|
+
- `browser_wait_for_load` returns a typed `timeout` error if the deadline elapses (legacy returned a soft string).
|
|
44
|
+
|
|
45
|
+
### Security fixes
|
|
46
|
+
|
|
47
|
+
- All JS evaluation source is built via `safeJs\`...\`` (always JSON.stringify-safe). The previous `replace(/'/g, "\\'")` selector escaping (broken for backslashes, newlines, unicode quotes, `</script>`) is gone.
|
|
48
|
+
- `browser_run_script` now requires:
|
|
49
|
+
- script path inside `tmpdir()`, `cwd()`, or `BH_SCRIPT_DIR` (other paths rejected with `invalid_state`)
|
|
50
|
+
- a mandatory timeout (default 60s, max 600s, enforced via `Promise.race`)
|
|
51
|
+
- the AbortSignal is honored even if the script ignores its `signal` parameter
|
|
52
|
+
- source size ≤ 1 MB
|
|
53
|
+
- return shape validated structurally (each content item must be `{ type: "text", text: string }`)
|
|
54
|
+
- `browser_download` validates the directory exists and is writable before calling CDP (Chrome was silently downloading to nowhere if the dir was bogus).
|
|
55
|
+
- `browser_upload_file` verifies the file exists and is readable before any CDP call (prevents half-set state on the input).
|
|
56
|
+
- `pdfPath()` / `screenshotPath()` validate the namespace against a strict regex so a hostile namespace cannot escape `tmpdir()`.
|
|
57
|
+
|
|
58
|
+
### Parameter renames (saved scripts must be updated)
|
|
59
|
+
|
|
60
|
+
- `browser_click`: `clicks` → `count`
|
|
61
|
+
- `browser_dispatch_key`: `event` → `eventType`
|
|
62
|
+
|
|
63
|
+
### Removed
|
|
64
|
+
|
|
65
|
+
- The unused `tabHistory`, `screenshotDir`, and `debugClicks` fields on persisted state.
|
|
66
|
+
- The dead `tool_result` hook in `index.ts` for tab-history tracking (`details.targetId` was never set by any tool).
|
|
67
|
+
- `src/protocol.ts`, `src/renderers.ts`, `src/daemon.ts`, `src/tools.ts` (replaced by per-domain files and `client.ts`).
|
|
68
|
+
|
|
69
|
+
### Known follow-ups
|
|
70
|
+
|
|
71
|
+
- `browser_get_network_log` returns a structured deprecation note. The new transport routes events through an `AsyncIterable` consumed by the session manager; a synchronous `recentEvents()` API is deferred. Use `browser_execute_js` with `PerformanceObserver` or `performance.getEntries()` as a workaround.
|
|
72
|
+
- `browser_run_script` script binding is named `daemon` for back-compat, but the underlying object is now a `BrowserClient`. Scripts using `daemon.cdp(method, params)` should switch to `daemon.session().call(method, params)`.
|
|
73
|
+
- No tests added in this rewrite; that's a separate workstream.
|
|
74
|
+
|
|
5
75
|
## [0.2.0] - 2026-05-02
|
|
6
76
|
|
|
7
77
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -62,6 +62,9 @@
|
|
|
62
62
|
"publishConfig": {
|
|
63
63
|
"access": "public"
|
|
64
64
|
},
|
|
65
|
+
"optionalDependencies": {
|
|
66
|
+
"sharp": "^0.33.0"
|
|
67
|
+
},
|
|
65
68
|
"dependencies": {
|
|
66
69
|
"ws": "^8.20.0"
|
|
67
70
|
}
|
|
@@ -130,7 +130,18 @@ The script is written to disk, so it's auditable and re-runnable.
|
|
|
130
130
|
|
|
131
131
|
**Script bindings:**
|
|
132
132
|
- `params` — the arguments passed to browser_run_script
|
|
133
|
-
- `daemon` — browser daemon
|
|
133
|
+
- `daemon` — browser daemon with these methods:
|
|
134
|
+
- `daemon.evaluateJs(expression)` — run JS in the current page
|
|
135
|
+
- `daemon.pageInfo()` — get current page info or dialog
|
|
136
|
+
- `daemon.listTabs()` — returns `{ success, data: [{ targetId, url, title }] }`
|
|
137
|
+
- `daemon.switchTab(targetId)` — switch to a tab by full targetId
|
|
138
|
+
- `daemon.newTab(url?)` — open and switch to a new tab
|
|
139
|
+
- `daemon.current()` — returns `{ targetId, url?, title? }`
|
|
140
|
+
- `daemon.session(targetId)` — returns a session object for raw CDP:
|
|
141
|
+
- `session.call(method, params?)` — CDP command on the page target
|
|
142
|
+
- `session.callOnTarget(method, params, sessionId)` — CDP on a specific session
|
|
143
|
+
- `session.callBrowser(method, params?)` — CDP on the browser target
|
|
144
|
+
- `session.takeDialog()` — get/dismiss pending dialog
|
|
134
145
|
- `require` — Node.js require() (use 'fs', 'path', 'crypto', etc.)
|
|
135
146
|
- `signal` — AbortSignal for cancellation
|
|
136
147
|
- `onUpdate` — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })
|
|
@@ -140,9 +151,9 @@ The script is written to disk, so it's auditable and re-runnable.
|
|
|
140
151
|
**Example — a script that extracts structured data:**
|
|
141
152
|
```javascript
|
|
142
153
|
// /tmp/extract-products.js
|
|
143
|
-
const info = await daemon.
|
|
144
|
-
if ("dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message);
|
|
145
|
-
const data = await daemon.
|
|
154
|
+
const info = await daemon.pageInfo();
|
|
155
|
+
if (info && "dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message);
|
|
156
|
+
const data = await daemon.evaluateJs(`
|
|
146
157
|
JSON.stringify(Array.from(document.querySelectorAll('${params.rowSelector}')).map(el => ({
|
|
147
158
|
title: el.querySelector('${params.titleSelector}')?.textContent?.trim(),
|
|
148
159
|
price: el.querySelector('${params.priceSelector}')?.textContent?.trim(),
|
|
@@ -196,6 +207,8 @@ browser_execute_js("!!document.querySelector('.loaded-content')")
|
|
|
196
207
|
- **Stale session**: Run `/browser-reload-daemon` to restart the daemon
|
|
197
208
|
- **Dialog blocking**: Run browser_page_info to detect it, then browser_handle_dialog
|
|
198
209
|
- **Status check**: Run `/browser-status` to see daemon health and current page
|
|
210
|
+
- **Screenshot maxDim not working**: Install sharp (`npm install sharp`) for auto-resize support
|
|
211
|
+
- **Google navigation fails**: Google's anti-bot detection may reject CDP navigation; use `browser_http_get` to fetch search results instead
|
|
199
212
|
|
|
200
213
|
## Architecture
|
|
201
214
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { connect as netConnect } from "node:net";
|
|
5
|
+
import { type Result, err, ok } from "../util/result";
|
|
6
|
+
import { type CdpError, cdpError } from "./errors";
|
|
7
|
+
|
|
8
|
+
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
9
|
+
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
10
|
+
|
|
11
|
+
const profileDirs = (): ReadonlyArray<string> => {
|
|
12
|
+
const home = homedir();
|
|
13
|
+
return [
|
|
14
|
+
join(home, "Library/Application Support/Google/Chrome"),
|
|
15
|
+
join(home, "Library/Application Support/Microsoft Edge"),
|
|
16
|
+
join(home, "Library/Application Support/Microsoft Edge Beta"),
|
|
17
|
+
join(home, "Library/Application Support/Microsoft Edge Dev"),
|
|
18
|
+
join(home, "Library/Application Support/Microsoft Edge Canary"),
|
|
19
|
+
join(home, ".config/google-chrome"),
|
|
20
|
+
join(home, ".config/chromium"),
|
|
21
|
+
join(home, ".config/chromium-browser"),
|
|
22
|
+
join(home, ".config/microsoft-edge"),
|
|
23
|
+
join(home, ".config/microsoft-edge-beta"),
|
|
24
|
+
join(home, ".config/microsoft-edge-dev"),
|
|
25
|
+
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
26
|
+
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
27
|
+
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
28
|
+
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
29
|
+
join(home, "AppData/Local/Google/Chrome/User Data"),
|
|
30
|
+
join(home, "AppData/Local/Chromium/User Data"),
|
|
31
|
+
join(home, "AppData/Local/Microsoft/Edge/User Data"),
|
|
32
|
+
join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
|
|
33
|
+
join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
|
|
34
|
+
join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
|
|
35
|
+
];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const probePort = (port: number): Promise<Result<void, CdpError>> =>
|
|
39
|
+
new Promise((resolve) => {
|
|
40
|
+
const sock = netConnect({ host: "127.0.0.1", port });
|
|
41
|
+
let settled = false;
|
|
42
|
+
const finish = (r: Result<void, CdpError>): void => {
|
|
43
|
+
if (settled) return;
|
|
44
|
+
settled = true;
|
|
45
|
+
sock.setTimeout(0);
|
|
46
|
+
sock.destroy();
|
|
47
|
+
resolve(r);
|
|
48
|
+
};
|
|
49
|
+
sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
|
|
50
|
+
sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
|
|
51
|
+
sock.once("connect", () => finish(ok(undefined)));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
55
|
+
const end = Date.now() + PORT_PROBE_DEADLINE_MS;
|
|
56
|
+
let lastMessage = "unknown";
|
|
57
|
+
while (Date.now() < end) {
|
|
58
|
+
const probe = await probePort(port);
|
|
59
|
+
if (probe.success) return probe;
|
|
60
|
+
lastMessage = probe.error.message;
|
|
61
|
+
await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
|
|
62
|
+
}
|
|
63
|
+
return err(cdpError(
|
|
64
|
+
"discovery_failed",
|
|
65
|
+
`Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
|
|
66
|
+
));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
70
|
+
const dirs = profileDirs();
|
|
71
|
+
for (const base of dirs) {
|
|
72
|
+
const portFile = join(base, "DevToolsActivePort");
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = await readFile(portFile, "utf8");
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Node fs errors carry .code; see ErrnoException
|
|
78
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
79
|
+
if (code === "ENOENT" || code === undefined) continue;
|
|
80
|
+
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
|
|
81
|
+
}
|
|
82
|
+
const lines = raw.trim().split("\n");
|
|
83
|
+
if (lines.length < 2) continue;
|
|
84
|
+
const port = lines[0]?.trim();
|
|
85
|
+
const path = lines[1]?.trim();
|
|
86
|
+
if (!port || !path) continue;
|
|
87
|
+
const ready = await waitForPort(Number(port));
|
|
88
|
+
if (!ready.success) return ready;
|
|
89
|
+
return ok(`ws://127.0.0.1:${port}${path}`);
|
|
90
|
+
}
|
|
91
|
+
return err(cdpError(
|
|
92
|
+
"discovery_failed",
|
|
93
|
+
`DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`,
|
|
94
|
+
));
|
|
95
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type CdpErrorKind =
|
|
2
|
+
| "transport_closed"
|
|
3
|
+
| "timeout"
|
|
4
|
+
| "session_not_found"
|
|
5
|
+
| "remote_error"
|
|
6
|
+
| "discovery_failed"
|
|
7
|
+
| "invalid_response";
|
|
8
|
+
|
|
9
|
+
export type CdpError = {
|
|
10
|
+
readonly kind: CdpErrorKind;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly method?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const cdpError = (
|
|
16
|
+
kind: CdpErrorKind,
|
|
17
|
+
message: string,
|
|
18
|
+
method?: string,
|
|
19
|
+
): CdpError => ({ kind, message, ...(method !== undefined ? { method } : {}) });
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { type Result, err, ok } from "../util/result";
|
|
2
|
+
import { type CdpError, cdpError } from "./errors";
|
|
3
|
+
import type { DialogInfo } from "./types";
|
|
4
|
+
import { isInternalUrl } from "./types";
|
|
5
|
+
import type { CdpTransport } from "./transport";
|
|
6
|
+
|
|
7
|
+
export type CdpSession = {
|
|
8
|
+
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
9
|
+
switchTo(targetId: string): Promise<Result<void, CdpError>>;
|
|
10
|
+
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
11
|
+
call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
12
|
+
callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
13
|
+
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
14
|
+
takeDialog(): DialogInfo | null;
|
|
15
|
+
drainPageInfoInvalidations(): boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const createCdpSession = (transport: CdpTransport): CdpSession => {
|
|
19
|
+
let sessionId: string | null = null;
|
|
20
|
+
let targetId: string | null = null;
|
|
21
|
+
let dialog: DialogInfo | null = null;
|
|
22
|
+
let pageInfoDirty = false;
|
|
23
|
+
|
|
24
|
+
let activeConsumer: Promise<void> = Promise.resolve();
|
|
25
|
+
|
|
26
|
+
const consumeEvents = async (): Promise<void> => {
|
|
27
|
+
for await (const ev of transport.events()) {
|
|
28
|
+
if (ev.method === "Page.javascriptDialogOpening") {
|
|
29
|
+
const params = ev.params as Partial<DialogInfo> | undefined;
|
|
30
|
+
dialog = {
|
|
31
|
+
type: (params?.type as DialogInfo["type"]) ?? "alert",
|
|
32
|
+
message: params?.message ?? "",
|
|
33
|
+
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
|
|
34
|
+
};
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Page.javascriptDialogClosed is intentionally NOT cleared here —
|
|
38
|
+
// the dialog stays in the buffer until takeDialog() is called.
|
|
39
|
+
// This prevents fast dismiss flows from dropping a dialog the agent
|
|
40
|
+
// was about to read. (Fix for spec §7 predictability bug #2.)
|
|
41
|
+
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
|
|
42
|
+
pageInfoDirty = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const restartConsumer = (): void => {
|
|
48
|
+
activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => {
|
|
49
|
+
// The .then() chain calls consumeEvents() which iterates the transport's
|
|
50
|
+
// events() AsyncIterable. The iterable resolves cleanly on close (returns
|
|
51
|
+
// {done:true}); any rejection here is an unexpected bug in the event
|
|
52
|
+
// handler, not a normal termination. Surface it on stderr so it's not lost.
|
|
53
|
+
console.warn("[pi-browser-harness] CDP event consumer crashed:", e);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
restartConsumer();
|
|
58
|
+
transport.onClose(() => {
|
|
59
|
+
sessionId = null;
|
|
60
|
+
targetId = null;
|
|
61
|
+
pageInfoDirty = false;
|
|
62
|
+
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
|
|
63
|
+
// the agent may have a pending takeDialog() call that should still see it.
|
|
64
|
+
restartConsumer();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// TODO(perf): the four enable calls are sequential here for predictability.
|
|
68
|
+
// Switching to Promise.all over a single WS pipelines the round-trips and
|
|
69
|
+
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
|
|
70
|
+
const enableDomains = async (sid: string): Promise<void> => {
|
|
71
|
+
for (const d of ["Page", "DOM", "Runtime", "Network"]) {
|
|
72
|
+
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// CDP response shapes are documented in chromedevtools.github.io but not
|
|
77
|
+
// available as TypeScript types. We cast `as` from `unknown` only in this
|
|
78
|
+
// file (the CDP boundary). Each cast is paired with the CDP method that
|
|
79
|
+
// produced the response. Adding runtime guards for every shape would be
|
|
80
|
+
// noise — Chrome's protocol is stable enough that a wrong cast surfaces
|
|
81
|
+
// as a clear Error in normal use.
|
|
82
|
+
return {
|
|
83
|
+
async attachFirstPage() {
|
|
84
|
+
const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
|
|
85
|
+
if (!targets.success) return targets;
|
|
86
|
+
const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
|
|
87
|
+
let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url));
|
|
88
|
+
if (pages.length === 0) {
|
|
89
|
+
const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null });
|
|
90
|
+
if (!created.success) return created;
|
|
91
|
+
const c = created.data as { targetId: string };
|
|
92
|
+
pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }];
|
|
93
|
+
}
|
|
94
|
+
const first = pages[0];
|
|
95
|
+
if (!first) return err(cdpError("invalid_response", "no page targets after creation"));
|
|
96
|
+
const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
|
|
97
|
+
if (!attached.success) return attached;
|
|
98
|
+
const a = attached.data as { sessionId: string };
|
|
99
|
+
sessionId = a.sessionId;
|
|
100
|
+
targetId = first.targetId;
|
|
101
|
+
await enableDomains(a.sessionId);
|
|
102
|
+
return ok({ targetId: first.targetId, sessionId: a.sessionId });
|
|
103
|
+
},
|
|
104
|
+
async switchTo(tid) {
|
|
105
|
+
const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
|
|
106
|
+
if (!activated.success) return activated;
|
|
107
|
+
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
|
|
108
|
+
if (!attached.success) return attached;
|
|
109
|
+
const a = attached.data as { sessionId: string };
|
|
110
|
+
sessionId = a.sessionId;
|
|
111
|
+
targetId = tid;
|
|
112
|
+
pageInfoDirty = true;
|
|
113
|
+
await enableDomains(a.sessionId);
|
|
114
|
+
return ok(undefined);
|
|
115
|
+
},
|
|
116
|
+
current() {
|
|
117
|
+
return sessionId && targetId ? { sessionId, targetId } : null;
|
|
118
|
+
},
|
|
119
|
+
call(method, params = {}, opts = {}) {
|
|
120
|
+
return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
121
|
+
},
|
|
122
|
+
callOnTarget(method, params, sid, opts = {}) {
|
|
123
|
+
return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
124
|
+
},
|
|
125
|
+
callBrowser(method, params = {}, opts = {}) {
|
|
126
|
+
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
127
|
+
},
|
|
128
|
+
takeDialog() {
|
|
129
|
+
const d = dialog;
|
|
130
|
+
dialog = null;
|
|
131
|
+
return d;
|
|
132
|
+
},
|
|
133
|
+
drainPageInfoInvalidations() {
|
|
134
|
+
const dirty = pageInfoDirty;
|
|
135
|
+
pageInfoDirty = false;
|
|
136
|
+
return dirty;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { type Result, err, ok } from "../util/result";
|
|
3
|
+
import { type CdpError, cdpError } from "./errors";
|
|
4
|
+
import { isCdpRawMessage } from "./types";
|
|
5
|
+
import type { CdpEvent, CdpRawMessage } from "./types";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
|
+
|
|
9
|
+
type Pending = {
|
|
10
|
+
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
11
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
12
|
+
readonly method: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CdpTransport = {
|
|
16
|
+
connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
request(
|
|
19
|
+
method: string,
|
|
20
|
+
params: Record<string, unknown>,
|
|
21
|
+
opts?: { sessionId?: string | null; timeoutMs?: number },
|
|
22
|
+
): Promise<Result<unknown, CdpError>>;
|
|
23
|
+
// Returns the current event queue's iterator. After close()+reconnect, callers
|
|
24
|
+
// must re-call events() — the old iterator will have received done:true.
|
|
25
|
+
events(): AsyncIterable<CdpEvent>;
|
|
26
|
+
state(): "open" | "closed" | "connecting";
|
|
27
|
+
onClose(cb: () => void): () => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const createCdpTransport = (): CdpTransport => {
|
|
31
|
+
let ws: WebSocket | null = null;
|
|
32
|
+
let nextId = 1;
|
|
33
|
+
const pending = new Map<number, Pending>();
|
|
34
|
+
const closeListeners = new Set<() => void>();
|
|
35
|
+
|
|
36
|
+
type EventQueue = {
|
|
37
|
+
readonly push: (e: CdpEvent) => void;
|
|
38
|
+
readonly end: () => void;
|
|
39
|
+
readonly iter: AsyncIterable<CdpEvent>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const makeEventQueue = (): EventQueue => {
|
|
43
|
+
const buf: CdpEvent[] = [];
|
|
44
|
+
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
45
|
+
let ended = false;
|
|
46
|
+
return {
|
|
47
|
+
push(e) {
|
|
48
|
+
if (ended) return;
|
|
49
|
+
const w = waiters.shift();
|
|
50
|
+
if (w) w({ value: e, done: false });
|
|
51
|
+
else buf.push(e);
|
|
52
|
+
},
|
|
53
|
+
end() {
|
|
54
|
+
ended = true;
|
|
55
|
+
// value: undefined as unknown as CdpEvent is the correct iterator-protocol
|
|
56
|
+
// pattern: when done=true the value field is conventionally undefined but
|
|
57
|
+
// the TS type still requires CdpEvent.
|
|
58
|
+
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
59
|
+
},
|
|
60
|
+
iter: {
|
|
61
|
+
[Symbol.asyncIterator]() {
|
|
62
|
+
return {
|
|
63
|
+
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
64
|
+
new Promise((resolve) => {
|
|
65
|
+
const next = buf.shift();
|
|
66
|
+
if (next) resolve({ value: next, done: false });
|
|
67
|
+
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
68
|
+
else waiters.push(resolve);
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let queue = makeEventQueue();
|
|
77
|
+
|
|
78
|
+
const handleMessage = (raw: string): void => {
|
|
79
|
+
let parsed: unknown;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!isCdpRawMessage(parsed)) return;
|
|
86
|
+
const msg: CdpRawMessage = parsed;
|
|
87
|
+
if (msg.id === undefined) {
|
|
88
|
+
if (msg.method) {
|
|
89
|
+
queue.push({
|
|
90
|
+
method: msg.method,
|
|
91
|
+
params: msg.params,
|
|
92
|
+
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const p = pending.get(msg.id);
|
|
98
|
+
if (!p) return;
|
|
99
|
+
pending.delete(msg.id);
|
|
100
|
+
clearTimeout(p.timer);
|
|
101
|
+
if (msg.error) {
|
|
102
|
+
const kind = msg.error.message.includes("Session with given id not found")
|
|
103
|
+
? "session_not_found"
|
|
104
|
+
: "remote_error";
|
|
105
|
+
p.resolve(err(cdpError(kind, msg.error.message, p.method)));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
p.resolve(ok(msg.result));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const cleanup = (reason: string): void => {
|
|
112
|
+
for (const [, p] of pending) {
|
|
113
|
+
clearTimeout(p.timer);
|
|
114
|
+
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
115
|
+
}
|
|
116
|
+
pending.clear();
|
|
117
|
+
queue.end();
|
|
118
|
+
queue = makeEventQueue();
|
|
119
|
+
for (const cb of closeListeners) cb();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
connect(url, opts = {}): Promise<Result<void, CdpError>> {
|
|
124
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let settled = false;
|
|
127
|
+
const settle = (r: Result<void, CdpError>): void => {
|
|
128
|
+
if (settled) return;
|
|
129
|
+
settled = true;
|
|
130
|
+
resolve(r);
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
ws = new WebSocket(url);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
ws?.close();
|
|
140
|
+
ws = null;
|
|
141
|
+
settle(err(cdpError("timeout", `CDP WebSocket connection timed out after ${timeoutMs}ms`)));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
ws.onopen = () => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
settle(ok(undefined));
|
|
146
|
+
};
|
|
147
|
+
ws.onmessage = (ev: WebSocket.MessageEvent) => {
|
|
148
|
+
const data = ev.data;
|
|
149
|
+
handleMessage(typeof data === "string" ? data : data.toString());
|
|
150
|
+
};
|
|
151
|
+
ws.onerror = () => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
settle(err(cdpError("transport_closed", "CDP WebSocket error during connection")));
|
|
154
|
+
};
|
|
155
|
+
ws.onclose = () => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
ws = null;
|
|
158
|
+
cleanup("WebSocket closed");
|
|
159
|
+
// If onopen already settled the connect promise, this settle is a no-op
|
|
160
|
+
// (guarded by `settled`). cleanup() above still runs unconditionally so
|
|
161
|
+
// pending requests are rejected and the event queue is rotated.
|
|
162
|
+
settle(err(cdpError("transport_closed", "CDP WebSocket closed")));
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
close(): Promise<void> {
|
|
167
|
+
if (ws) {
|
|
168
|
+
try { ws.close(1000, "Shutdown"); } catch { /* best effort */ }
|
|
169
|
+
ws = null;
|
|
170
|
+
}
|
|
171
|
+
cleanup("close() called");
|
|
172
|
+
return Promise.resolve();
|
|
173
|
+
},
|
|
174
|
+
request(method, params, opts = {}): Promise<Result<unknown, CdpError>> {
|
|
175
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
176
|
+
const sessionId = opts.sessionId ?? null;
|
|
177
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
178
|
+
return Promise.resolve(err(cdpError("transport_closed", "Browser not connected. Is Chrome running?", method)));
|
|
179
|
+
}
|
|
180
|
+
// Bind to a local const so the non-null is guaranteed even across microtasks
|
|
181
|
+
// between the readyState check and the send() call.
|
|
182
|
+
const sock = ws;
|
|
183
|
+
const id = nextId++;
|
|
184
|
+
const payload: Record<string, unknown> = { id, method, params };
|
|
185
|
+
if (sessionId) payload["sessionId"] = sessionId;
|
|
186
|
+
const json = JSON.stringify(payload);
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
pending.delete(id);
|
|
190
|
+
resolve(err(cdpError("timeout", `CDP timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
191
|
+
}, timeoutMs);
|
|
192
|
+
pending.set(id, { resolve, timer, method });
|
|
193
|
+
try {
|
|
194
|
+
sock.send(json);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
pending.delete(id);
|
|
198
|
+
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
events(): AsyncIterable<CdpEvent> {
|
|
203
|
+
// Single-consumer: each connection's event stream may be iterated by
|
|
204
|
+
// exactly one for-await loop. Calling events() multiple times returns
|
|
205
|
+
// the same iterable; the second consumer will silently steal events
|
|
206
|
+
// from the first. After a reconnect, the previous iterator is ended
|
|
207
|
+
// and callers must re-call events() to receive new events.
|
|
208
|
+
return queue.iter;
|
|
209
|
+
},
|
|
210
|
+
state(): "open" | "closed" | "connecting" {
|
|
211
|
+
if (!ws) return "closed";
|
|
212
|
+
if (ws.readyState === WebSocket.CONNECTING) return "connecting";
|
|
213
|
+
if (ws.readyState === WebSocket.OPEN) return "open";
|
|
214
|
+
return "closed";
|
|
215
|
+
},
|
|
216
|
+
onClose(cb): () => void {
|
|
217
|
+
closeListeners.add(cb);
|
|
218
|
+
return () => closeListeners.delete(cb);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
};
|