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.
- package/CHANGELOG.md +36 -0
- package/README.md +86 -0
- package/package.json +12 -6
- package/skills/pi-browser-harness/SKILL.md +57 -2
- package/src/cdp/commands.ts +200 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- package/src/cdp/events.ts +8 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +320 -217
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +76 -43
- package/src/daemon/bridge.ts +110 -349
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +65 -44
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/serp.page.ts +39 -0
- package/src/domains/page.ts +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -33
- package/src/domains/snapshot.ts +71 -73
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +90 -33
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +32 -7
- package/src/registry.ts +65 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +3 -2
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +163 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/daemon/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer, type Server, type Socket } from "node:net";
|
|
2
|
-
import {
|
|
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
|
|
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()
|
|
26
|
-
stop()
|
|
27
|
-
onMessage(handler: MessageHandler)
|
|
28
|
-
onConnect(handler: ConnectionHandler)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
unlinkSync(DAEMON_SOCKET_PATH);
|
|
48
|
-
} catch {}
|
|
84
|
+
unlinkSocketFile();
|
|
49
85
|
|
|
50
86
|
server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
|
|
51
|
-
if (clients.size >=
|
|
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
|
|
58
|
-
|
|
93
|
+
const stopReading = readLines(socket, (line) => { onLine(line); });
|
|
94
|
+
const client: ClientSocket = { id: tempId, socket, stopReading, registered: false };
|
|
59
95
|
|
|
60
|
-
|
|
96
|
+
anonymous.set(tempId, client);
|
|
61
97
|
|
|
62
|
-
|
|
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"
|
|
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
|
-
|
|
77
|
-
socket.
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
clients.set(clientId,
|
|
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
|
-
|
|
126
|
+
for (const h of connectHandlers) h(client);
|
|
91
127
|
return;
|
|
92
128
|
}
|
|
93
129
|
|
|
94
|
-
if (!
|
|
130
|
+
if (!client.registered) return;
|
|
95
131
|
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
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
|
-
|
|
165
|
+
client.registered = false;
|
|
166
|
+
destroyQuietly(client.socket);
|
|
127
167
|
}
|
|
128
|
-
for (const [, client] of
|
|
129
|
-
|
|
168
|
+
for (const [, client] of anonymous) {
|
|
169
|
+
destroyQuietly(client.socket);
|
|
130
170
|
}
|
|
131
171
|
clients.clear();
|
|
132
|
-
|
|
172
|
+
anonymous.clear();
|
|
133
173
|
|
|
134
174
|
if (server) {
|
|
135
175
|
server.close(() => {
|
|
136
|
-
|
|
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(
|
|
156
|
-
|
|
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: ()
|
|
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
|
+
};
|
package/src/daemon/spawn.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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, "
|
|
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;
|
package/src/daemon/transport.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createConnection, type Socket } from "node:net";
|
|
2
|
-
import {
|
|
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
|
|
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 (
|
|
32
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
const settle = (r: ConnectResult): void => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
72
78
|
clearTimeout(connectTimer);
|
|
73
|
-
|
|
79
|
+
resolve(r);
|
|
80
|
+
};
|
|
74
81
|
|
|
75
|
-
|
|
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"
|
|
80
|
-
registered
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 =
|
|
139
|
-
|
|
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 {
|
|
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
|
|
196
|
+
...(sessionId !== null ? { sessionId } : {}),
|
|
166
197
|
};
|
|
167
198
|
|
|
168
199
|
return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
|
package/src/domains/ax-tree.ts
CHANGED
|
@@ -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
|
|
142
|
-
|
|
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:
|
|
173
|
+
functionDeclaration: LIVE_VALUE_FN,
|
|
174
174
|
returnByValue: true,
|
|
175
175
|
});
|
|
176
176
|
if (!r.success) return undefined;
|