pi-browser-harness 0.11.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 +62 -0
- package/README.md +102 -1
- package/package.json +13 -7
- package/skills/pi-browser-harness/SKILL.md +84 -0
- package/src/cdp/commands.ts +204 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +72 -37
- 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 +115 -191
- 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 +84 -27
- package/src/daemon/transport.ts +88 -52
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -7
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -66
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +64 -11
- package/src/domains/form-batch.ts +13 -11
- package/src/domains/form.ts +79 -116
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +7 -7
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +68 -53
- package/src/domains/keyboard.ts +16 -18
- package/src/domains/navigate.ts +102 -69
- package/src/domains/network.ts +56 -98
- 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 +19 -58
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -54
- 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 -14
- package/src/domains/scroll.ts +4 -5
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -34
- package/src/domains/setup.ts +0 -2
- package/src/domains/snapshot.ts +72 -77
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +19 -78
- package/src/domains/target.ts +1 -1
- package/src/domains/viewport.ts +0 -1
- package/src/index.ts +100 -39
- 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 +42 -117
- package/src/registry.ts +68 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +33 -2
- 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 +12 -1
- 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
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { accessSync } from "node:fs";
|
|
2
3
|
import { access, unlink } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import { createConnection } from "node:net";
|
|
7
|
-
import {
|
|
8
|
+
import { readLines } from "./lines";
|
|
9
|
+
import { DAEMON_SOCKET_PATH, deserialize, serialize, type WireControl } from "./protocol";
|
|
10
|
+
import { destroyQuietly } from "./socket";
|
|
8
11
|
|
|
9
12
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
10
13
|
|
|
14
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
15
|
+
|
|
11
16
|
const isWindows = process.platform === "win32";
|
|
12
17
|
|
|
13
18
|
// No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
|
|
14
19
|
const cleanupStaleSocket = (): void => {
|
|
15
20
|
if (isWindows) return;
|
|
16
|
-
|
|
21
|
+
// ENOENT is the normal case; anything else surfaces when listen() fails.
|
|
22
|
+
unlink(DAEMON_SOCKET_PATH).catch(() => undefined);
|
|
17
23
|
};
|
|
18
24
|
|
|
19
25
|
export const daemonSocketMayExist = async (
|
|
@@ -42,7 +48,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
42
48
|
|
|
43
49
|
const sock = createConnection(DAEMON_SOCKET_PATH);
|
|
44
50
|
const timer = setTimeout(() => {
|
|
45
|
-
sock
|
|
51
|
+
destroyQuietly(sock);
|
|
46
52
|
cleanupStaleSocket();
|
|
47
53
|
done(false);
|
|
48
54
|
}, timeoutMs);
|
|
@@ -52,20 +58,20 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
52
58
|
sock.write(serialize(regMsg) + "\n");
|
|
53
59
|
});
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
64
70
|
});
|
|
65
71
|
|
|
66
72
|
sock.on("error", () => {
|
|
67
73
|
clearTimeout(timer);
|
|
68
|
-
sock
|
|
74
|
+
destroyQuietly(sock);
|
|
69
75
|
cleanupStaleSocket();
|
|
70
76
|
done(false);
|
|
71
77
|
});
|
|
@@ -78,28 +84,79 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
78
84
|
});
|
|
79
85
|
};
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
// Walks upward like Node's own node_modules lookup. Exported with an injectable start so the
|
|
88
|
+
// hoisted layout pi uses can be exercised against a fixture instead of this checkout.
|
|
89
|
+
export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => {
|
|
90
|
+
for (let dir = startDir; ; dir = dirname(dir)) {
|
|
91
|
+
const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
|
|
92
|
+
try {
|
|
93
|
+
accessSync(candidate);
|
|
94
|
+
return candidate;
|
|
95
|
+
} catch {
|
|
96
|
+
// Keep walking toward the filesystem root. This mirrors Node's node_modules lookup
|
|
97
|
+
// without relying on createRequire().resolve(), which is broken for external modules
|
|
98
|
+
// loaded through jiti/static inside a Bun-compiled executable such as pi.exe.
|
|
99
|
+
}
|
|
100
|
+
// dirname() of the filesystem root is the root itself.
|
|
101
|
+
if (dirname(dir) === dir) return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const resolveTsxCli = (): string | null => {
|
|
106
|
+
try {
|
|
107
|
+
const resolved = requireFromHere.resolve("tsx/cli");
|
|
108
|
+
// A compiled runtime may theoretically return a virtual path that a separately spawned
|
|
109
|
+
// Node process cannot read. Only accept a resolver result that exists on the host FS.
|
|
110
|
+
accessSync(resolved);
|
|
111
|
+
return resolved;
|
|
112
|
+
} catch {
|
|
113
|
+
return resolveTsxCliFromFilesystem();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
83
116
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
export const daemonNodeExecutable = (
|
|
118
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
119
|
+
versions: Readonly<Record<string, string | undefined>> = process.versions,
|
|
120
|
+
platform: NodeJS.Platform = process.platform,
|
|
121
|
+
execPath: string = process.execPath,
|
|
122
|
+
): string => {
|
|
123
|
+
const override = env["PI_BROWSER_NODE"];
|
|
124
|
+
if (override) return override;
|
|
88
125
|
|
|
89
|
-
|
|
90
|
-
|
|
126
|
+
// Under normal Node keep the exact executable that launched pi/the extension. Under Bun,
|
|
127
|
+
// process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime.
|
|
128
|
+
if ("bun" in versions) return platform === "win32" ? "node.exe" : "node";
|
|
91
129
|
|
|
92
|
-
|
|
93
|
-
|
|
130
|
+
return execPath;
|
|
131
|
+
};
|
|
94
132
|
|
|
95
|
-
|
|
133
|
+
export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
|
|
134
|
+
const daemonScript = join(moduleDir, "main.ts");
|
|
135
|
+
const tsxCli = resolveTsxCli();
|
|
136
|
+
if (!tsxCli) return null;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
command: daemonNodeExecutable(),
|
|
140
|
+
args: [tsxCli, daemonScript],
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const spawnDaemon = (): ChildProcess | null => {
|
|
145
|
+
const spec = daemonSpawnCommand();
|
|
146
|
+
if (!spec) return null;
|
|
147
|
+
|
|
148
|
+
const child = spawn(spec.command, [...spec.args], {
|
|
96
149
|
detached: true,
|
|
97
150
|
stdio: "ignore",
|
|
98
151
|
env: { ...process.env },
|
|
99
|
-
|
|
100
|
-
windowsVerbatimArguments: isWindows,
|
|
152
|
+
windowsHide: true,
|
|
101
153
|
});
|
|
102
154
|
|
|
155
|
+
// `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host)
|
|
156
|
+
// asynchronously via `error`; without a listener that is an uncaught exception in pi.
|
|
157
|
+
// ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees.
|
|
158
|
+
child.on("error", () => undefined);
|
|
159
|
+
|
|
103
160
|
child.unref();
|
|
104
161
|
return child;
|
|
105
162
|
};
|
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,72 +14,85 @@ import {
|
|
|
13
14
|
serialize,
|
|
14
15
|
} from "./protocol";
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
type ConnectResult = Result<void, CdpError>;
|
|
18
|
+
|
|
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
|
-
sock = createConnection(
|
|
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;
|
|
60
|
-
|
|
61
|
+
// The client is kept connected across sessions on purpose (index.ts session_shutdown), so this
|
|
62
|
+
// socket must not be what keeps the process alive: in `pi -p` it was the last handle standing
|
|
63
|
+
// and pi never exited (#39). In-flight requests hold their own ref'd timers, so nothing a
|
|
64
|
+
// caller is awaiting can be dropped by this.
|
|
65
|
+
sock.unref();
|
|
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;
|
|
61
71
|
const connectTimer = setTimeout(() => {
|
|
62
|
-
cleanup("Connection timeout");
|
|
72
|
+
cleanup(sock, "Connection timeout");
|
|
63
73
|
settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`)));
|
|
64
74
|
}, timeoutMs);
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
const settle = (r: ConnectResult): void => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
67
78
|
clearTimeout(connectTimer);
|
|
68
|
-
|
|
79
|
+
resolve(r);
|
|
80
|
+
};
|
|
69
81
|
|
|
70
|
-
|
|
82
|
+
sock.on("connect", () => {
|
|
83
|
+
stopReading = readLines(sock, (line) => {
|
|
84
|
+
if (socket !== sock) return;
|
|
71
85
|
const msg = deserialize(line);
|
|
72
86
|
if (!msg) return;
|
|
73
87
|
|
|
74
|
-
if (msg.type === "control"
|
|
75
|
-
registered
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
82
96
|
return;
|
|
83
97
|
}
|
|
84
98
|
|
|
@@ -104,38 +118,60 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
|
104
118
|
params: msg.params,
|
|
105
119
|
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
106
120
|
});
|
|
107
|
-
return;
|
|
108
121
|
}
|
|
109
122
|
});
|
|
110
123
|
|
|
111
|
-
sock.on("error", () => {});
|
|
112
|
-
|
|
113
|
-
sock.on("close", () => {
|
|
114
|
-
clearTimeout(connectTimer);
|
|
115
|
-
cleanup("Daemon socket closed");
|
|
116
|
-
settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
|
|
117
|
-
});
|
|
118
|
-
|
|
119
124
|
const regMsg: WireControl = { type: "control", action: "register", clientId };
|
|
120
125
|
sock.write(serialize(regMsg) + "\n");
|
|
121
126
|
});
|
|
122
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.
|
|
123
130
|
sock.on("error", (e: NodeJS.ErrnoException) => {
|
|
124
|
-
clearTimeout(connectTimer);
|
|
125
131
|
const msg = e.code === "ENOENT"
|
|
126
132
|
? "Daemon not running — socket not found"
|
|
127
133
|
: e.message;
|
|
134
|
+
cleanup(sock, msg);
|
|
128
135
|
settle(err(cdpError("transport_closed", msg)));
|
|
129
136
|
});
|
|
137
|
+
|
|
138
|
+
sock.on("close", () => {
|
|
139
|
+
cleanup(sock, "Daemon socket closed");
|
|
140
|
+
settle(err(cdpError("transport_closed", "Daemon socket closed before registration")));
|
|
141
|
+
});
|
|
130
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;
|
|
131
160
|
};
|
|
132
161
|
|
|
133
|
-
const close =
|
|
134
|
-
|
|
162
|
+
const close = (): Promise<void> => {
|
|
163
|
+
const sock = socket;
|
|
164
|
+
if (!sock) return Promise.resolve();
|
|
165
|
+
if (registered && !sock.destroyed) {
|
|
135
166
|
const dereg: WireControl = { type: "control", action: "deregister", clientId };
|
|
136
|
-
try {
|
|
167
|
+
try {
|
|
168
|
+
sock.write(serialize(dereg) + "\n");
|
|
169
|
+
} catch {
|
|
170
|
+
// The daemon is already gone; cleanup below drops the socket regardless.
|
|
171
|
+
}
|
|
137
172
|
}
|
|
138
|
-
cleanup("close() called");
|
|
173
|
+
cleanup(sock, "close() called");
|
|
174
|
+
return Promise.resolve();
|
|
139
175
|
};
|
|
140
176
|
|
|
141
177
|
const request = (
|
|
@@ -157,7 +193,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
|
|
|
157
193
|
id,
|
|
158
194
|
method,
|
|
159
195
|
params,
|
|
160
|
-
...(sessionId !== null
|
|
196
|
+
...(sessionId !== null ? { sessionId } : {}),
|
|
161
197
|
};
|
|
162
198
|
|
|
163
199
|
return sendWithTimeout(pending, id, method, timeoutMs, "Daemon", () => sock.write(serialize(req) + "\n"));
|