pi-browser-harness 0.10.2 → 0.11.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 +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/cdp/discovery.ts
CHANGED
|
@@ -2,20 +2,24 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { connect as netConnect } from "node:net";
|
|
4
4
|
import { request as httpRequest } from "node:http";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { Compile } from "typebox/compile";
|
|
7
|
+
import { parseJson } from "../schemas/parse";
|
|
5
8
|
import { type Result, err, ok } from "../util/result";
|
|
6
9
|
import { userDataDirCandidates } from "../profile/paths";
|
|
7
10
|
import { type CdpError, cdpError } from "./errors";
|
|
11
|
+
import { errnoCode } from "../util/guards";
|
|
8
12
|
|
|
9
13
|
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
10
14
|
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const VersionResponse = Type.Object(
|
|
17
|
+
{ webSocketDebuggerUrl: Type.Optional(Type.String()) },
|
|
18
|
+
{ additionalProperties: true },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const versionValidator = Compile(VersionResponse);
|
|
22
|
+
|
|
19
23
|
export type CdpEndpoint = {
|
|
20
24
|
readonly wsUrl: string;
|
|
21
25
|
readonly userDataDir?: string;
|
|
@@ -52,17 +56,9 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
|
52
56
|
));
|
|
53
57
|
};
|
|
54
58
|
|
|
55
|
-
// Fast, single-shot TCP check (no retry loop). Used to skip stale
|
|
56
|
-
// DevToolsActivePort files left behind by a browser that has since quit —
|
|
57
|
-
// unlike waitForPort, this returns immediately instead of blocking ~30s.
|
|
58
59
|
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
|
|
59
60
|
|
|
60
|
-
//
|
|
61
|
-
// http://127.0.0.1:<port>/json/version. This is authoritative: it avoids
|
|
62
|
-
// trusting a path from a stale DevToolsActivePort file that may belong to a
|
|
63
|
-
// different browser which has since exited (e.g. Chrome's stale file pointing
|
|
64
|
-
// at 9222 while Brave actually owns the port now). Returns null if the endpoint
|
|
65
|
-
// is unreachable, non-200, or doesn't expose a WS URL.
|
|
61
|
+
// /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port.
|
|
66
62
|
const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
|
|
67
63
|
new Promise<string | null>((resolve) => {
|
|
68
64
|
const req = httpRequest(
|
|
@@ -76,13 +72,8 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
|
|
|
76
72
|
const chunks: Buffer[] = [];
|
|
77
73
|
res.on("data", (c: Buffer) => chunks.push(c));
|
|
78
74
|
res.on("end", () => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const ws = json["webSocketDebuggerUrl"];
|
|
82
|
-
resolve(typeof ws === "string" ? ws : null);
|
|
83
|
-
} catch {
|
|
84
|
-
resolve(null);
|
|
85
|
-
}
|
|
75
|
+
const parsed = parseJson(Buffer.concat(chunks).toString("utf8"), versionValidator);
|
|
76
|
+
resolve(parsed.success ? parsed.data.webSocketDebuggerUrl ?? null : null);
|
|
86
77
|
});
|
|
87
78
|
},
|
|
88
79
|
);
|
|
@@ -91,10 +82,6 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
|
|
|
91
82
|
req.end();
|
|
92
83
|
});
|
|
93
84
|
|
|
94
|
-
// Well-known CDP ports to probe when no DevToolsActivePort file is readable
|
|
95
|
-
// (sandboxed harness, restricted filesystem permissions, or an unusual profile
|
|
96
|
-
// location not on the profileDirs list). The default Chromium remote-debugging
|
|
97
|
-
// port is 9222; users can extend via BU_CDP_PORTS="9222,9333".
|
|
98
85
|
const fallbackPorts = (): ReadonlyArray<number> => {
|
|
99
86
|
const ports = new Set<number>([9222]);
|
|
100
87
|
const fromEnv = process.env["BU_CDP_PORTS"];
|
|
@@ -111,21 +98,21 @@ type Candidate = {
|
|
|
111
98
|
readonly port: number;
|
|
112
99
|
readonly path: string;
|
|
113
100
|
readonly mtimeMs: number;
|
|
114
|
-
/** The user-data-dir whose DevToolsActivePort produced this candidate. */
|
|
115
101
|
readonly userDataDir: string;
|
|
116
102
|
};
|
|
117
103
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
104
|
+
// Read here rather than in the client: the daemon owns the only socket to Chrome, so an override the daemon cannot see has no effect.
|
|
105
|
+
export const endpointFromEnv = (env: NodeJS.ProcessEnv = process.env): CdpEndpoint | undefined => {
|
|
106
|
+
const wsUrl = env["BU_CDP_WS"]?.trim();
|
|
107
|
+
return wsUrl ? { wsUrl } : undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
122
110
|
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
|
|
111
|
+
const override = endpointFromEnv();
|
|
112
|
+
if (override) return ok(override);
|
|
113
|
+
|
|
123
114
|
const dirs = userDataDirCandidates();
|
|
124
115
|
|
|
125
|
-
// Phase 1: collect every readable DevToolsActivePort candidate up front,
|
|
126
|
-
// rather than committing to the first one found. A single browser writes one
|
|
127
|
-
// such file, but stale files from quit browsers linger, so we must
|
|
128
|
-
// disambiguate below instead of trusting discovery order.
|
|
129
116
|
const candidates: Candidate[] = [];
|
|
130
117
|
const readErrors: string[] = [];
|
|
131
118
|
for (const base of dirs) {
|
|
@@ -136,15 +123,10 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
136
123
|
raw = await readFile(portFile, "utf8");
|
|
137
124
|
try {
|
|
138
125
|
mtimeMs = (await stat(portFile)).mtimeMs;
|
|
139
|
-
} catch {
|
|
140
|
-
// mtime is only a tiebreaker; a stat failure just leaves it at 0.
|
|
141
|
-
}
|
|
126
|
+
} catch {}
|
|
142
127
|
} catch (e) {
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
// EACCES is common under sandboxes; remember it and fall back to network
|
|
146
|
-
// probing rather than failing the whole discovery.
|
|
147
|
-
const code = (e as NodeJS.ErrnoException).code;
|
|
128
|
+
// EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
|
|
129
|
+
const code = errnoCode(e);
|
|
148
130
|
if (code === "ENOENT" || code === undefined) continue;
|
|
149
131
|
if (code === "EPERM" || code === "EACCES") {
|
|
150
132
|
readErrors.push(`${portFile}: ${code}`);
|
|
@@ -157,25 +139,16 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
157
139
|
const port = lines[0]?.trim();
|
|
158
140
|
const path = lines[1]?.trim();
|
|
159
141
|
if (!port || !path) continue;
|
|
160
|
-
//
|
|
161
|
-
// line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT
|
|
162
|
-
// synchronously, which would escape discoverWsUrl instead of surfacing as
|
|
163
|
-
// a Result error. Skip such entries.
|
|
142
|
+
// An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error.
|
|
164
143
|
const portNum = Number(port);
|
|
165
144
|
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
|
|
166
145
|
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
|
|
167
146
|
}
|
|
168
147
|
|
|
169
|
-
// Phase 2: no readable candidate — probe well-known ports directly. Covers
|
|
170
|
-
// sandboxed harnesses that can't read profile dirs and non-default install
|
|
171
|
-
// locations. Requires the browser to have been launched with
|
|
172
|
-
// --remote-debugging-port so /json/version is served.
|
|
173
148
|
if (candidates.length === 0) {
|
|
174
149
|
for (const port of fallbackPorts()) {
|
|
175
150
|
if (!(await isPortLive(port))) continue;
|
|
176
151
|
const live = await queryLiveWsUrl(port);
|
|
177
|
-
// No DevToolsActivePort file was involved, so the user-data-dir behind
|
|
178
|
-
// this endpoint is unknown — profile selection stays unavailable.
|
|
179
152
|
if (live) return ok({ wsUrl: live });
|
|
180
153
|
}
|
|
181
154
|
const permHint = readErrors.length > 0
|
|
@@ -187,9 +160,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
187
160
|
));
|
|
188
161
|
}
|
|
189
162
|
|
|
190
|
-
// Phase 3: disambiguate candidates sharing a port (one browser running, and
|
|
191
|
-
// another's file left behind on the default 9222): keep the most-recently-
|
|
192
|
-
// written file — that's the currently-running browser.
|
|
193
163
|
const byPort = new Map<number, Candidate>();
|
|
194
164
|
for (const c of candidates) {
|
|
195
165
|
const prev = byPort.get(c.port);
|
|
@@ -197,16 +167,11 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
197
167
|
}
|
|
198
168
|
const unique = Array.from(byPort.values());
|
|
199
169
|
|
|
200
|
-
// Prefer candidates whose port is actually accepting connections; this skips
|
|
201
|
-
// stale files whose browser has quit (avoiding a ~30s waitForPort block).
|
|
202
170
|
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
|
|
203
171
|
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
|
|
204
172
|
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
|
|
205
173
|
|
|
206
|
-
//
|
|
207
|
-
// /json/version. Some browsers disable that HTTP endpoint when remote
|
|
208
|
-
// debugging is toggled only via chrome://inspect (rather than a launch flag),
|
|
209
|
-
// so fall back to the WS path written in DevToolsActivePort.
|
|
174
|
+
// Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
|
|
210
175
|
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
|
|
211
176
|
for (const c of ordered) {
|
|
212
177
|
const ready = await waitForPort(c.port);
|
|
@@ -218,10 +183,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
218
183
|
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
|
|
219
184
|
}
|
|
220
185
|
|
|
221
|
-
// Every discovered candidate was stale/unreachable. Before giving up, try the
|
|
222
|
-
// well-known fallback ports — a user with a leftover DevToolsActivePort file
|
|
223
|
-
// may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports
|
|
224
|
-
// already covered by a candidate above so we don't re-probe them.)
|
|
225
186
|
for (const port of fallbackPorts()) {
|
|
226
187
|
if (byPort.has(port) || !(await isPortLive(port))) continue;
|
|
227
188
|
const liveUrl = await queryLiveWsUrl(port);
|
|
@@ -231,10 +192,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
231
192
|
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
|
|
232
193
|
};
|
|
233
194
|
|
|
234
|
-
/**
|
|
235
|
-
* URL-only view of {@link discoverEndpoint}, kept as the stable entry point for
|
|
236
|
-
* callers that don't care which user-data-dir answered.
|
|
237
|
-
*/
|
|
238
195
|
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
239
196
|
const endpoint = await discoverEndpoint();
|
|
240
197
|
return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
|
package/src/cdp/errors.ts
CHANGED
|
@@ -12,6 +12,10 @@ export type CdpError = {
|
|
|
12
12
|
readonly method?: string;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// Chrome reports a dead session as a generic protocol error, so the message text is the only signal that a reattach — not a retry — is what will fix it.
|
|
16
|
+
export const classifyRemoteError = (message: string): CdpErrorKind =>
|
|
17
|
+
message.includes("Session with given id not found") ? "session_not_found" : "remote_error";
|
|
18
|
+
|
|
15
19
|
export const cdpError = (
|
|
16
20
|
kind: CdpErrorKind,
|
|
17
21
|
message: string,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { type CdpError, cdpError } from "./errors";
|
|
5
|
+
|
|
6
|
+
const ev = <P extends TSchema>(params: P) => ({ params, validate: Compile(params) });
|
|
7
|
+
|
|
8
|
+
const open = { additionalProperties: true } as const;
|
|
9
|
+
|
|
10
|
+
const Anything = Type.Object({}, open);
|
|
11
|
+
|
|
12
|
+
const StackTrace = Type.Object(
|
|
13
|
+
{
|
|
14
|
+
callFrames: Type.Optional(
|
|
15
|
+
Type.Array(
|
|
16
|
+
Type.Object(
|
|
17
|
+
{
|
|
18
|
+
url: Type.Optional(Type.String()),
|
|
19
|
+
lineNumber: Type.Optional(Type.Number()),
|
|
20
|
+
functionName: Type.Optional(Type.String()),
|
|
21
|
+
},
|
|
22
|
+
open,
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
open,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const ConsoleArg = Type.Object(
|
|
31
|
+
{
|
|
32
|
+
type: Type.Optional(Type.String()),
|
|
33
|
+
value: Type.Optional(Type.Unknown()),
|
|
34
|
+
description: Type.Optional(Type.String()),
|
|
35
|
+
unserializableValue: Type.Optional(Type.String()),
|
|
36
|
+
},
|
|
37
|
+
open,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const EVENTS = {
|
|
41
|
+
"Inspector.detached": ev(Anything),
|
|
42
|
+
|
|
43
|
+
"Page.javascriptDialogOpening": ev(
|
|
44
|
+
Type.Object(
|
|
45
|
+
{
|
|
46
|
+
type: Type.String(),
|
|
47
|
+
message: Type.String(),
|
|
48
|
+
defaultPrompt: Type.Optional(Type.String()),
|
|
49
|
+
},
|
|
50
|
+
open,
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
"Page.loadEventFired": ev(Anything),
|
|
54
|
+
"Page.frameNavigated": ev(Anything),
|
|
55
|
+
|
|
56
|
+
"Runtime.consoleAPICalled": ev(
|
|
57
|
+
Type.Object(
|
|
58
|
+
{
|
|
59
|
+
type: Type.Optional(Type.String()),
|
|
60
|
+
args: Type.Optional(Type.Array(ConsoleArg)),
|
|
61
|
+
stackTrace: Type.Optional(StackTrace),
|
|
62
|
+
},
|
|
63
|
+
open,
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
|
|
67
|
+
"Log.entryAdded": ev(
|
|
68
|
+
Type.Object(
|
|
69
|
+
{
|
|
70
|
+
entry: Type.Object(
|
|
71
|
+
{
|
|
72
|
+
level: Type.Optional(Type.String()),
|
|
73
|
+
text: Type.Optional(Type.String()),
|
|
74
|
+
url: Type.Optional(Type.String()),
|
|
75
|
+
lineNumber: Type.Optional(Type.Number()),
|
|
76
|
+
stackTrace: Type.Optional(StackTrace),
|
|
77
|
+
},
|
|
78
|
+
open,
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
open,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
|
|
85
|
+
"Network.requestWillBeSent": ev(
|
|
86
|
+
Type.Object(
|
|
87
|
+
{
|
|
88
|
+
requestId: Type.String(),
|
|
89
|
+
request: Type.Object(
|
|
90
|
+
{
|
|
91
|
+
url: Type.String(),
|
|
92
|
+
method: Type.String(),
|
|
93
|
+
postData: Type.Optional(Type.String()),
|
|
94
|
+
},
|
|
95
|
+
open,
|
|
96
|
+
),
|
|
97
|
+
type: Type.Optional(Type.String()),
|
|
98
|
+
},
|
|
99
|
+
open,
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
"Network.responseReceived": ev(
|
|
103
|
+
Type.Object(
|
|
104
|
+
{
|
|
105
|
+
requestId: Type.String(),
|
|
106
|
+
response: Type.Optional(
|
|
107
|
+
Type.Object(
|
|
108
|
+
{
|
|
109
|
+
status: Type.Optional(Type.Number()),
|
|
110
|
+
statusText: Type.Optional(Type.String()),
|
|
111
|
+
mimeType: Type.Optional(Type.String()),
|
|
112
|
+
},
|
|
113
|
+
open,
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
type: Type.Optional(Type.String()),
|
|
117
|
+
},
|
|
118
|
+
open,
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
"Network.loadingFinished": ev(
|
|
122
|
+
Type.Object(
|
|
123
|
+
{ requestId: Type.String(), encodedDataLength: Type.Optional(Type.Number()) },
|
|
124
|
+
open,
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
"Network.loadingFailed": ev(
|
|
128
|
+
Type.Object(
|
|
129
|
+
{ requestId: Type.String(), errorText: Type.Optional(Type.String()) },
|
|
130
|
+
open,
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
|
|
134
|
+
"Target.targetCreated": ev(
|
|
135
|
+
Type.Object(
|
|
136
|
+
{
|
|
137
|
+
targetInfo: Type.Object(
|
|
138
|
+
{
|
|
139
|
+
targetId: Type.String(),
|
|
140
|
+
type: Type.String(),
|
|
141
|
+
openerId: Type.Optional(Type.String()),
|
|
142
|
+
},
|
|
143
|
+
open,
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
open,
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
"Target.targetDestroyed": ev(Type.Object({ targetId: Type.String() }, open)),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type CdpEventName = keyof typeof EVENTS;
|
|
153
|
+
export type EventParamsOf<E extends CdpEventName> = Static<(typeof EVENTS)[E]["params"]>;
|
|
154
|
+
|
|
155
|
+
export const isKnownEvent = (name: string): name is CdpEventName => Object.hasOwn(EVENTS, name);
|
|
156
|
+
|
|
157
|
+
export const decodeEvent = <E extends CdpEventName>(
|
|
158
|
+
name: E,
|
|
159
|
+
raw: unknown,
|
|
160
|
+
): Result<EventParamsOf<E>, CdpError> => {
|
|
161
|
+
const spec = EVENTS[name];
|
|
162
|
+
if (spec === undefined) {
|
|
163
|
+
return err(cdpError("invalid_response", "unknown CDP event", name));
|
|
164
|
+
}
|
|
165
|
+
if (!spec.validate.Check(raw)) {
|
|
166
|
+
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
167
|
+
return err(cdpError("invalid_response", why, name));
|
|
168
|
+
}
|
|
169
|
+
return ok(raw as EventParamsOf<E>);
|
|
170
|
+
};
|
|
@@ -1,18 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* The CDP event stream is consumed by a single internal loop in session.ts.
|
|
5
|
-
* That loop hands Network.* event params to this module, which joins them into
|
|
6
|
-
* one record per requestId (request → response → finished/failed). Tools then
|
|
7
|
-
* read records via drain(), filtered to what the caller asked for.
|
|
8
|
-
*
|
|
9
|
-
* Bounded by a fixed capacity (default 500): on overflow the oldest record is
|
|
10
|
-
* evicted FIFO. The overflow flag is reported once per drain so tools can warn
|
|
11
|
-
* the LLM that data may be missing.
|
|
12
|
-
*
|
|
13
|
-
* This module is pure (no CDP dependency) so the joining/filtering logic stays
|
|
14
|
-
* trivially testable in isolation.
|
|
15
|
-
*/
|
|
1
|
+
import { decodeEvent, type EventParamsOf } from "./events";
|
|
2
|
+
import { createRecordStore } from "./record-store";
|
|
16
3
|
|
|
17
4
|
export type NetworkRecord = {
|
|
18
5
|
requestId: string;
|
|
@@ -22,34 +9,27 @@ export type NetworkRecord = {
|
|
|
22
9
|
statusText?: string;
|
|
23
10
|
type: string;
|
|
24
11
|
mimeType?: string;
|
|
25
|
-
/** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */
|
|
26
12
|
requestStartedMs: number;
|
|
27
13
|
durationMs?: number;
|
|
28
14
|
requestBodySize: number;
|
|
29
15
|
responseBodySize?: number;
|
|
30
16
|
failed?: boolean;
|
|
31
17
|
errorText?: string;
|
|
32
|
-
/** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */
|
|
33
18
|
body?: string | null;
|
|
34
19
|
};
|
|
35
20
|
|
|
36
21
|
export type NetworkFilter = {
|
|
37
|
-
/** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
|
|
38
22
|
urlPattern?: string;
|
|
39
23
|
methodFilter?: string[];
|
|
40
24
|
statusFilter?: { min?: number; max?: number };
|
|
41
25
|
resourceTypes?: string[];
|
|
42
|
-
/** Only requests whose start is at most N ms ago. */
|
|
43
26
|
sinceMs?: number;
|
|
44
|
-
/** Cap on the returned records. */
|
|
45
27
|
limit?: number;
|
|
46
28
|
};
|
|
47
29
|
|
|
48
30
|
export type DrainResult = {
|
|
49
31
|
readonly records: NetworkRecord[];
|
|
50
|
-
/** Total matches before `limit` was applied. */
|
|
51
32
|
readonly total: number;
|
|
52
|
-
/** True if the ring buffer evicted at least one record since the last drain. */
|
|
53
33
|
readonly bufferOverflowed: boolean;
|
|
54
34
|
};
|
|
55
35
|
|
|
@@ -67,53 +47,41 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
|
|
|
67
47
|
try {
|
|
68
48
|
const re = new RegExp(pattern.slice(1, -1));
|
|
69
49
|
return (url) => re.test(url);
|
|
70
|
-
} catch {
|
|
71
|
-
// Fall through to substring match if the pattern is not a valid regex.
|
|
72
|
-
}
|
|
50
|
+
} catch {}
|
|
73
51
|
}
|
|
74
52
|
return (url) => url.includes(pattern);
|
|
75
53
|
};
|
|
76
54
|
|
|
55
|
+
type UpdateEvent = "Network.responseReceived" | "Network.loadingFinished" | "Network.loadingFailed";
|
|
56
|
+
|
|
77
57
|
export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
58
|
+
const records = createRecordStore<string, NetworkRecord>(capacity);
|
|
59
|
+
|
|
60
|
+
// All three follow-up events say the same thing first: decode, find the request they belong to, give up quietly if it is gone.
|
|
61
|
+
const update = <E extends UpdateEvent>(
|
|
62
|
+
method: E,
|
|
63
|
+
p: unknown,
|
|
64
|
+
apply: (r: NetworkRecord, params: EventParamsOf<E>) => void,
|
|
65
|
+
): void => {
|
|
66
|
+
const decoded = decodeEvent(method, p);
|
|
67
|
+
if (!decoded.success) return;
|
|
68
|
+
const id = decoded.data.requestId;
|
|
69
|
+
if (!id) return;
|
|
70
|
+
const r = records.get(id);
|
|
71
|
+
if (!r) return;
|
|
72
|
+
apply(r, decoded.data);
|
|
90
73
|
};
|
|
91
74
|
|
|
92
75
|
return {
|
|
93
76
|
ingestRequestWillBeSent(p) {
|
|
94
|
-
|
|
95
|
-
const params =
|
|
96
|
-
| {
|
|
97
|
-
requestId?: string;
|
|
98
|
-
request?: {
|
|
99
|
-
url?: string;
|
|
100
|
-
method?: string;
|
|
101
|
-
postData?: string;
|
|
102
|
-
headers?: Record<string, string>;
|
|
103
|
-
};
|
|
104
|
-
type?: string;
|
|
105
|
-
timestamp?: number;
|
|
106
|
-
wallTime?: number;
|
|
107
|
-
}
|
|
108
|
-
| undefined;
|
|
77
|
+
const decoded = decodeEvent("Network.requestWillBeSent", p);
|
|
78
|
+
const params = decoded.success ? decoded.data : undefined;
|
|
109
79
|
const id = params?.requestId;
|
|
110
|
-
const url = params?.request
|
|
111
|
-
const method = params?.request
|
|
80
|
+
const url = params?.request.url;
|
|
81
|
+
const method = params?.request.method;
|
|
112
82
|
if (!id || !url || !method) return;
|
|
113
|
-
// Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place.
|
|
114
83
|
records.delete(id);
|
|
115
|
-
|
|
116
|
-
const postData = params.request?.postData;
|
|
84
|
+
const postData = params.request.postData;
|
|
117
85
|
records.set(id, {
|
|
118
86
|
requestId: id,
|
|
119
87
|
method,
|
|
@@ -125,54 +93,28 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
|
125
93
|
},
|
|
126
94
|
|
|
127
95
|
ingestResponseReceived(p) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
mimeType?: string;
|
|
136
|
-
};
|
|
137
|
-
type?: string;
|
|
138
|
-
}
|
|
139
|
-
| undefined;
|
|
140
|
-
const id = params?.requestId;
|
|
141
|
-
if (!id) return;
|
|
142
|
-
const r = records.get(id);
|
|
143
|
-
if (!r) return;
|
|
144
|
-
if (params?.response?.status !== undefined) r.status = params.response.status;
|
|
145
|
-
if (params?.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
|
|
146
|
-
if (params?.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
|
|
147
|
-
// type is more accurate on responseReceived (CDP sometimes refines it)
|
|
148
|
-
if (params?.type !== undefined) r.type = params.type;
|
|
96
|
+
update("Network.responseReceived", p, (r, params) => {
|
|
97
|
+
if (params.response?.status !== undefined) r.status = params.response.status;
|
|
98
|
+
if (params.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText;
|
|
99
|
+
if (params.response?.mimeType !== undefined) r.mimeType = params.response.mimeType;
|
|
100
|
+
// type is more accurate on responseReceived (CDP sometimes refines it)
|
|
101
|
+
if (params.type !== undefined) r.type = params.type;
|
|
102
|
+
});
|
|
149
103
|
},
|
|
150
104
|
|
|
151
105
|
ingestLoadingFinished(p) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const id = params?.requestId;
|
|
157
|
-
if (!id) return;
|
|
158
|
-
const r = records.get(id);
|
|
159
|
-
if (!r) return;
|
|
160
|
-
if (params?.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
|
|
161
|
-
r.durationMs = Date.now() - r.requestStartedMs;
|
|
106
|
+
update("Network.loadingFinished", p, (r, params) => {
|
|
107
|
+
if (params.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength;
|
|
108
|
+
r.durationMs = Date.now() - r.requestStartedMs;
|
|
109
|
+
});
|
|
162
110
|
},
|
|
163
111
|
|
|
164
112
|
ingestLoadingFailed(p) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (!id) return;
|
|
171
|
-
const r = records.get(id);
|
|
172
|
-
if (!r) return;
|
|
173
|
-
r.failed = true;
|
|
174
|
-
if (params?.errorText !== undefined) r.errorText = params.errorText;
|
|
175
|
-
r.durationMs = Date.now() - r.requestStartedMs;
|
|
113
|
+
update("Network.loadingFailed", p, (r, params) => {
|
|
114
|
+
r.failed = true;
|
|
115
|
+
if (params.errorText !== undefined) r.errorText = params.errorText;
|
|
116
|
+
r.durationMs = Date.now() - r.requestStartedMs;
|
|
117
|
+
});
|
|
176
118
|
},
|
|
177
119
|
|
|
178
120
|
drain(filter) {
|
|
@@ -191,23 +133,14 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
|
191
133
|
if (cutoff !== undefined && r.requestStartedMs < cutoff) continue;
|
|
192
134
|
if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue;
|
|
193
135
|
if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue;
|
|
194
|
-
// Clone so callers can mutate (e.g. attach body) without polluting the buffer.
|
|
195
136
|
matched.push({ ...r });
|
|
196
137
|
}
|
|
197
138
|
|
|
198
|
-
|
|
199
|
-
const limit = Math.min(filter.limit ?? 50, 500);
|
|
200
|
-
const limited = matched.slice(-limit); // most recent N
|
|
201
|
-
|
|
202
|
-
const bufferOverflowed = overflowed;
|
|
203
|
-
overflowed = false;
|
|
204
|
-
|
|
205
|
-
return { records: limited, total, bufferOverflowed };
|
|
139
|
+
return records.page(matched, filter.limit);
|
|
206
140
|
},
|
|
207
141
|
|
|
208
142
|
clear() {
|
|
209
143
|
records.clear();
|
|
210
|
-
overflowed = false;
|
|
211
144
|
},
|
|
212
145
|
};
|
|
213
146
|
};
|
package/src/cdp/ownership.ts
CHANGED
|
@@ -1,28 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tracks which tab targetIds belong to this harness session.
|
|
3
|
-
*
|
|
4
|
-
* The harness attaches to the user's running Chrome, which is also full of
|
|
5
|
-
* the user's own tabs. Without an ownership boundary the agent could attach
|
|
6
|
-
* to (or close) personal tabs by accident. The registry is the single source
|
|
7
|
-
* of truth for "did this session open that tab?".
|
|
8
|
-
*
|
|
9
|
-
* Mutations notify a single change listener so the orchestrator can persist
|
|
10
|
-
* to disk via the existing BrowserState flow without the registry needing to
|
|
11
|
-
* know about the pi storage API.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
1
|
export type OwnershipRegistry = {
|
|
15
2
|
add(targetId: string): void;
|
|
16
3
|
remove(targetId: string): void;
|
|
17
4
|
has(targetId: string): boolean;
|
|
18
5
|
list(): ReadonlyArray<string>;
|
|
19
|
-
/** Replace the full set — used on restoreState to re-hydrate from disk. */
|
|
20
6
|
replaceAll(ids: ReadonlyArray<string>): void;
|
|
21
7
|
setHarnessWindow(targetId: string | undefined): void;
|
|
22
8
|
harnessWindow(): string | undefined;
|
|
23
|
-
/** The real Chrome windowId of the dedicated harness window — the durable
|
|
24
|
-
* identity of "the window this session initialized", surviving seed-tab
|
|
25
|
-
* closure. Undefined until the window is created (or after teardown). */
|
|
26
9
|
setHarnessWindowId(windowId: number | undefined): void;
|
|
27
10
|
harnessWindowId(): number | undefined;
|
|
28
11
|
onChange(cb: () => void): void;
|