pi-browser-harness 0.12.0 → 0.13.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 (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +86 -0
  3. package/package.json +12 -6
  4. package/skills/pi-browser-harness/SKILL.md +57 -2
  5. package/src/cdp/commands.ts +200 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +65 -44
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +18 -51
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -53
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -10
  59. package/src/domains/scroll.ts +4 -4
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -33
  62. package/src/domains/snapshot.ts +71 -73
  63. package/src/domains/tab-resolve.ts +36 -0
  64. package/src/domains/tabs.ts +18 -69
  65. package/src/domains/target.ts +1 -1
  66. package/src/index.ts +90 -33
  67. package/src/profile/bind.ts +45 -14
  68. package/src/profile/browser-process.ts +50 -12
  69. package/src/profile/command.ts +20 -12
  70. package/src/profile/launch.ts +13 -9
  71. package/src/profile/list.ts +9 -13
  72. package/src/profile/store.ts +2 -1
  73. package/src/prompt.ts +32 -7
  74. package/src/registry.ts +65 -4
  75. package/src/schemas/parse.ts +2 -2
  76. package/src/setup.ts +28 -19
  77. package/src/state.ts +2 -1
  78. package/src/util/debug.ts +7 -1
  79. package/src/util/guards.ts +3 -0
  80. package/src/util/mutex.ts +1 -1
  81. package/src/util/paths.ts +3 -2
  82. package/src/util/result.ts +8 -0
  83. package/src/util/sharp-shim.ts +18 -11
  84. package/src/util/sleep.ts +18 -0
  85. package/src/util/tool.ts +163 -114
  86. package/src/util/truncate.ts +1 -1
  87. package/tsconfig.json +2 -1
  88. package/src/domains/fill-engine.ts +0 -80
@@ -1,6 +1,7 @@
1
1
  import { createServer, type Server, type Socket } from "node:net";
2
- import { createInterface, type Interface } from "node:readline";
2
+ import { readLines } from "./lines";
3
3
  import { unlinkSync } from "node:fs";
4
+ import { destroyQuietly } from "./socket";
4
5
  import {
5
6
  type WireMessage,
6
7
  type WireControl,
@@ -13,7 +14,7 @@ import {
13
14
  export type ClientSocket = {
14
15
  id: string;
15
16
  readonly socket: Socket;
16
- readonly rl: Interface;
17
+ readonly stopReading: () => void;
17
18
  registered: boolean;
18
19
  };
19
20
 
@@ -21,51 +22,86 @@ export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
21
22
 
22
23
  export type ConnectionHandler = (client: ClientSocket) => void;
23
24
 
25
+ export type Disposer = () => void;
26
+
27
+ export type IpcServerOptions = {
28
+ readonly socketPath?: string;
29
+ readonly maxClients?: number;
30
+ };
31
+
24
32
  export type IpcServer = {
25
- start(): Promise<void>;
26
- stop(): Promise<void>;
27
- onMessage(handler: MessageHandler): void;
28
- onConnect(handler: ConnectionHandler): void;
29
- onDisconnect(handler: ConnectionHandler): void;
30
- send(clientId: string, msg: WireMessage): void;
31
- broadcast(msg: WireMessage): void;
32
- clientCount(): number;
33
+ readonly start: () => Promise<void>;
34
+ readonly stop: () => Promise<void>;
35
+ readonly onMessage: (handler: MessageHandler) => Disposer;
36
+ readonly onConnect: (handler: ConnectionHandler) => Disposer;
37
+ // Runs once per registered client when it goes away, whether its socket closed or it sent
38
+ // "deregister". Every registered handler runs; registering is additive, not a replacement.
39
+ readonly onDisconnect: (handler: ConnectionHandler) => Disposer;
40
+ readonly send: (clientId: string, msg: WireMessage) => void;
41
+ readonly broadcast: (msg: WireMessage) => void;
42
+ readonly clientCount: () => number;
43
+ // Every open socket, registered or not; the connection cap counts these.
44
+ readonly socketCount: () => number;
33
45
  };
34
46
 
35
- export const createIpcServer = (): IpcServer => {
47
+ const listen = <H>(handlers: Set<H>) => (handler: H): Disposer => {
48
+ handlers.add(handler);
49
+ return () => { handlers.delete(handler); };
50
+ };
51
+
52
+
53
+ export const createIpcServer = (options: IpcServerOptions = {}): IpcServer => {
54
+ const socketPath = options.socketPath ?? DAEMON_SOCKET_PATH;
55
+ const maxClients = options.maxClients ?? DAEMON_MAX_CLIENTS;
36
56
  let server: Server | null = null;
37
- const sockets = new Map<string, ClientSocket>();
57
+ // Sockets that have not registered yet, by their temporary id.
58
+ const anonymous = new Map<string, ClientSocket>();
38
59
  const clients = new Map<string, ClientSocket>();
39
- let messageHandler: MessageHandler | null = null;
40
- let connectHandler: ConnectionHandler | null = null;
41
- let disconnectHandler: ConnectionHandler | null = null;
60
+ const messageHandlers = new Set<MessageHandler>();
61
+ const connectHandlers = new Set<ConnectionHandler>();
62
+ const disconnectHandlers = new Set<ConnectionHandler>();
42
63
  let nextTempId = 1;
43
64
 
65
+ const unlinkSocketFile = (): void => {
66
+ if (process.platform === "win32") return;
67
+ try {
68
+ unlinkSync(socketPath);
69
+ } catch {
70
+ // Nothing to unlink, or no permission: listen() reports the real problem if there is one.
71
+ }
72
+ };
73
+
74
+ // A registered client leaves exactly once: either its socket closes or it deregisters first.
75
+ const forget = (client: ClientSocket): void => {
76
+ if (!client.registered) return;
77
+ client.registered = false;
78
+ clients.delete(client.id);
79
+ for (const h of disconnectHandlers) h(client);
80
+ };
81
+
44
82
  const start = (): Promise<void> =>
45
83
  new Promise((resolve, reject) => {
46
- try {
47
- unlinkSync(DAEMON_SOCKET_PATH);
48
- } catch {}
84
+ unlinkSocketFile();
49
85
 
50
86
  server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
51
- if (clients.size >= DAEMON_MAX_CLIENTS) {
87
+ if (anonymous.size + clients.size >= maxClients) {
52
88
  socket.destroy();
53
89
  return;
54
90
  }
55
91
 
56
92
  const tempId = `anon-${nextTempId++}`;
57
- const rl = createInterface({ input: socket, crlfDelay: Infinity });
58
- let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
93
+ const stopReading = readLines(socket, (line) => { onLine(line); });
94
+ const client: ClientSocket = { id: tempId, socket, stopReading, registered: false };
59
95
 
60
- sockets.set(tempId, currentClient);
96
+ anonymous.set(tempId, client);
61
97
 
62
- rl.on("line", (line: string) => {
98
+ function onLine(line: string): void {
63
99
  const msg = deserialize(line);
64
100
  if (!msg) return;
65
101
 
66
- if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
102
+ if (msg.type === "control" && msg.action === "register") {
103
+ if (client.registered) return;
67
104
  const clientId = msg.clientId;
68
- if (!clientId) return;
69
105
 
70
106
  if (clients.has(clientId)) {
71
107
  const reply: WireControl = {
@@ -73,41 +109,43 @@ export const createIpcServer = (): IpcServer => {
73
109
  action: "shutdown",
74
110
  reason: `clientId ${clientId} is already connected`,
75
111
  };
76
- socket.write(serialize(reply) + "\n");
77
- socket.destroy();
112
+ // end() flushes the reply before closing; destroy() would race it.
113
+ socket.end(serialize(reply) + "\n");
78
114
  return;
79
115
  }
80
116
 
81
117
  // Mutate in place so the already-captured closure sees the promoted client.
82
- sockets.delete(tempId);
83
- currentClient.id = clientId;
84
- currentClient.registered = true;
85
- clients.set(clientId, currentClient);
118
+ anonymous.delete(tempId);
119
+ client.id = clientId;
120
+ client.registered = true;
121
+ clients.set(clientId, client);
86
122
 
87
123
  const ack: WireControl = { type: "control", action: "registered", clientId };
88
124
  socket.write(serialize(ack) + "\n");
89
125
 
90
- connectHandler?.(currentClient);
126
+ for (const h of connectHandlers) h(client);
91
127
  return;
92
128
  }
93
129
 
94
- if (!currentClient.registered) return;
130
+ if (!client.registered) return;
95
131
 
96
- messageHandler?.(msg, currentClient);
97
- });
132
+ if (msg.type === "control" && msg.action === "deregister") {
133
+ if (msg.clientId !== client.id) return;
134
+ forget(client);
135
+ socket.end();
136
+ return;
137
+ }
138
+
139
+ for (const h of messageHandlers) h(msg, client);
140
+ }
98
141
 
99
- socket.on("error", () => {});
142
+ // A client socket error is always followed by "close", which does the cleanup.
143
+ socket.on("error", () => undefined);
100
144
 
101
145
  socket.on("close", () => {
102
- const found = clients.get(currentClient.id) ?? sockets.get(tempId);
103
- if (found) {
104
- clients.delete(found.id);
105
- sockets.delete(tempId);
106
- if (found.registered) {
107
- disconnectHandler?.(found);
108
- }
109
- }
110
- rl.close();
146
+ anonymous.delete(tempId);
147
+ forget(client);
148
+ stopReading();
111
149
  });
112
150
  });
113
151
 
@@ -115,25 +153,27 @@ export const createIpcServer = (): IpcServer => {
115
153
  reject(err);
116
154
  });
117
155
 
118
- server.listen(DAEMON_SOCKET_PATH, () => {
156
+ server.listen(socketPath, () => {
119
157
  resolve();
120
158
  });
121
159
  });
122
160
 
123
161
  const stop = (): Promise<void> =>
124
162
  new Promise((resolve) => {
163
+ // Stopping is not a client leaving: no disconnect handler runs for sockets torn down here.
125
164
  for (const [, client] of clients) {
126
- try { client.socket.destroy(); } catch {}
165
+ client.registered = false;
166
+ destroyQuietly(client.socket);
127
167
  }
128
- for (const [, client] of sockets) {
129
- try { client.socket.destroy(); } catch {}
168
+ for (const [, client] of anonymous) {
169
+ destroyQuietly(client.socket);
130
170
  }
131
171
  clients.clear();
132
- sockets.clear();
172
+ anonymous.clear();
133
173
 
134
174
  if (server) {
135
175
  server.close(() => {
136
- try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
176
+ unlinkSocketFile();
137
177
  resolve();
138
178
  });
139
179
  } else {
@@ -146,25 +186,22 @@ export const createIpcServer = (): IpcServer => {
146
186
  if (!client) return;
147
187
  try {
148
188
  client.socket.write(serialize(msg) + "\n");
149
- } catch {}
189
+ } catch {
190
+ // The client went away mid-write; its "close" handler forgets it.
191
+ }
150
192
  };
151
193
 
152
194
  return {
153
195
  start,
154
196
  stop,
155
- onMessage(handler) {
156
- messageHandler = handler;
157
- },
158
- onConnect(handler) {
159
- connectHandler = handler;
160
- },
161
- onDisconnect(handler) {
162
- disconnectHandler = handler;
163
- },
197
+ onMessage: listen(messageHandlers),
198
+ onConnect: listen(connectHandlers),
199
+ onDisconnect: listen(disconnectHandlers),
164
200
  send,
165
- broadcast(msg) {
201
+ broadcast: (msg) => {
166
202
  for (const [clientId] of clients) send(clientId, msg);
167
203
  },
168
- clientCount: (): number => clients.size,
204
+ clientCount: () => clients.size,
205
+ socketCount: () => anonymous.size + clients.size,
169
206
  };
170
207
  };
@@ -0,0 +1,10 @@
1
+ import type { Socket } from "node:net";
2
+
3
+ // destroy() on a socket whose handle is already gone throws; the socket is gone either way.
4
+ export const destroyQuietly = (socket: Socket): void => {
5
+ try {
6
+ socket.destroy();
7
+ } catch {
8
+ // Already torn down.
9
+ }
10
+ };
@@ -5,7 +5,9 @@ import { createRequire } from "node:module";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { dirname, join } from "node:path";
7
7
  import { createConnection } from "node:net";
8
- import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
8
+ import { readLines } from "./lines";
9
+ import { DAEMON_SOCKET_PATH, deserialize, serialize, type WireControl } from "./protocol";
10
+ import { destroyQuietly } from "./socket";
9
11
 
10
12
  const moduleDir = dirname(fileURLToPath(import.meta.url));
11
13
 
@@ -16,7 +18,8 @@ const isWindows = process.platform === "win32";
16
18
  // No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
17
19
  const cleanupStaleSocket = (): void => {
18
20
  if (isWindows) return;
19
- unlink(DAEMON_SOCKET_PATH).catch(() => {});
21
+ // ENOENT is the normal case; anything else surfaces when listen() fails.
22
+ unlink(DAEMON_SOCKET_PATH).catch(() => undefined);
20
23
  };
21
24
 
22
25
  export const daemonSocketMayExist = async (
@@ -45,7 +48,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
45
48
 
46
49
  const sock = createConnection(DAEMON_SOCKET_PATH);
47
50
  const timer = setTimeout(() => {
48
- sock.destroy();
51
+ destroyQuietly(sock);
49
52
  cleanupStaleSocket();
50
53
  done(false);
51
54
  }, timeoutMs);
@@ -55,20 +58,20 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
55
58
  sock.write(serialize(regMsg) + "\n");
56
59
  });
57
60
 
58
- sock.on("data", (chunk: Buffer) => {
59
- const text = chunk.toString();
60
- if (text.includes('"registered"')) {
61
- clearTimeout(timer);
62
- const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
63
- try { sock.write(serialize(deregMsg) + "\n"); } catch {}
64
- sock.destroy();
65
- done(true);
66
- }
61
+ // Line-framed like every other reader of this protocol: a chunk may hold a partial line.
62
+ readLines(sock, (line) => {
63
+ const msg = deserialize(line);
64
+ if (msg?.type !== "control" || msg.action !== "registered") return;
65
+ clearTimeout(timer);
66
+ const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
67
+ // end() lets the deregister reach the daemon; destroy() would drop it unflushed.
68
+ try { sock.end(serialize(deregMsg) + "\n"); } catch { destroyQuietly(sock); }
69
+ done(true);
67
70
  });
68
71
 
69
72
  sock.on("error", () => {
70
73
  clearTimeout(timer);
71
- sock.destroy();
74
+ destroyQuietly(sock);
72
75
  cleanupStaleSocket();
73
76
  done(false);
74
77
  });
@@ -84,9 +87,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
84
87
  // Walks upward like Node's own node_modules lookup. Exported with an injectable start so the
85
88
  // hoisted layout pi uses can be exercised against a fixture instead of this checkout.
86
89
  export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => {
87
- let dir = startDir;
88
-
89
- while (true) {
90
+ for (let dir = startDir; ; dir = dirname(dir)) {
90
91
  const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
91
92
  try {
92
93
  accessSync(candidate);
@@ -96,10 +97,8 @@ export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): strin
96
97
  // without relying on createRequire().resolve(), which is broken for external modules
97
98
  // loaded through jiti/static inside a Bun-compiled executable such as pi.exe.
98
99
  }
99
-
100
- const parent = dirname(dir);
101
- if (parent === dir) return null;
102
- dir = parent;
100
+ // dirname() of the filesystem root is the root itself.
101
+ if (dirname(dir) === dir) return null;
103
102
  }
104
103
  };
105
104
 
@@ -132,7 +131,7 @@ export const daemonNodeExecutable = (
132
131
  };
133
132
 
134
133
  export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
135
- const daemonScript = join(moduleDir, "index.ts");
134
+ const daemonScript = join(moduleDir, "main.ts");
136
135
  const tsxCli = resolveTsxCli();
137
136
  if (!tsxCli) return null;
138
137
 
@@ -156,7 +155,7 @@ export const spawnDaemon = (): ChildProcess | null => {
156
155
  // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host)
157
156
  // asynchronously via `error`; without a listener that is an uncaught exception in pi.
158
157
  // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees.
159
- child.on("error", () => {});
158
+ child.on("error", () => undefined);
160
159
 
161
160
  child.unref();
162
161
  return child;
@@ -1,5 +1,6 @@
1
1
  import { createConnection, type Socket } from "node:net";
2
- import { createInterface } from "node:readline";
2
+ import { readLines } from "./lines";
3
+ import { destroyQuietly } from "./socket";
3
4
  import { type Result, err, ok } from "../util/result";
4
5
  import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors";
5
6
  import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types";
@@ -13,47 +14,47 @@ import {
13
14
  serialize,
14
15
  } from "./protocol";
15
16
 
17
+ type ConnectResult = Result<void, CdpError>;
18
+
16
19
  export const createDaemonTransport = (clientId: string, socketPath: string = DAEMON_SOCKET_PATH): CdpTransport => {
17
20
  let socket: Socket | null = null;
18
- let rl: ReturnType<typeof createInterface> | null = null;
21
+ let stopReading: (() => void) | null = null;
19
22
  let queue = makeEventQueue();
20
23
  const closeListeners = new Set<() => void>();
21
24
  const pending = new Map<number, Pending>();
22
25
  let registered = false;
23
26
  let nextRequestId = 1;
27
+ // The handshake in progress, so that concurrent connect() callers share one socket instead
28
+ // of each opening their own and the later one orphaning the earlier.
29
+ let inFlight: Promise<ConnectResult> | null = null;
30
+
31
+ // Tears down `sock` only if it is still the transport's socket: a socket from a superseded
32
+ // attempt is just destroyed, without touching the state that belongs to the current one.
33
+ const cleanup = (sock: Socket, reason: string): void => {
34
+ if (socket !== sock) {
35
+ destroyQuietly(sock);
36
+ return;
37
+ }
24
38
 
25
- const cleanup = (reason: string): void => {
26
39
  rejectAllPending(pending, reason);
27
40
  queue.end();
28
41
  queue = makeEventQueue();
29
42
  registered = false;
30
43
 
31
- if (rl) { rl.close(); rl = null; }
32
- if (socket) { try { socket.destroy(); } catch {} socket = null; }
44
+ if (stopReading) { stopReading(); stopReading = null; }
45
+ socket = null;
46
+ destroyQuietly(sock);
33
47
 
34
48
  for (const cb of closeListeners) cb();
35
49
  };
36
50
 
37
- const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
38
- const timeoutMs = opts?.timeoutMs ?? 10_000;
39
-
40
- if (socket && !socket.destroyed && registered) {
41
- return Promise.resolve(ok(undefined));
42
- }
43
-
44
- return new Promise((resolve) => {
45
- let settled = false;
46
- const settle = (r: Result<void, CdpError>): void => {
47
- if (settled) return;
48
- settled = true;
49
- resolve(r);
50
- };
51
-
51
+ const handshake = (timeoutMs: number): Promise<ConnectResult> =>
52
+ new Promise((resolve) => {
52
53
  let sock: Socket;
53
54
  try {
54
55
  sock = createConnection(socketPath);
55
56
  } catch (e) {
56
- settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
57
+ resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
57
58
  return;
58
59
  }
59
60
  socket = sock;
@@ -63,27 +64,35 @@ export const createDaemonTransport = (clientId: string, socketPath: string = DAE
63
64
  // caller is awaiting can be dropped by this.
64
65
  sock.unref();
65
66
 
67
+ // The timer is the one ref'd handle for the whole handshake: with the socket unref'd, nothing
68
+ // else stops the process exiting between TCP connect and the daemon's "registered" reply.
69
+ // It is cleared only in settle(), so a daemon that accepts but never registers times out.
70
+ let settled = false;
66
71
  const connectTimer = setTimeout(() => {
67
- cleanup("Connection timeout");
72
+ cleanup(sock, "Connection timeout");
68
73
  settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
69
74
  }, timeoutMs);
70
-
71
- sock.on("connect", () => {
75
+ const settle = (r: ConnectResult): void => {
76
+ if (settled) return;
77
+ settled = true;
72
78
  clearTimeout(connectTimer);
73
- rl = createInterface({ input: sock, crlfDelay: Infinity });
79
+ resolve(r);
80
+ };
74
81
 
75
- rl.on("line", (line: string) => {
82
+ sock.on("connect", () => {
83
+ stopReading = readLines(sock, (line) => {
84
+ if (socket !== sock) return;
76
85
  const msg = deserialize(line);
77
86
  if (!msg) return;
78
87
 
79
- if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) {
80
- registered = true;
81
- settle(ok(undefined));
82
- return;
83
- }
84
-
85
- if (msg.type === "control" && msg.action === "shutdown") {
86
- cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`);
88
+ if (msg.type === "control") {
89
+ if (msg.action === "registered" && msg.clientId === clientId) {
90
+ registered = true;
91
+ settle(ok(undefined));
92
+ } else if (msg.action === "shutdown") {
93
+ cleanup(sock, `Daemon shutting down: ${msg.reason ?? "unknown"}`);
94
+ settle(err(cdpError("transport_closed", `Daemon refused the connection: ${msg.reason ?? "unknown"}`)));
95
+ }
87
96
  return;
88
97
  }
89
98
 
@@ -109,38 +118,60 @@ export const createDaemonTransport = (clientId: string, socketPath: string = DAE
109
118
  params: msg.params,
110
119
  ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
111
120
  });
112
- return;
113
121
  }
114
122
  });
115
123
 
116
- sock.on("error", () => {});
117
-
118
- sock.on("close", () => {
119
- clearTimeout(connectTimer);
120
- cleanup("Daemon socket closed");
121
- settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
122
- });
123
-
124
124
  const regMsg: WireControl = { type: "control", action: "register", clientId };
125
125
  sock.write(serialize(regMsg) + "\n");
126
126
  });
127
127
 
128
+ // Fires before "close" on any failure, so the message it carries wins over the generic one.
129
+ // Tearing down here too means state() reads "closed" as soon as the caller sees the error.
128
130
  sock.on("error", (e: NodeJS.ErrnoException) => {
129
- clearTimeout(connectTimer);
130
131
  const msg = e.code === "ENOENT"
131
132
  ? "Daemon not running — socket not found"
132
133
  : e.message;
134
+ cleanup(sock, msg);
133
135
  settle(err(cdpError("transport_closed", msg)));
134
136
  });
137
+
138
+ sock.on("close", () => {
139
+ cleanup(sock, "Daemon socket closed");
140
+ settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
141
+ });
135
142
  });
143
+
144
+ // `_url` is part of CdpTransport but meaningless here: the daemon discovers and owns the
145
+ // Chrome endpoint itself, and this transport always dials the daemon's own socket path.
146
+ const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<ConnectResult> => {
147
+ const timeoutMs = opts?.timeoutMs ?? 10_000;
148
+
149
+ if (socket && !socket.destroyed && registered) {
150
+ return Promise.resolve(ok(undefined));
151
+ }
152
+ if (inFlight) return inFlight;
153
+
154
+ const attempt = handshake(timeoutMs).then((r) => {
155
+ if (inFlight === attempt) inFlight = null;
156
+ return r;
157
+ });
158
+ inFlight = attempt;
159
+ return attempt;
136
160
  };
137
161
 
138
- const close = async (): Promise<void> => {
139
- if (registered && socket && !socket.destroyed) {
162
+ const close = (): Promise<void> => {
163
+ const sock = socket;
164
+ if (!sock) return Promise.resolve();
165
+ if (registered && !sock.destroyed) {
140
166
  const dereg: WireControl = { type: "control", action: "deregister", clientId };
141
- try { socket.write(serialize(dereg) + "\n"); } catch {}
167
+ try {
168
+ sock.write(serialize(dereg) + "\n");
169
+ } catch {
170
+ // The daemon is already gone; cleanup below drops the socket regardless.
171
+ }
142
172
  }
143
- cleanup("close() called");
173
+ cleanup(sock, "close() called");
174
+ return Promise.resolve();
144
175
  };
145
176
 
146
177
  const request = (
@@ -162,7 +193,7 @@ export const createDaemonTransport = (clientId: string, socketPath: string = DAE
162
193
  id,
163
194
  method,
164
195
  params,
165
- ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}),
196
+ ...(sessionId !== null ? { sessionId } : {}),
166
197
  };
167
198
 
168
199
  return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
@@ -2,6 +2,8 @@ import type { AxNodeResult } from "../cdp/commands";
2
2
  import type { BrowserClient } from "../client";
3
3
  import type { Box } from "./box";
4
4
  import { cdpCall } from "./cdp-call";
5
+ import { elementFunctionSource } from "./page/inject";
6
+ import { liveValue as liveValueInPage } from "./page/live-value.page";
5
7
 
6
8
  export type RawAxNode = AxNodeResult;
7
9
  type AxValue = NonNullable<RawAxNode["role"]>;
@@ -138,8 +140,9 @@ export const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
138
140
 
139
141
  export const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
140
142
  nodes.map((n) => {
141
- const { _backendId: _bid, ...rest } = n;
142
- return { ...rest, children: stripInternals(n.children) };
143
+ const stripped: SlimNode = { ...n, children: stripInternals(n.children) };
144
+ delete stripped._backendId;
145
+ return stripped;
143
146
  });
144
147
 
145
148
  export const collectInteractiveTargets = (
@@ -158,19 +161,16 @@ export const collectInteractiveTargets = (
158
161
  return out;
159
162
  };
160
163
 
164
+ const LIVE_VALUE_FN = elementFunctionSource(liveValueInPage);
165
+
161
166
  export const liveValue = async (client: BrowserClient, backendId: number): Promise<string | undefined> => {
162
167
  const resolved = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId });
163
168
  if (!resolved.success) return undefined;
164
169
  const objectId = resolved.data.object.objectId;
165
170
  if (!objectId) return undefined;
166
- const fn = `function () {
167
- if (this.type === "checkbox" || this.type === "radio") return this.checked ? "checked" : "unchecked";
168
- if (typeof this.value === "string") return this.value;
169
- return null;
170
- }`;
171
171
  const r = await cdpCall(client, "Runtime.callFunctionOn", {
172
172
  objectId,
173
- functionDeclaration: fn,
173
+ functionDeclaration: LIVE_VALUE_FN,
174
174
  returnByValue: true,
175
175
  });
176
176
  if (!r.success) return undefined;