pi-browser-harness 0.10.2 → 0.12.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 +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -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 +375 -265
- 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 +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- 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 -37
- package/src/domains/console.ts +48 -4
- 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 +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- 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 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- 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/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- 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/paths.ts +11 -1
- 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
|
@@ -1,54 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Mirrors network-buffer.ts: the single CDP event loop in session.ts hands raw
|
|
5
|
-
* params to ingestConsoleApi / ingestLogEntry; tools later read records via drain().
|
|
6
|
-
*
|
|
7
|
-
* Bounded by capacity (default 500). On overflow the oldest record is evicted FIFO,
|
|
8
|
-
* and the overflow flag is reported once per drain so tools can warn the LLM that
|
|
9
|
-
* data may be missing.
|
|
10
|
-
*
|
|
11
|
-
* Each record carries a monotonic `seq`. Tools expose the last drained seq as
|
|
12
|
-
* `nextCursor`; callers pass it back as `sinceSeq` to read only what's new since
|
|
13
|
-
* the previous drain. This is the cursor pattern that makes "what did this action
|
|
14
|
-
* cause?" answerable in one tool call.
|
|
15
|
-
*
|
|
16
|
-
* Pure module (no CDP dependency) — joining/filtering logic stays trivially testable.
|
|
17
|
-
*/
|
|
1
|
+
import { decodeEvent } from "./events";
|
|
2
|
+
import { createRecordStore } from "./record-store";
|
|
18
3
|
|
|
19
4
|
export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
|
|
20
5
|
|
|
21
6
|
export type ConsoleRecord = {
|
|
22
7
|
seq: number;
|
|
23
|
-
/** Wall-clock ms when the event was ingested, in Date.now() units. */
|
|
24
8
|
timestampMs: number;
|
|
25
9
|
level: ConsoleLevel;
|
|
26
|
-
/** Joined args (console-api) or the entry text (log-entry). Per-arg truncated. */
|
|
27
10
|
text: string;
|
|
28
11
|
source: "console-api" | "log-entry";
|
|
29
12
|
url?: string;
|
|
30
13
|
lineNumber?: number;
|
|
31
|
-
/** Top frames only, joined with newlines. Populated for error/warn when CDP provides one. */
|
|
32
14
|
stackTrace?: string;
|
|
33
15
|
};
|
|
34
16
|
|
|
35
17
|
export type ConsoleFilter = {
|
|
36
18
|
levels?: ConsoleLevel[];
|
|
37
|
-
/** Substring match by default. Wrap in slashes (`/foo/`) for regex. */
|
|
38
19
|
textPattern?: string;
|
|
39
|
-
/** Only records with seq strictly greater than this. */
|
|
40
20
|
sinceSeq?: number;
|
|
41
|
-
/** Only records with timestamp at most N ms ago. */
|
|
42
21
|
sinceMs?: number;
|
|
43
|
-
/** Cap on returned records (default 50, max 500). */
|
|
44
22
|
limit?: number;
|
|
45
23
|
};
|
|
46
24
|
|
|
47
25
|
export type ConsoleDrainResult = {
|
|
48
26
|
readonly records: ConsoleRecord[];
|
|
49
|
-
/** Total matches before `limit` was applied. */
|
|
50
27
|
readonly total: number;
|
|
51
|
-
/** True if the ring buffer evicted at least one record since the last drain. */
|
|
52
28
|
readonly bufferOverflowed: boolean;
|
|
53
29
|
};
|
|
54
30
|
|
|
@@ -59,9 +35,7 @@ export type ConsoleBuffer = {
|
|
|
59
35
|
clear(): void;
|
|
60
36
|
};
|
|
61
37
|
|
|
62
|
-
/** Per-arg cap to stop a single console.log(hugeBlob) from blowing the buffer. */
|
|
63
38
|
const PER_ARG_CAP = 2000;
|
|
64
|
-
/** Max stack frames preserved per record. */
|
|
65
39
|
const MAX_STACK_FRAMES = 3;
|
|
66
40
|
|
|
67
41
|
const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
@@ -69,9 +43,7 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
|
69
43
|
try {
|
|
70
44
|
const re = new RegExp(pattern.slice(1, -1));
|
|
71
45
|
return (text) => re.test(text);
|
|
72
|
-
} catch {
|
|
73
|
-
// Fall through to substring match if the pattern is not a valid regex.
|
|
74
|
-
}
|
|
46
|
+
} catch {}
|
|
75
47
|
}
|
|
76
48
|
return (text) => text.includes(pattern);
|
|
77
49
|
};
|
|
@@ -79,7 +51,6 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
|
79
51
|
const truncateArg = (s: string): string =>
|
|
80
52
|
s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s;
|
|
81
53
|
|
|
82
|
-
/** Best-effort stringification of one Runtime.RemoteObject. */
|
|
83
54
|
const stringifyRemoteObject = (
|
|
84
55
|
arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string },
|
|
85
56
|
): string => {
|
|
@@ -96,7 +67,6 @@ const stringifyRemoteObject = (
|
|
|
96
67
|
return arg.type ?? "";
|
|
97
68
|
};
|
|
98
69
|
|
|
99
|
-
/** Map CDP console-API method names to our normalized levels. */
|
|
100
70
|
const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
101
71
|
switch (type) {
|
|
102
72
|
case "error":
|
|
@@ -114,7 +84,6 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
|
114
84
|
}
|
|
115
85
|
};
|
|
116
86
|
|
|
117
|
-
/** Map Log.LogEntry.level values ("verbose","info","warning","error") to our levels. */
|
|
118
87
|
const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
|
|
119
88
|
switch (level) {
|
|
120
89
|
case "error":
|
|
@@ -143,43 +112,18 @@ const formatStackTrace = (
|
|
|
143
112
|
};
|
|
144
113
|
|
|
145
114
|
export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
146
|
-
|
|
147
|
-
// so iteration gives us oldest-first eviction without a separate index.
|
|
148
|
-
const records = new Map<number, ConsoleRecord>();
|
|
115
|
+
const records = createRecordStore<number, ConsoleRecord>(capacity);
|
|
149
116
|
let nextSeq = 1;
|
|
150
|
-
let overflowed = false;
|
|
151
|
-
|
|
152
|
-
const evictOldestIfFull = (): void => {
|
|
153
|
-
while (records.size >= capacity) {
|
|
154
|
-
const oldest = records.keys().next();
|
|
155
|
-
if (oldest.done) return;
|
|
156
|
-
records.delete(oldest.value);
|
|
157
|
-
overflowed = true;
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
117
|
|
|
161
118
|
const insert = (rec: ConsoleRecord): void => {
|
|
162
|
-
evictOldestIfFull();
|
|
163
119
|
records.set(rec.seq, rec);
|
|
164
120
|
};
|
|
165
121
|
|
|
166
122
|
return {
|
|
167
123
|
ingestConsoleApi(p) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
type?: string;
|
|
172
|
-
args?: ReadonlyArray<{
|
|
173
|
-
type?: string;
|
|
174
|
-
subtype?: string;
|
|
175
|
-
value?: unknown;
|
|
176
|
-
description?: string;
|
|
177
|
-
unserializableValue?: string;
|
|
178
|
-
}>;
|
|
179
|
-
stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> };
|
|
180
|
-
}
|
|
181
|
-
| undefined;
|
|
182
|
-
if (!params) return;
|
|
124
|
+
const decoded = decodeEvent("Runtime.consoleAPICalled", p);
|
|
125
|
+
if (!decoded.success) return;
|
|
126
|
+
const params = decoded.data;
|
|
183
127
|
const level = mapConsoleApiLevel(params.type);
|
|
184
128
|
const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a)));
|
|
185
129
|
const text = argTexts.join(" ");
|
|
@@ -199,19 +143,8 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
|
199
143
|
},
|
|
200
144
|
|
|
201
145
|
ingestLogEntry(p) {
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
| {
|
|
205
|
-
entry?: {
|
|
206
|
-
level?: string;
|
|
207
|
-
text?: string;
|
|
208
|
-
url?: string;
|
|
209
|
-
lineNumber?: number;
|
|
210
|
-
stackTrace?: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> };
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
| undefined;
|
|
214
|
-
const entry = params?.entry;
|
|
146
|
+
const decoded = decodeEvent("Log.entryAdded", p);
|
|
147
|
+
const entry = decoded.success ? decoded.data.entry : undefined;
|
|
215
148
|
if (!entry) return;
|
|
216
149
|
const level = mapLogEntryLevel(entry.level);
|
|
217
150
|
const text = truncateArg(entry.text ?? "");
|
|
@@ -244,20 +177,12 @@ export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
|
244
177
|
matched.push({ ...r });
|
|
245
178
|
}
|
|
246
179
|
|
|
247
|
-
|
|
248
|
-
const limit = Math.min(filter.limit ?? 50, 500);
|
|
249
|
-
const limited = matched.slice(-limit);
|
|
250
|
-
|
|
251
|
-
const bufferOverflowed = overflowed;
|
|
252
|
-
overflowed = false;
|
|
253
|
-
|
|
254
|
-
return { records: limited, total, bufferOverflowed };
|
|
180
|
+
return records.page(matched, filter.limit);
|
|
255
181
|
},
|
|
256
182
|
|
|
257
183
|
clear() {
|
|
258
184
|
records.clear();
|
|
259
185
|
nextSeq = 1;
|
|
260
|
-
overflowed = false;
|
|
261
186
|
},
|
|
262
187
|
};
|
|
263
188
|
};
|
package/src/cdp/discovery.ts
CHANGED
|
@@ -5,17 +5,25 @@ import { request as httpRequest } from "node:http";
|
|
|
5
5
|
import { type Result, err, ok } from "../util/result";
|
|
6
6
|
import { userDataDirCandidates } from "../profile/paths";
|
|
7
7
|
import { type CdpError, cdpError } from "./errors";
|
|
8
|
+
import { asString, errnoCode, isRecord } from "../util/guards";
|
|
8
9
|
|
|
9
10
|
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
10
11
|
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
// Hand-rolled on purpose: this file runs inside the daemon, a separate Node process that pi does not
|
|
14
|
+
// supply host-provided packages (typebox) to. Everything the daemon imports must be a runtime
|
|
15
|
+
// dependency or a builtin; test/daemon/entry-imports-test.ts enforces that.
|
|
16
|
+
export const parseVersionResponse = (raw: string): string | null => {
|
|
17
|
+
let parsed: unknown;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!isRecord(parsed)) return null;
|
|
24
|
+
return asString(parsed["webSocketDebuggerUrl"]) ?? null;
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
export type CdpEndpoint = {
|
|
20
28
|
readonly wsUrl: string;
|
|
21
29
|
readonly userDataDir?: string;
|
|
@@ -52,17 +60,9 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
|
52
60
|
));
|
|
53
61
|
};
|
|
54
62
|
|
|
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
63
|
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
|
|
59
64
|
|
|
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.
|
|
65
|
+
// /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port.
|
|
66
66
|
const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
|
|
67
67
|
new Promise<string | null>((resolve) => {
|
|
68
68
|
const req = httpRequest(
|
|
@@ -76,13 +76,7 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
|
|
|
76
76
|
const chunks: Buffer[] = [];
|
|
77
77
|
res.on("data", (c: Buffer) => chunks.push(c));
|
|
78
78
|
res.on("end", () => {
|
|
79
|
-
|
|
80
|
-
const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
|
81
|
-
const ws = json["webSocketDebuggerUrl"];
|
|
82
|
-
resolve(typeof ws === "string" ? ws : null);
|
|
83
|
-
} catch {
|
|
84
|
-
resolve(null);
|
|
85
|
-
}
|
|
79
|
+
resolve(parseVersionResponse(Buffer.concat(chunks).toString("utf8")));
|
|
86
80
|
});
|
|
87
81
|
},
|
|
88
82
|
);
|
|
@@ -91,10 +85,6 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
|
|
|
91
85
|
req.end();
|
|
92
86
|
});
|
|
93
87
|
|
|
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
88
|
const fallbackPorts = (): ReadonlyArray<number> => {
|
|
99
89
|
const ports = new Set<number>([9222]);
|
|
100
90
|
const fromEnv = process.env["BU_CDP_PORTS"];
|
|
@@ -111,21 +101,21 @@ type Candidate = {
|
|
|
111
101
|
readonly port: number;
|
|
112
102
|
readonly path: string;
|
|
113
103
|
readonly mtimeMs: number;
|
|
114
|
-
/** The user-data-dir whose DevToolsActivePort produced this candidate. */
|
|
115
104
|
readonly userDataDir: string;
|
|
116
105
|
};
|
|
117
106
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
107
|
+
// 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.
|
|
108
|
+
export const endpointFromEnv = (env: NodeJS.ProcessEnv = process.env): CdpEndpoint | undefined => {
|
|
109
|
+
const wsUrl = env["BU_CDP_WS"]?.trim();
|
|
110
|
+
return wsUrl ? { wsUrl } : undefined;
|
|
111
|
+
};
|
|
112
|
+
|
|
122
113
|
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
|
|
114
|
+
const override = endpointFromEnv();
|
|
115
|
+
if (override) return ok(override);
|
|
116
|
+
|
|
123
117
|
const dirs = userDataDirCandidates();
|
|
124
118
|
|
|
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
119
|
const candidates: Candidate[] = [];
|
|
130
120
|
const readErrors: string[] = [];
|
|
131
121
|
for (const base of dirs) {
|
|
@@ -136,15 +126,10 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
136
126
|
raw = await readFile(portFile, "utf8");
|
|
137
127
|
try {
|
|
138
128
|
mtimeMs = (await stat(portFile)).mtimeMs;
|
|
139
|
-
} catch {
|
|
140
|
-
// mtime is only a tiebreaker; a stat failure just leaves it at 0.
|
|
141
|
-
}
|
|
129
|
+
} catch {}
|
|
142
130
|
} 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;
|
|
131
|
+
// EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
|
|
132
|
+
const code = errnoCode(e);
|
|
148
133
|
if (code === "ENOENT" || code === undefined) continue;
|
|
149
134
|
if (code === "EPERM" || code === "EACCES") {
|
|
150
135
|
readErrors.push(`${portFile}: ${code}`);
|
|
@@ -157,25 +142,16 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
157
142
|
const port = lines[0]?.trim();
|
|
158
143
|
const path = lines[1]?.trim();
|
|
159
144
|
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.
|
|
145
|
+
// An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error.
|
|
164
146
|
const portNum = Number(port);
|
|
165
147
|
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
|
|
166
148
|
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
|
|
167
149
|
}
|
|
168
150
|
|
|
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
151
|
if (candidates.length === 0) {
|
|
174
152
|
for (const port of fallbackPorts()) {
|
|
175
153
|
if (!(await isPortLive(port))) continue;
|
|
176
154
|
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
155
|
if (live) return ok({ wsUrl: live });
|
|
180
156
|
}
|
|
181
157
|
const permHint = readErrors.length > 0
|
|
@@ -187,9 +163,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
187
163
|
));
|
|
188
164
|
}
|
|
189
165
|
|
|
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
166
|
const byPort = new Map<number, Candidate>();
|
|
194
167
|
for (const c of candidates) {
|
|
195
168
|
const prev = byPort.get(c.port);
|
|
@@ -197,16 +170,11 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
197
170
|
}
|
|
198
171
|
const unique = Array.from(byPort.values());
|
|
199
172
|
|
|
200
|
-
// Prefer candidates whose port is actually accepting connections; this skips
|
|
201
|
-
// stale files whose browser has quit (avoiding a ~30s waitForPort block).
|
|
202
173
|
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
|
|
203
174
|
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
|
|
204
175
|
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
|
|
205
176
|
|
|
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.
|
|
177
|
+
// 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
178
|
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
|
|
211
179
|
for (const c of ordered) {
|
|
212
180
|
const ready = await waitForPort(c.port);
|
|
@@ -218,10 +186,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
218
186
|
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
|
|
219
187
|
}
|
|
220
188
|
|
|
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
189
|
for (const port of fallbackPorts()) {
|
|
226
190
|
if (byPort.has(port) || !(await isPortLive(port))) continue;
|
|
227
191
|
const liveUrl = await queryLiveWsUrl(port);
|
|
@@ -231,10 +195,6 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
231
195
|
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
|
|
232
196
|
};
|
|
233
197
|
|
|
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
198
|
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
239
199
|
const endpoint = await discoverEndpoint();
|
|
240
200
|
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
|
+
};
|