pi-browser-harness 0.5.0 → 0.8.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 +12 -1
- package/package.json +2 -1
- package/skills/pi-browser-harness/SKILL.md +17 -1
- package/src/cdp/daemon-transport.ts +211 -0
- package/src/cdp/event-queue.ts +128 -0
- package/src/cdp/session.ts +31 -0
- package/src/cdp/transport.ts +4 -69
- package/src/client.ts +16 -2
- package/src/daemon/bridge.ts +411 -0
- package/src/daemon/index.ts +114 -0
- package/src/daemon/protocol.ts +162 -0
- package/src/daemon/server.ts +245 -0
- package/src/daemon/spawn.ts +85 -0
- package/src/domains/click.ts +60 -24
- package/src/domains/files.ts +33 -4
- package/src/domains/form.ts +270 -0
- package/src/domains/keyboard.ts +61 -5
- package/src/domains/navigate.ts +17 -3
- package/src/domains/page.ts +45 -0
- package/src/domains/ref-resolve.ts +152 -0
- package/src/domains/setup.ts +41 -0
- package/src/domains/snapshot.ts +87 -11
- package/src/index.ts +46 -25
- package/src/prompt.ts +26 -7
- package/src/registry.ts +9 -1
- package/src/setup.ts +45 -39
- package/src/util/tool.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
5
|
+
## 0.6.0 — 2026-06-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Per-tab isolation for multi-agent safety.** Each agent's browser session now operates in a dedicated Chrome window with tab ownership tracking. Tabs opened via `browser_open_urls` and `browser_new_tab` are automatically registered in the ownership registry and tagged with a 🟢 prefix in the document title for user visibility. `browser_list_tabs` scoped to `"owned"` (default) only shows the current session's tabs.
|
|
10
|
+
- **Unix socket daemon with auto-reconnect.** The browser daemon now binds to a Unix-domain socket (`pi-browser.sock`) in the harness temp directory instead of a TCP port. The transport layer reconnects automatically when Chrome restarts — no manual `/browser-reload-daemon` needed.
|
|
11
|
+
- **On-demand browser initialization.** Chrome is no longer launched eagerly at harness startup. The harness attaches lazily on the first browser tool call, reducing resource usage when the agent isn't using the browser.
|
|
12
|
+
- **`browser_setup` as agent-callable tool.** The setup tool can now be called programmatically by agents, not just via slash command. Idempotent — safe to call when already connected.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Chrome detection on macOS** now matches by process-name substring instead of exact binary path comparison, fixing false negatives when Chrome is running from different installation paths (e.g. `/Applications/Google Chrome.app/` vs user-local copies).
|
|
6
17
|
|
|
7
18
|
### Added
|
|
8
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-browser-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@mariozechner/pi-coding-agent": "*",
|
|
53
53
|
"@mariozechner/pi-tui": "*",
|
|
54
54
|
"@types/ws": "^8.18.1",
|
|
55
|
+
"tsx": "^4.22.4",
|
|
55
56
|
"typebox": "*",
|
|
56
57
|
"typescript": "^5.0.0"
|
|
57
58
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pi-browser-harness
|
|
3
|
-
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user
|
|
3
|
+
description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome. Default to browser_snapshot for understanding pages and browser_execute_js for surgical reads — browser_screenshot is for visual verification only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pi-browser-harness
|
|
@@ -30,6 +30,22 @@ What do you need to know?
|
|
|
30
30
|
|
|
31
31
|
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
32
32
|
|
|
33
|
+
## Connection Setup
|
|
34
|
+
|
|
35
|
+
Browser control is **on-demand** — the daemon does NOT start automatically.
|
|
36
|
+
If you try a browser tool and get a `not_connected` error, tell the user to
|
|
37
|
+
run `/browser-setup` first. This opens the daemon and connects to Chrome.
|
|
38
|
+
Once initialized, all subsequent sessions reuse the same connection silently.
|
|
39
|
+
|
|
40
|
+
**Before calling any browser tool**, the runtime checks for the daemon socket
|
|
41
|
+
at `/tmp/pi-browser-daemon.sock`. If the socket is missing (user hasn't run
|
|
42
|
+
`/browser-setup`), you get: `"Browser harness not initialized. Run /browser-setup first"`.
|
|
43
|
+
|
|
44
|
+
**Do not ask the user.** Call `browser_setup` directly — it spawns the daemon,
|
|
45
|
+
connects to Chrome, and opens a test tab. The user sees a single "Allow Remote
|
|
46
|
+
Debugging" prompt the first time. After that, all sessions reuse the same connection.
|
|
47
|
+
`browser_setup` is idempotent — safe to call even when already connected.
|
|
48
|
+
|
|
33
49
|
## Connection
|
|
34
50
|
|
|
35
51
|
You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaemonTransport — CdpTransport backed by a Unix socket to the browser daemon.
|
|
3
|
+
*
|
|
4
|
+
* Satisfies the exact CdpTransport interface so BrowserClient, CdpSession, and
|
|
5
|
+
* all domain tools work without changes. Internally uses newline-delimited JSON
|
|
6
|
+
* over a Unix domain socket instead of a WebSocket to Chrome.
|
|
7
|
+
*
|
|
8
|
+
* References:
|
|
9
|
+
* - CdpTransport interface: src/cdp/transport.ts
|
|
10
|
+
* - net.createConnection IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateconnection
|
|
11
|
+
* - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createConnection, type Socket } from "node:net";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { type Result, err, ok } from "../util/result";
|
|
17
|
+
import { type CdpError, cdpError } from "../cdp/errors";
|
|
18
|
+
import type { CdpTransport } from "../cdp/transport";
|
|
19
|
+
import type { CdpEvent } from "../cdp/types";
|
|
20
|
+
import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
|
|
21
|
+
import {
|
|
22
|
+
DAEMON_SOCKET_PATH,
|
|
23
|
+
type WireRequest,
|
|
24
|
+
type WireControl,
|
|
25
|
+
deserialize,
|
|
26
|
+
serialize,
|
|
27
|
+
} from "../daemon/protocol";
|
|
28
|
+
|
|
29
|
+
// ── Implementation ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
32
|
+
let socket: Socket | null = null;
|
|
33
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
34
|
+
let queue = makeEventQueue();
|
|
35
|
+
const closeListeners = new Set<() => void>();
|
|
36
|
+
const pending = new Map<number, Pending>();
|
|
37
|
+
let registered = false;
|
|
38
|
+
let nextRequestId = 1;
|
|
39
|
+
|
|
40
|
+
const cleanup = (reason: string): void => {
|
|
41
|
+
rejectAllPending(pending, reason);
|
|
42
|
+
queue.end();
|
|
43
|
+
queue = makeEventQueue();
|
|
44
|
+
registered = false;
|
|
45
|
+
|
|
46
|
+
if (rl) { rl.close(); rl = null; }
|
|
47
|
+
if (socket) { try { socket.destroy(); } catch {} socket = null; }
|
|
48
|
+
|
|
49
|
+
for (const cb of closeListeners) cb();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── Connect ────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
|
|
55
|
+
// url is ignored — we always connect to the daemon socket
|
|
56
|
+
const timeoutMs = opts?.timeoutMs ?? 10_000;
|
|
57
|
+
|
|
58
|
+
if (socket && !socket.destroyed && registered) {
|
|
59
|
+
return Promise.resolve(ok(undefined));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
const settle = (r: Result<void, CdpError>): void => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
resolve(r);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
socket = createConnection(DAEMON_SOCKET_PATH);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const connectTimer = setTimeout(() => {
|
|
78
|
+
cleanup("Connection timeout");
|
|
79
|
+
settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
|
|
82
|
+
socket.on("connect", () => {
|
|
83
|
+
clearTimeout(connectTimer);
|
|
84
|
+
rl = createInterface({ input: socket!, crlfDelay: Infinity });
|
|
85
|
+
|
|
86
|
+
rl.on("line", (line: string) => {
|
|
87
|
+
const msg = deserialize(line);
|
|
88
|
+
if (!msg) return;
|
|
89
|
+
|
|
90
|
+
if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) {
|
|
91
|
+
registered = true;
|
|
92
|
+
settle(ok(undefined));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (msg.type === "control" && msg.action === "shutdown") {
|
|
97
|
+
cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!registered) return;
|
|
102
|
+
|
|
103
|
+
if (msg.type === "response") {
|
|
104
|
+
const p = pending.get(msg.id);
|
|
105
|
+
if (!p) return;
|
|
106
|
+
pending.delete(msg.id);
|
|
107
|
+
clearTimeout(p.timer);
|
|
108
|
+
|
|
109
|
+
if (msg.error) {
|
|
110
|
+
p.resolve(err(cdpError(
|
|
111
|
+
msg.error.message.includes("Session with given id not found")
|
|
112
|
+
? "session_not_found"
|
|
113
|
+
: "remote_error",
|
|
114
|
+
msg.error.message,
|
|
115
|
+
p.method,
|
|
116
|
+
)));
|
|
117
|
+
} else {
|
|
118
|
+
p.resolve(ok(msg.result));
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (msg.type === "event") {
|
|
124
|
+
queue.push({
|
|
125
|
+
method: msg.method,
|
|
126
|
+
params: msg.params,
|
|
127
|
+
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
socket!.on("error", () => {
|
|
134
|
+
// Errors are surfaced via 'close'
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
socket!.on("close", () => {
|
|
138
|
+
clearTimeout(connectTimer);
|
|
139
|
+
cleanup("Daemon socket closed");
|
|
140
|
+
// If connect hasn't settled yet, settle with error
|
|
141
|
+
settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Send registration
|
|
145
|
+
const regMsg: WireControl = { type: "control", action: "register", clientId };
|
|
146
|
+
socket!.write(serialize(regMsg) + "\n");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
socket.on("error", (e: NodeJS.ErrnoException) => {
|
|
150
|
+
clearTimeout(connectTimer);
|
|
151
|
+
const msg = e.code === "ENOENT"
|
|
152
|
+
? "Daemon not running — socket not found"
|
|
153
|
+
: e.message;
|
|
154
|
+
settle(err(cdpError("transport_closed", msg)));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ── Close ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const close = async (): Promise<void> => {
|
|
162
|
+
if (registered && socket && !socket.destroyed) {
|
|
163
|
+
const dereg: WireControl = { type: "control", action: "deregister", clientId };
|
|
164
|
+
try { socket.write(serialize(dereg) + "\n"); } catch {}
|
|
165
|
+
}
|
|
166
|
+
cleanup("close() called");
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// ── Request ────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const request = (
|
|
172
|
+
method: string,
|
|
173
|
+
params: Record<string, unknown>,
|
|
174
|
+
opts?: { sessionId?: string | null; timeoutMs?: number },
|
|
175
|
+
): Promise<Result<unknown, CdpError>> => {
|
|
176
|
+
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
|
177
|
+
const sessionId = opts?.sessionId ?? null;
|
|
178
|
+
|
|
179
|
+
if (!socket || socket.destroyed || !registered) {
|
|
180
|
+
return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const id = nextRequestId++;
|
|
184
|
+
const req: WireRequest = {
|
|
185
|
+
type: "request",
|
|
186
|
+
id,
|
|
187
|
+
method,
|
|
188
|
+
params,
|
|
189
|
+
...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => socket!.write(serialize(req) + "\n"));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ── Events ─────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const events = (): AsyncIterable<CdpEvent> => queue.iter;
|
|
198
|
+
|
|
199
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const state = (): "open" | "closed" | "connecting" => {
|
|
202
|
+
if (!socket) return "closed";
|
|
203
|
+
if (!registered) return "connecting";
|
|
204
|
+
if (socket.destroyed) return "closed";
|
|
205
|
+
return "open";
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const onClose = makeOnClose(closeListeners);
|
|
209
|
+
|
|
210
|
+
return { connect, close, request, events, state, onClose };
|
|
211
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transport primitives for CdpTransport implementations.
|
|
3
|
+
*
|
|
4
|
+
* Both the WebSocket transport (transport.ts, talking to Chrome directly) and
|
|
5
|
+
* the daemon transport (daemon-transport.ts, talking to the daemon over a Unix
|
|
6
|
+
* socket) need an identical single-consumer async event queue plus the same
|
|
7
|
+
* pending-request bookkeeping. This module is the single source of truth for
|
|
8
|
+
* that shared machinery.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type Result, err } from "../util/result";
|
|
12
|
+
import { type CdpError, cdpError } from "./errors";
|
|
13
|
+
import type { CdpEvent } from "./types";
|
|
14
|
+
|
|
15
|
+
export type CdpResult = Result<unknown, CdpError>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single-consumer async queue of CDP events.
|
|
19
|
+
*
|
|
20
|
+
* `push` enqueues an event (dropped silently after `end`). `end` terminates the
|
|
21
|
+
* stream — any pending and future iterator `next()` calls resolve `done:true`.
|
|
22
|
+
* `iter` is the AsyncIterable consumed by one for-await loop; a second consumer
|
|
23
|
+
* would silently steal events from the first.
|
|
24
|
+
*/
|
|
25
|
+
export type EventQueue = {
|
|
26
|
+
readonly push: (e: CdpEvent) => void;
|
|
27
|
+
readonly end: () => void;
|
|
28
|
+
readonly iter: AsyncIterable<CdpEvent>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const makeEventQueue = (): EventQueue => {
|
|
32
|
+
const buf: CdpEvent[] = [];
|
|
33
|
+
const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
|
|
34
|
+
let ended = false;
|
|
35
|
+
return {
|
|
36
|
+
push(e) {
|
|
37
|
+
if (ended) return;
|
|
38
|
+
const w = waiters.shift();
|
|
39
|
+
if (w) w({ value: e, done: false });
|
|
40
|
+
else buf.push(e);
|
|
41
|
+
},
|
|
42
|
+
end() {
|
|
43
|
+
ended = true;
|
|
44
|
+
// value: undefined as unknown as CdpEvent is the correct iterator-protocol
|
|
45
|
+
// pattern: when done=true the value field is conventionally undefined but
|
|
46
|
+
// the TS type still requires CdpEvent.
|
|
47
|
+
for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
|
|
48
|
+
},
|
|
49
|
+
iter: {
|
|
50
|
+
[Symbol.asyncIterator]() {
|
|
51
|
+
return {
|
|
52
|
+
next: (): Promise<IteratorResult<CdpEvent>> =>
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
const next = buf.shift();
|
|
55
|
+
if (next) resolve({ value: next, done: false });
|
|
56
|
+
else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
|
|
57
|
+
else waiters.push(resolve);
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** An in-flight CDP request awaiting its response or timeout. */
|
|
66
|
+
export type Pending = {
|
|
67
|
+
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
68
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
69
|
+
readonly method: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reject every in-flight request with a transport_closed error and clear the
|
|
74
|
+
* map. Called from each transport's cleanup path when the underlying connection
|
|
75
|
+
* goes away.
|
|
76
|
+
*/
|
|
77
|
+
export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
|
|
78
|
+
for (const [, p] of pending) {
|
|
79
|
+
clearTimeout(p.timer);
|
|
80
|
+
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
81
|
+
}
|
|
82
|
+
pending.clear();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send a CDP request and return a promise that settles when the matching
|
|
87
|
+
* response arrives, the request times out, or the send throws.
|
|
88
|
+
*
|
|
89
|
+
* The transport supplies a `send` callback that puts the already-serialized
|
|
90
|
+
* payload onto its wire (WebSocket frame or Unix-socket line); everything else
|
|
91
|
+
* — registering the pending entry, the timeout timer, and synchronous send
|
|
92
|
+
* failure handling — is identical across transports and lives here.
|
|
93
|
+
*
|
|
94
|
+
* `timeoutLabel` is the transport's prefix for the timeout error message
|
|
95
|
+
* (e.g. "CDP" or "Daemon").
|
|
96
|
+
*/
|
|
97
|
+
export const sendWithTimeout = (
|
|
98
|
+
pending: Map<number, Pending>,
|
|
99
|
+
id: number,
|
|
100
|
+
method: string,
|
|
101
|
+
timeoutMs: number,
|
|
102
|
+
timeoutLabel: string,
|
|
103
|
+
send: () => void,
|
|
104
|
+
): Promise<CdpResult> =>
|
|
105
|
+
new Promise((resolve) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
pending.delete(id);
|
|
108
|
+
resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
pending.set(id, { resolve, timer, method });
|
|
111
|
+
try {
|
|
112
|
+
send();
|
|
113
|
+
} catch (e) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
pending.delete(id);
|
|
116
|
+
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Register a close listener, returning an unsubscribe function. Identical
|
|
122
|
+
* across transports, so it lives here.
|
|
123
|
+
*/
|
|
124
|
+
export const makeOnClose = (closeListeners: Set<() => void>) =>
|
|
125
|
+
(cb: () => void): (() => void) => {
|
|
126
|
+
closeListeners.add(cb);
|
|
127
|
+
return () => closeListeners.delete(cb);
|
|
128
|
+
};
|
package/src/cdp/session.ts
CHANGED
|
@@ -17,12 +17,25 @@ type TabSession = {
|
|
|
17
17
|
pageInfoDirty: boolean;
|
|
18
18
|
networkBuffer: ReturnType<typeof createNetworkBuffer>;
|
|
19
19
|
consoleBuffer: ReturnType<typeof createConsoleBuffer>;
|
|
20
|
+
// Stable element refs (e1, e2, …) → CDP backendNodeId, from the latest
|
|
21
|
+
// snapshot of this tab. Replaced wholesale on every snapshot — old refs are
|
|
22
|
+
// stale anyway, so this stays bounded (~50–60 KB worst case).
|
|
23
|
+
refMap: Map<string, number>;
|
|
24
|
+
// Per-ref signature ("role|name|value") from the latest snapshot, used as the
|
|
25
|
+
// baseline for the post-mutation auto-diff. Replaced alongside refMap.
|
|
26
|
+
refSig: Map<string, string>;
|
|
20
27
|
};
|
|
21
28
|
|
|
22
29
|
export type CdpSession = {
|
|
23
30
|
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
24
31
|
switchTo(targetId: string): Promise<Result<void, CdpError>>;
|
|
25
32
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
33
|
+
/** Replace the active tab's ref map and ref signatures (from a fresh snapshot). */
|
|
34
|
+
setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
|
|
35
|
+
/** Resolve a ref (e.g. "e12") to its backendNodeId on the active tab, or undefined if unknown/stale. */
|
|
36
|
+
resolveRef(ref: string): number | undefined;
|
|
37
|
+
/** The active tab's prior ref signatures — baseline for the post-mutation diff. */
|
|
38
|
+
refSignatures(): ReadonlyMap<string, string>;
|
|
26
39
|
call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
27
40
|
callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
28
41
|
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
|
|
@@ -211,6 +224,8 @@ export const createCdpSession = (
|
|
|
211
224
|
pageInfoDirty: false,
|
|
212
225
|
networkBuffer: createNetworkBuffer(),
|
|
213
226
|
consoleBuffer: createConsoleBuffer(),
|
|
227
|
+
refMap: new Map(),
|
|
228
|
+
refSig: new Map(),
|
|
214
229
|
});
|
|
215
230
|
sessionIdToTargetId.set(a.sessionId, pickTargetId);
|
|
216
231
|
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
|
|
@@ -230,6 +245,8 @@ export const createCdpSession = (
|
|
|
230
245
|
pageInfoDirty: true,
|
|
231
246
|
networkBuffer: createNetworkBuffer(),
|
|
232
247
|
consoleBuffer: createConsoleBuffer(),
|
|
248
|
+
refMap: new Map(),
|
|
249
|
+
refSig: new Map(),
|
|
233
250
|
};
|
|
234
251
|
if (existing) {
|
|
235
252
|
// Each Target.attachToTarget produces a new sessionId — update it.
|
|
@@ -249,6 +266,20 @@ export const createCdpSession = (
|
|
|
249
266
|
current() {
|
|
250
267
|
return sessionId && targetId ? { sessionId, targetId } : null;
|
|
251
268
|
},
|
|
269
|
+
setRefMap(refMap, refSig) {
|
|
270
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
271
|
+
if (!tab) return;
|
|
272
|
+
tab.refMap = refMap;
|
|
273
|
+
tab.refSig = refSig;
|
|
274
|
+
},
|
|
275
|
+
resolveRef(ref) {
|
|
276
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
277
|
+
return tab?.refMap.get(ref);
|
|
278
|
+
},
|
|
279
|
+
refSignatures() {
|
|
280
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
281
|
+
return tab?.refSig ?? new Map();
|
|
282
|
+
},
|
|
252
283
|
call(method, params = {}, opts = {}) {
|
|
253
284
|
return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
254
285
|
},
|
package/src/cdp/transport.ts
CHANGED
|
@@ -3,15 +3,10 @@ import { type Result, err, ok } from "../util/result";
|
|
|
3
3
|
import { type CdpError, cdpError } from "./errors";
|
|
4
4
|
import { isCdpRawMessage } from "./types";
|
|
5
5
|
import type { CdpEvent, CdpRawMessage } from "./types";
|
|
6
|
+
import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
8
9
|
|
|
9
|
-
type Pending = {
|
|
10
|
-
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
11
|
-
readonly timer: ReturnType<typeof setTimeout>;
|
|
12
|
-
readonly method: string;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
10
|
export type CdpTransport = {
|
|
16
11
|
connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
|
|
17
12
|
close(): Promise<void>;
|
|
@@ -33,46 +28,6 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
33
28
|
const pending = new Map<number, Pending>();
|
|
34
29
|
const closeListeners = new Set<() => void>();
|
|
35
30
|
|
|
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
31
|
let queue = makeEventQueue();
|
|
77
32
|
|
|
78
33
|
const handleMessage = (raw: string): void => {
|
|
@@ -109,11 +64,7 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
109
64
|
};
|
|
110
65
|
|
|
111
66
|
const cleanup = (reason: string): void => {
|
|
112
|
-
|
|
113
|
-
clearTimeout(p.timer);
|
|
114
|
-
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
115
|
-
}
|
|
116
|
-
pending.clear();
|
|
67
|
+
rejectAllPending(pending, reason);
|
|
117
68
|
queue.end();
|
|
118
69
|
queue = makeEventQueue();
|
|
119
70
|
for (const cb of closeListeners) cb();
|
|
@@ -184,20 +135,7 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
184
135
|
const payload: Record<string, unknown> = { id, method, params };
|
|
185
136
|
if (sessionId) payload["sessionId"] = sessionId;
|
|
186
137
|
const json = JSON.stringify(payload);
|
|
187
|
-
return
|
|
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
|
-
});
|
|
138
|
+
return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json));
|
|
201
139
|
},
|
|
202
140
|
events(): AsyncIterable<CdpEvent> {
|
|
203
141
|
// Single-consumer: each connection's event stream may be iterated by
|
|
@@ -213,9 +151,6 @@ export const createCdpTransport = (): CdpTransport => {
|
|
|
213
151
|
if (ws.readyState === WebSocket.OPEN) return "open";
|
|
214
152
|
return "closed";
|
|
215
153
|
},
|
|
216
|
-
onClose
|
|
217
|
-
closeListeners.add(cb);
|
|
218
|
-
return () => closeListeners.delete(cb);
|
|
219
|
-
},
|
|
154
|
+
onClose: makeOnClose(closeListeners),
|
|
220
155
|
};
|
|
221
156
|
};
|
package/src/client.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
|
11
11
|
|
|
12
12
|
export type BrowserClientOptions = {
|
|
13
13
|
readonly namespace: string;
|
|
14
|
+
readonly transport?: CdpTransport;
|
|
14
15
|
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
|
|
15
16
|
readonly initialOwnership?: {
|
|
16
17
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
@@ -28,6 +29,9 @@ export type BrowserClient = {
|
|
|
28
29
|
status(): DaemonStatus;
|
|
29
30
|
start(): Promise<Result<void, CdpError>>;
|
|
30
31
|
stop(): Promise<void>;
|
|
32
|
+
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
|
|
33
|
+
* while keeping the transport connection alive. Call on session shutdown. */
|
|
34
|
+
detach(): Promise<void>;
|
|
31
35
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
32
36
|
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
|
|
33
37
|
takeDialog(): DialogInfo | null;
|
|
@@ -77,7 +81,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
77
81
|
};
|
|
78
82
|
|
|
79
83
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
80
|
-
const transport = createCdpTransport();
|
|
84
|
+
const transport = opts.transport ?? createCdpTransport();
|
|
81
85
|
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
82
86
|
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
83
87
|
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
@@ -298,9 +302,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
298
302
|
...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}),
|
|
299
303
|
});
|
|
300
304
|
|
|
305
|
+
const detach = async (): Promise<void> => {
|
|
306
|
+
const cur = session.current();
|
|
307
|
+
if (!cur) return;
|
|
308
|
+
// Target.detachFromTarget removes the "Chrome is being controlled" banner
|
|
309
|
+
// and releases the page session. The transport (WebSocket/Unix socket)
|
|
310
|
+
// stays alive for reuse by the next session.
|
|
311
|
+
// Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
|
|
312
|
+
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
313
|
+
};
|
|
314
|
+
|
|
301
315
|
return {
|
|
302
316
|
namespace: opts.namespace,
|
|
303
|
-
ensureAlive, status, start, stop,
|
|
317
|
+
ensureAlive, status, start, stop, detach,
|
|
304
318
|
evaluateJs, pageInfo,
|
|
305
319
|
takeDialog: () => session.takeDialog(),
|
|
306
320
|
listTabs, switchTab, newTab, closeTab,
|