pi-browser-harness 0.10.2 → 0.11.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 +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/daemon/bridge.ts
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing.
|
|
3
|
-
*
|
|
4
|
-
* A single long-lived process connection to Chrome that proxies CDP traffic
|
|
5
|
-
* for multiple pi clients connected via the IpcServer. One Allow dialog ever.
|
|
6
|
-
*
|
|
7
|
-
* References:
|
|
8
|
-
* - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
|
|
9
|
-
* - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions
|
|
10
|
-
* - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
1
|
import WebSocket from "ws";
|
|
14
2
|
import { discoverWsUrl } from "../cdp/discovery";
|
|
3
|
+
import { isCdpRawMessage } from "../cdp/types";
|
|
15
4
|
import type { CdpRawMessage } from "../cdp/types";
|
|
16
5
|
import type { WireRequest, WireResponse, WireEvent } from "./protocol";
|
|
17
6
|
import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
|
|
18
|
-
|
|
19
|
-
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
7
|
+
import { asString, isRecord } from "../util/guards";
|
|
20
8
|
|
|
21
9
|
export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
|
|
22
10
|
|
|
@@ -25,146 +13,147 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void
|
|
|
25
13
|
export type CloseHandler = () => void;
|
|
26
14
|
|
|
27
15
|
export type CdpBridge = {
|
|
28
|
-
/** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop
|
|
29
|
-
* are initialized (not when Chrome is connected — that may take retries). */
|
|
30
16
|
start(): Promise<void>;
|
|
31
17
|
stop(): Promise<void>;
|
|
32
18
|
handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
|
|
33
|
-
/** True when the Chrome WebSocket is open and ready. */
|
|
34
19
|
isAlive(): boolean;
|
|
35
|
-
recordSession(clientId: string, sessionId: string): void;
|
|
36
|
-
releaseSession(sessionId: string): void;
|
|
37
20
|
getSessionOwner(sessionId: string): string | undefined;
|
|
38
21
|
removeClient(clientId: string): void;
|
|
39
22
|
onEvent(handler: EventHandler): void;
|
|
40
23
|
onClose(handler: CloseHandler): void;
|
|
41
24
|
};
|
|
42
25
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class IdMultiplexer {
|
|
51
|
-
private nextId = 1;
|
|
52
|
-
private localToRemote = new Map<string, Map<number, number>>();
|
|
53
|
-
private pending = new Map<number, PendingEntry>();
|
|
54
|
-
|
|
55
|
-
allocate(clientId: string, localId: number): number {
|
|
56
|
-
const daemonId = this.nextId++;
|
|
57
|
-
let m = this.localToRemote.get(clientId);
|
|
58
|
-
if (!m) { m = new Map(); this.localToRemote.set(clientId, m); }
|
|
59
|
-
m.set(localId, daemonId);
|
|
60
|
-
this.pending.set(daemonId, { clientId, localId });
|
|
61
|
-
return daemonId;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
resolve(daemonId: number): PendingEntry | null {
|
|
65
|
-
const e = this.pending.get(daemonId);
|
|
66
|
-
if (!e) return null;
|
|
67
|
-
this.pending.delete(daemonId);
|
|
68
|
-
const m = this.localToRemote.get(e.clientId);
|
|
69
|
-
if (m) m.delete(e.localId);
|
|
70
|
-
return e;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
clearClient(clientId: string): void {
|
|
74
|
-
const m = this.localToRemote.get(clientId);
|
|
75
|
-
if (m) { for (const [, did] of m) this.pending.delete(did); }
|
|
76
|
-
this.localToRemote.delete(clientId);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Event Router ───────────────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
class EventRouter {
|
|
83
|
-
private owners = new Map<string, string>(); // sessionId → clientId
|
|
84
|
-
private clientSessions = new Map<string, Set<string>>(); // clientId → sessions
|
|
85
|
-
|
|
86
|
-
record(clientId: string, sessionId: string): void {
|
|
87
|
-
const prev = this.owners.get(sessionId);
|
|
88
|
-
if (prev && prev !== clientId) {
|
|
89
|
-
this.clientSessions.get(prev)?.delete(sessionId);
|
|
90
|
-
}
|
|
91
|
-
this.owners.set(sessionId, clientId);
|
|
92
|
-
let s = this.clientSessions.get(clientId);
|
|
93
|
-
if (!s) { s = new Set(); this.clientSessions.set(clientId, s); }
|
|
94
|
-
s.add(sessionId);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
release(sessionId: string): void {
|
|
98
|
-
const owner = this.owners.get(sessionId);
|
|
99
|
-
if (owner) this.clientSessions.get(owner)?.delete(sessionId);
|
|
100
|
-
this.owners.delete(sessionId);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
getOwner(sessionId: string): string | undefined {
|
|
104
|
-
return this.owners.get(sessionId);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
removeClient(clientId: string): void {
|
|
108
|
-
const sessions = this.clientSessions.get(clientId);
|
|
109
|
-
if (sessions) { for (const sid of sessions) this.owners.delete(sid); }
|
|
110
|
-
this.clientSessions.delete(clientId);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Route a CDP event to client(s).
|
|
115
|
-
* Session-scoped → only the owning client.
|
|
116
|
-
* Browser-level (no sessionId) → all connected clients.
|
|
117
|
-
*/
|
|
118
|
-
route(event: WireEvent): string[] {
|
|
119
|
-
if (event.sessionId) {
|
|
120
|
-
const owner = this.owners.get(event.sessionId);
|
|
121
|
-
return owner ? [owner] : [];
|
|
122
|
-
}
|
|
123
|
-
return [...this.clientSessions.keys()];
|
|
124
|
-
}
|
|
26
|
+
export type InFlight = {
|
|
27
|
+
readonly clientId: string;
|
|
28
|
+
readonly localId: number;
|
|
29
|
+
readonly send: SendToClient;
|
|
30
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
31
|
+
readonly isAttach: boolean;
|
|
32
|
+
};
|
|
125
33
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
34
|
+
// One map, keyed by the daemon-side id: the client that asked, the id it used, and everything needed to answer it.
|
|
35
|
+
export type IdMultiplexer = {
|
|
36
|
+
allocate(entry: Omit<InFlight, "timer">, arm: (daemonId: number) => InFlight["timer"]): number;
|
|
37
|
+
take(daemonId: number): InFlight | undefined;
|
|
38
|
+
takeAll(): ReadonlyArray<InFlight>;
|
|
39
|
+
clearClient(clientId: string): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const createIdMultiplexer = (): IdMultiplexer => {
|
|
43
|
+
let nextId = 1;
|
|
44
|
+
const inFlight = new Map<number, InFlight>();
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
allocate(entry, arm) {
|
|
48
|
+
const daemonId = nextId++;
|
|
49
|
+
inFlight.set(daemonId, { ...entry, timer: arm(daemonId) });
|
|
50
|
+
return daemonId;
|
|
51
|
+
},
|
|
52
|
+
take(daemonId) {
|
|
53
|
+
const e = inFlight.get(daemonId);
|
|
54
|
+
if (!e) return undefined;
|
|
55
|
+
inFlight.delete(daemonId);
|
|
56
|
+
clearTimeout(e.timer);
|
|
57
|
+
return e;
|
|
58
|
+
},
|
|
59
|
+
takeAll() {
|
|
60
|
+
const all = [...inFlight.values()];
|
|
61
|
+
inFlight.clear();
|
|
62
|
+
for (const e of all) clearTimeout(e.timer);
|
|
63
|
+
return all;
|
|
64
|
+
},
|
|
65
|
+
// A disconnected client must not be answered later: dropping its entries also cancels their timeouts.
|
|
66
|
+
clearClient(clientId) {
|
|
67
|
+
for (const [daemonId, e] of inFlight) {
|
|
68
|
+
if (e.clientId !== clientId) continue;
|
|
69
|
+
clearTimeout(e.timer);
|
|
70
|
+
inFlight.delete(daemonId);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type EventRouter = {
|
|
77
|
+
record(clientId: string, sessionId: string): void;
|
|
78
|
+
release(sessionId: string): void;
|
|
79
|
+
getOwner(sessionId: string): string | undefined;
|
|
80
|
+
removeClient(clientId: string): void;
|
|
81
|
+
route(event: WireEvent): string[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const createEventRouter = (): EventRouter => {
|
|
85
|
+
const owners = new Map<string, string>();
|
|
86
|
+
const clientSessions = new Map<string, Set<string>>();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
record(clientId, sessionId) {
|
|
90
|
+
const prev = owners.get(sessionId);
|
|
91
|
+
if (prev && prev !== clientId) clientSessions.get(prev)?.delete(sessionId);
|
|
92
|
+
owners.set(sessionId, clientId);
|
|
93
|
+
let s = clientSessions.get(clientId);
|
|
94
|
+
if (!s) { s = new Set(); clientSessions.set(clientId, s); }
|
|
95
|
+
s.add(sessionId);
|
|
96
|
+
},
|
|
97
|
+
release(sessionId) {
|
|
98
|
+
const owner = owners.get(sessionId);
|
|
99
|
+
if (owner) clientSessions.get(owner)?.delete(sessionId);
|
|
100
|
+
owners.delete(sessionId);
|
|
101
|
+
},
|
|
102
|
+
getOwner(sessionId) {
|
|
103
|
+
return owners.get(sessionId);
|
|
104
|
+
},
|
|
105
|
+
removeClient(clientId) {
|
|
106
|
+
const sessions = clientSessions.get(clientId);
|
|
107
|
+
if (sessions) { for (const sid of sessions) owners.delete(sid); }
|
|
108
|
+
clientSessions.delete(clientId);
|
|
109
|
+
},
|
|
110
|
+
route(event) {
|
|
111
|
+
if (event.sessionId) {
|
|
112
|
+
const owner = owners.get(event.sessionId);
|
|
113
|
+
return owner ? [owner] : [];
|
|
114
|
+
}
|
|
115
|
+
return [...clientSessions.keys()];
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
};
|
|
130
119
|
|
|
131
|
-
|
|
120
|
+
const CONNECT_WAIT_MS = 15_000;
|
|
132
121
|
|
|
133
122
|
export const createCdpBridge = (): CdpBridge => {
|
|
134
123
|
let ws: WebSocket | null = null;
|
|
135
124
|
let wsUrl: string | null = null;
|
|
136
|
-
const mux =
|
|
137
|
-
const router =
|
|
125
|
+
const mux = createIdMultiplexer();
|
|
126
|
+
const router = createEventRouter();
|
|
138
127
|
let eventHandler: EventHandler | null = null;
|
|
139
128
|
let closeHandler: CloseHandler | null = null;
|
|
140
129
|
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
localId: number;
|
|
146
|
-
timer: ReturnType<typeof setTimeout>;
|
|
147
|
-
isAttach: boolean;
|
|
148
|
-
}>();
|
|
130
|
+
const connectedWaiters: Array<() => void> = [];
|
|
131
|
+
const signalConnected = (): void => {
|
|
132
|
+
for (const w of connectedWaiters.splice(0)) w();
|
|
133
|
+
};
|
|
149
134
|
|
|
150
|
-
|
|
135
|
+
const waitForConnection = (timeoutMs: number): Promise<void> =>
|
|
136
|
+
new Promise((resolve) => {
|
|
137
|
+
let settled = false;
|
|
138
|
+
const finish = (): void => {
|
|
139
|
+
if (settled) return;
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
resolve();
|
|
143
|
+
};
|
|
144
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
145
|
+
connectedWaiters.push(finish);
|
|
146
|
+
});
|
|
151
147
|
|
|
152
148
|
const onChromeMessage = (raw: string): void => {
|
|
153
|
-
let
|
|
154
|
-
try {
|
|
149
|
+
let parsed: unknown;
|
|
150
|
+
try { parsed = JSON.parse(raw); } catch { return; }
|
|
151
|
+
if (!isCdpRawMessage(parsed)) return;
|
|
152
|
+
const msg: CdpRawMessage = parsed;
|
|
155
153
|
|
|
156
|
-
// ---- Response (has id) ----
|
|
157
154
|
if (msg.id !== undefined) {
|
|
158
|
-
const cb =
|
|
159
|
-
if (!cb)
|
|
160
|
-
// Stale response — client already timed out or disconnected.
|
|
161
|
-
// Still clean up the multiplexer mapping.
|
|
162
|
-
mux.resolve(msg.id);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
clearTimeout(cb.timer);
|
|
166
|
-
callbacks.delete(msg.id);
|
|
167
|
-
|
|
155
|
+
const cb = mux.take(msg.id);
|
|
156
|
+
if (!cb) return;
|
|
168
157
|
const localId = cb.localId;
|
|
169
158
|
|
|
170
159
|
if (msg.error) {
|
|
@@ -174,24 +163,19 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
174
163
|
error: { code: msg.error.code ?? -1, message: msg.error.message },
|
|
175
164
|
});
|
|
176
165
|
} else {
|
|
177
|
-
// Track session ownership for attach responses
|
|
178
166
|
if (cb.isAttach && msg.result) {
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
router.record(cb.clientId,
|
|
167
|
+
const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
|
|
168
|
+
if (sessionId) {
|
|
169
|
+
router.record(cb.clientId, sessionId);
|
|
182
170
|
}
|
|
183
171
|
}
|
|
184
172
|
cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
|
|
185
173
|
}
|
|
186
|
-
|
|
187
|
-
mux.resolve(msg.id);
|
|
188
174
|
return;
|
|
189
175
|
}
|
|
190
176
|
|
|
191
|
-
// ---- Event (no id, has method) ----
|
|
192
177
|
if (!msg.method) return;
|
|
193
178
|
|
|
194
|
-
// Inspector.detached — release session ownership
|
|
195
179
|
if (msg.method === "Inspector.detached" && msg.sessionId) {
|
|
196
180
|
router.release(msg.sessionId);
|
|
197
181
|
}
|
|
@@ -203,7 +187,6 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
203
187
|
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
204
188
|
};
|
|
205
189
|
|
|
206
|
-
// Route through the event handler for the IpcServer to broadcast
|
|
207
190
|
if (eventHandler) {
|
|
208
191
|
const targets = router.route(wireEvent);
|
|
209
192
|
if (targets.length > 0) {
|
|
@@ -212,35 +195,26 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
212
195
|
}
|
|
213
196
|
};
|
|
214
197
|
|
|
215
|
-
// ── Connect to Chrome ───────────────────────────────────────────────────
|
|
216
|
-
|
|
217
|
-
// ── Auto-reconnecting Chrome connection ───────────────────────────────
|
|
218
|
-
|
|
219
198
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
220
199
|
let reconnectAttempt = 0;
|
|
221
200
|
let stopped = false;
|
|
222
201
|
|
|
223
|
-
/**
|
|
224
|
-
* Try to discover Chrome and open a WebSocket. On failure, schedule
|
|
225
|
-
* an exponential backoff retry (capped at 60s). When Chrome finally
|
|
226
|
-
* becomes available, the bridge connects automatically — no manual
|
|
227
|
-
* daemon restart needed.
|
|
228
|
-
*/
|
|
229
202
|
const tryConnect = (): void => {
|
|
230
203
|
if (stopped) return;
|
|
231
204
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
232
205
|
|
|
233
206
|
const attempt = async (): Promise<void> => {
|
|
234
|
-
// Already connected — nothing to do.
|
|
235
207
|
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
236
208
|
|
|
237
|
-
|
|
238
|
-
let url
|
|
239
|
-
if (
|
|
209
|
+
const cachedUrl = wsUrl;
|
|
210
|
+
let url: string;
|
|
211
|
+
if (cachedUrl === null || cachedUrl === "" || reconnectAttempt > 0) {
|
|
240
212
|
const d = await discoverWsUrl();
|
|
241
213
|
if (!d.success) { scheduleRetry(); return; }
|
|
242
214
|
url = d.data;
|
|
243
215
|
wsUrl = url;
|
|
216
|
+
} else {
|
|
217
|
+
url = cachedUrl;
|
|
244
218
|
}
|
|
245
219
|
|
|
246
220
|
const settledPromise = new Promise<void>((settle) => {
|
|
@@ -253,7 +227,7 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
253
227
|
|
|
254
228
|
let sock: WebSocket;
|
|
255
229
|
try {
|
|
256
|
-
sock = new WebSocket(url
|
|
230
|
+
sock = new WebSocket(url, { perMessageDeflate: false });
|
|
257
231
|
} catch {
|
|
258
232
|
scheduleRetry();
|
|
259
233
|
return;
|
|
@@ -268,9 +242,9 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
268
242
|
clearTimeout(timer);
|
|
269
243
|
ws = sock;
|
|
270
244
|
reconnectAttempt = 0;
|
|
271
|
-
// Enable target discovery so we see page events
|
|
272
245
|
ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
|
|
273
246
|
console.log("[pi-browser-daemon] Connected to Chrome ✓");
|
|
247
|
+
signalConnected();
|
|
274
248
|
settleOnce();
|
|
275
249
|
});
|
|
276
250
|
|
|
@@ -286,25 +260,19 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
286
260
|
sock.on("close", () => {
|
|
287
261
|
clearTimeout(timer);
|
|
288
262
|
ws = null;
|
|
289
|
-
|
|
290
|
-
for (const [daemonId, cb] of callbacks) {
|
|
291
|
-
clearTimeout(cb.timer);
|
|
263
|
+
for (const cb of mux.takeAll()) {
|
|
292
264
|
cb.send(cb.clientId, {
|
|
293
265
|
type: "response",
|
|
294
266
|
id: cb.localId,
|
|
295
267
|
error: { code: -32000, message: "Chrome disconnected" },
|
|
296
268
|
});
|
|
297
|
-
callbacks.delete(daemonId);
|
|
298
|
-
mux.resolve(daemonId);
|
|
299
269
|
}
|
|
300
270
|
closeHandler?.();
|
|
301
|
-
// Auto-reconnect on unexpected close
|
|
302
271
|
scheduleRetry();
|
|
303
272
|
});
|
|
304
273
|
});
|
|
305
274
|
|
|
306
275
|
await settledPromise;
|
|
307
|
-
// If connection didn't succeed, schedule a retry
|
|
308
276
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
309
277
|
scheduleRetry();
|
|
310
278
|
}
|
|
@@ -313,28 +281,24 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
313
281
|
attempt().catch(() => scheduleRetry());
|
|
314
282
|
};
|
|
315
283
|
|
|
316
|
-
/** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */
|
|
317
284
|
const scheduleRetry = (): void => {
|
|
318
285
|
if (stopped) return;
|
|
319
286
|
const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000);
|
|
320
287
|
reconnectAttempt++;
|
|
321
|
-
wsUrl = null;
|
|
288
|
+
wsUrl = null;
|
|
322
289
|
reconnectTimer = setTimeout(tryConnect, delay);
|
|
323
290
|
};
|
|
324
291
|
|
|
325
292
|
const start = async (): Promise<void> => {
|
|
326
293
|
stopped = false;
|
|
327
|
-
// Don't await — the retry loop runs in the background.
|
|
328
|
-
// The daemon is "started" when the IPC server is listening, not when
|
|
329
|
-
// Chrome is connected.
|
|
330
294
|
tryConnect();
|
|
331
295
|
};
|
|
332
296
|
|
|
333
297
|
const stop = async (): Promise<void> => {
|
|
334
298
|
stopped = true;
|
|
335
299
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
336
|
-
|
|
337
|
-
|
|
300
|
+
mux.takeAll();
|
|
301
|
+
signalConnected();
|
|
338
302
|
if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }
|
|
339
303
|
wsUrl = null;
|
|
340
304
|
};
|
|
@@ -344,15 +308,8 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
344
308
|
clientId: string,
|
|
345
309
|
send: SendToClient,
|
|
346
310
|
): Promise<void> => {
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
350
|
-
const deadline = Date.now() + 15_000;
|
|
351
|
-
while (Date.now() < deadline) {
|
|
352
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
353
|
-
if (ws && ws.readyState === WebSocket.OPEN) break;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
311
|
+
// Every queued request awaits the same connect signal rather than each spinning its own poll loop.
|
|
312
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) await waitForConnection(CONNECT_WAIT_MS);
|
|
356
313
|
|
|
357
314
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
358
315
|
send(clientId, {
|
|
@@ -363,27 +320,21 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
363
320
|
return;
|
|
364
321
|
}
|
|
365
322
|
|
|
366
|
-
const daemonId = mux.allocate(clientId, req.id);
|
|
367
|
-
|
|
368
|
-
// Store callback for the response
|
|
369
|
-
const timer = setTimeout(() => {
|
|
370
|
-
callbacks.delete(daemonId);
|
|
371
|
-
mux.resolve(daemonId);
|
|
372
|
-
send(clientId, {
|
|
373
|
-
type: "response",
|
|
374
|
-
id: req.id,
|
|
375
|
-
error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
|
|
376
|
-
});
|
|
377
|
-
}, CDP_COMMAND_TIMEOUT_MS);
|
|
378
|
-
|
|
379
|
-
const isAttach = req.method === "Target.attachToTarget";
|
|
380
323
|
const isDetach = req.method === "Target.detachFromTarget";
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
324
|
+
if (isDetach && req.sessionId) router.release(req.sessionId);
|
|
325
|
+
|
|
326
|
+
const daemonId = mux.allocate(
|
|
327
|
+
{ clientId, localId: req.id, send, isAttach: req.method === "Target.attachToTarget" },
|
|
328
|
+
(id) =>
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
mux.take(id);
|
|
331
|
+
send(clientId, {
|
|
332
|
+
type: "response",
|
|
333
|
+
id: req.id,
|
|
334
|
+
error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
|
|
335
|
+
});
|
|
336
|
+
}, CDP_COMMAND_TIMEOUT_MS),
|
|
337
|
+
);
|
|
387
338
|
|
|
388
339
|
const payload: Record<string, unknown> = {
|
|
389
340
|
id: daemonId,
|
|
@@ -395,9 +346,7 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
395
346
|
try {
|
|
396
347
|
ws.send(JSON.stringify(payload));
|
|
397
348
|
} catch (e) {
|
|
398
|
-
|
|
399
|
-
callbacks.delete(daemonId);
|
|
400
|
-
mux.resolve(daemonId);
|
|
349
|
+
mux.take(daemonId);
|
|
401
350
|
send(clientId, {
|
|
402
351
|
type: "response",
|
|
403
352
|
id: req.id,
|
|
@@ -411,8 +360,6 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
411
360
|
stop,
|
|
412
361
|
handleRequest,
|
|
413
362
|
isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
|
|
414
|
-
recordSession: (cid, sid) => router.record(cid, sid),
|
|
415
|
-
releaseSession: (sid) => router.release(sid),
|
|
416
363
|
getSessionOwner: (sid) => router.getOwner(sid),
|
|
417
364
|
removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
|
|
418
365
|
onEvent: (h) => { eventHandler = h; },
|
package/src/daemon/index.ts
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser daemon entry point — standalone long-lived process.
|
|
3
|
-
*
|
|
4
|
-
* Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket
|
|
5
|
-
* for pi instances to connect through. One Allow dialog ever (per Chrome
|
|
6
|
-
* lifetime), zero repeated prompts.
|
|
7
|
-
*
|
|
8
|
-
* Run: npx tsx src/daemon/index.ts
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import { createIpcServer } from "./server";
|
|
12
2
|
import { createCdpBridge, type SendToClient } from "./bridge";
|
|
13
3
|
import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol";
|
|
@@ -18,9 +8,6 @@ async function main() {
|
|
|
18
8
|
const ipcServer = createIpcServer();
|
|
19
9
|
const cdpBridge = createCdpBridge();
|
|
20
10
|
|
|
21
|
-
// ── Wire IpcServer → CdpBridge ─────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
// When a pi client sends a CDP request, proxy it through the bridge to Chrome.
|
|
24
11
|
ipcServer.onMessage((msg, client) => {
|
|
25
12
|
if (msg.type !== "request") return;
|
|
26
13
|
|
|
@@ -31,19 +18,16 @@ async function main() {
|
|
|
31
18
|
cdpBridge.handleRequest(msg, client.id, send);
|
|
32
19
|
});
|
|
33
20
|
|
|
34
|
-
// When Chrome sends an event, route it to the correct pi client(s).
|
|
35
21
|
cdpBridge.onEvent((event, targetClientIds) => {
|
|
36
22
|
for (const cid of targetClientIds) {
|
|
37
23
|
ipcServer.send(cid, event);
|
|
38
24
|
}
|
|
39
25
|
});
|
|
40
26
|
|
|
41
|
-
// When a client disconnects, clean up its CDP state.
|
|
42
27
|
ipcServer.onDisconnect((client) => {
|
|
43
28
|
cdpBridge.removeClient(client.id);
|
|
44
29
|
});
|
|
45
30
|
|
|
46
|
-
// When Chrome disconnects, notify all clients.
|
|
47
31
|
cdpBridge.onClose(() => {
|
|
48
32
|
console.log("[pi-browser-daemon] Chrome disconnected");
|
|
49
33
|
ipcServer.broadcast({
|
|
@@ -53,11 +37,6 @@ async function main() {
|
|
|
53
37
|
});
|
|
54
38
|
});
|
|
55
39
|
|
|
56
|
-
// ── Idle timeout ───────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
// The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients.
|
|
59
|
-
// This prevents zombie processes when pi sessions end and never reconnect.
|
|
60
|
-
// The timeout resets whenever a client connects.
|
|
61
40
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
41
|
|
|
63
42
|
const resetIdleTimer = (): void => {
|
|
@@ -78,8 +57,6 @@ async function main() {
|
|
|
78
57
|
ipcServer.onConnect(() => { cancelIdleTimer(); });
|
|
79
58
|
ipcServer.onDisconnect(() => { resetIdleTimer(); });
|
|
80
59
|
|
|
81
|
-
// ── Start ──────────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
60
|
try {
|
|
84
61
|
await ipcServer.start();
|
|
85
62
|
console.log("[pi-browser-daemon] IPC server listening");
|
|
@@ -88,15 +65,10 @@ async function main() {
|
|
|
88
65
|
process.exit(1);
|
|
89
66
|
}
|
|
90
67
|
|
|
91
|
-
// Connect to Chrome (auto-retries with backoff in the background).
|
|
92
|
-
// The daemon is ready immediately — Chrome may connect later.
|
|
93
68
|
await cdpBridge.start();
|
|
94
69
|
|
|
95
|
-
// Start the idle timer (no clients yet at startup).
|
|
96
70
|
resetIdleTimer();
|
|
97
71
|
|
|
98
|
-
// ── Graceful shutdown ──────────────────────────────────────────────────
|
|
99
|
-
|
|
100
72
|
const shutdown = async () => {
|
|
101
73
|
console.log("[pi-browser-daemon] Shutting down...");
|
|
102
74
|
await cdpBridge.stop();
|