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.
Files changed (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -1,54 +1,30 @@
1
- /**
2
- * In-memory aggregator for CDP Runtime.consoleAPICalled and Log.entryAdded events.
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
- // Insertion-ordered map keyed by seq. Map preserves insertion order in JS,
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
- // CDP boundary cast: Runtime.consoleAPICalled
169
- const params = p as
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
- // CDP boundary cast: Log.entryAdded
203
- const params = p as
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
- const total = matched.length;
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
  };
@@ -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
- * A live CDP endpoint plus, when discovery went through a DevToolsActivePort
14
- * file, the user-data-dir that file lives in. That directory is the browser's
15
- * profile registry (`Local State`) — Chromium writes DevToolsActivePort to
16
- * chrome::DIR_USER_DATA — so it is what profile selection enumerates.
17
- * Undefined when the endpoint came from BU_CDP_WS or a bare port probe.
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
- // Ask the live browser for its canonical webSocketDebuggerUrl via
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
- try {
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
- * Locate a live CDP endpoint, reporting the user-data-dir it belongs to when
120
- * that is knowable. `discoverWsUrl` is the URL-only view of the same walk.
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
- // Node fs errors carry .code; see ErrnoException. ENOENT is normal — the
144
- // dir is on our list but the user hasn't installed that browser. EPERM/
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
- // Guard against a truncated/corrupt port file (e.g. a non-numeric first
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
- // Phase 4: for each candidate, prefer the browser's own canonical WS URL from
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
+ };