pi-browser-harness 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -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 +375 -265
- 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 +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- 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 -37
- package/src/domains/console.ts +48 -4
- 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 +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- 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 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- 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/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- 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/paths.ts +11 -1
- 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,12 @@
|
|
|
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
|
-
|
|
7
|
+
import { asString, isRecord } from "../util/guards";
|
|
8
|
+
import type { CdpError } from "../cdp/errors";
|
|
9
|
+
import type { Result } from "../util/result";
|
|
20
10
|
|
|
21
11
|
export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
|
|
22
12
|
|
|
@@ -25,146 +15,153 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void
|
|
|
25
15
|
export type CloseHandler = () => void;
|
|
26
16
|
|
|
27
17
|
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
18
|
start(): Promise<void>;
|
|
31
19
|
stop(): Promise<void>;
|
|
32
20
|
handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
|
|
33
|
-
/** True when the Chrome WebSocket is open and ready. */
|
|
34
21
|
isAlive(): boolean;
|
|
35
|
-
recordSession(clientId: string, sessionId: string): void;
|
|
36
|
-
releaseSession(sessionId: string): void;
|
|
37
22
|
getSessionOwner(sessionId: string): string | undefined;
|
|
38
23
|
removeClient(clientId: string): void;
|
|
39
24
|
onEvent(handler: EventHandler): void;
|
|
40
25
|
onClose(handler: CloseHandler): void;
|
|
41
26
|
};
|
|
42
27
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
28
|
+
export type InFlight = {
|
|
29
|
+
readonly clientId: string;
|
|
30
|
+
readonly localId: number;
|
|
31
|
+
readonly send: SendToClient;
|
|
32
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
33
|
+
readonly isAttach: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// One map, keyed by the daemon-side id: the client that asked, the id it used, and everything needed to answer it.
|
|
37
|
+
export type IdMultiplexer = {
|
|
38
|
+
allocate(entry: Omit<InFlight, "timer">, arm: (daemonId: number) => InFlight["timer"]): number;
|
|
39
|
+
take(daemonId: number): InFlight | undefined;
|
|
40
|
+
takeAll(): ReadonlyArray<InFlight>;
|
|
41
|
+
clearClient(clientId: string): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const createIdMultiplexer = (): IdMultiplexer => {
|
|
45
|
+
let nextId = 1;
|
|
46
|
+
const inFlight = new Map<number, InFlight>();
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
allocate(entry, arm) {
|
|
50
|
+
const daemonId = nextId++;
|
|
51
|
+
inFlight.set(daemonId, { ...entry, timer: arm(daemonId) });
|
|
52
|
+
return daemonId;
|
|
53
|
+
},
|
|
54
|
+
take(daemonId) {
|
|
55
|
+
const e = inFlight.get(daemonId);
|
|
56
|
+
if (!e) return undefined;
|
|
57
|
+
inFlight.delete(daemonId);
|
|
58
|
+
clearTimeout(e.timer);
|
|
59
|
+
return e;
|
|
60
|
+
},
|
|
61
|
+
takeAll() {
|
|
62
|
+
const all = [...inFlight.values()];
|
|
63
|
+
inFlight.clear();
|
|
64
|
+
for (const e of all) clearTimeout(e.timer);
|
|
65
|
+
return all;
|
|
66
|
+
},
|
|
67
|
+
// A disconnected client must not be answered later: dropping its entries also cancels their timeouts.
|
|
68
|
+
clearClient(clientId) {
|
|
69
|
+
for (const [daemonId, e] of inFlight) {
|
|
70
|
+
if (e.clientId !== clientId) continue;
|
|
71
|
+
clearTimeout(e.timer);
|
|
72
|
+
inFlight.delete(daemonId);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type EventRouter = {
|
|
79
|
+
record(clientId: string, sessionId: string): void;
|
|
80
|
+
release(sessionId: string): void;
|
|
81
|
+
getOwner(sessionId: string): string | undefined;
|
|
82
|
+
removeClient(clientId: string): void;
|
|
83
|
+
route(event: WireEvent): string[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const createEventRouter = (): EventRouter => {
|
|
87
|
+
const owners = new Map<string, string>();
|
|
88
|
+
const clientSessions = new Map<string, Set<string>>();
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
record(clientId, sessionId) {
|
|
92
|
+
const prev = owners.get(sessionId);
|
|
93
|
+
if (prev && prev !== clientId) clientSessions.get(prev)?.delete(sessionId);
|
|
94
|
+
owners.set(sessionId, clientId);
|
|
95
|
+
let s = clientSessions.get(clientId);
|
|
96
|
+
if (!s) { s = new Set(); clientSessions.set(clientId, s); }
|
|
97
|
+
s.add(sessionId);
|
|
98
|
+
},
|
|
99
|
+
release(sessionId) {
|
|
100
|
+
const owner = owners.get(sessionId);
|
|
101
|
+
if (owner) clientSessions.get(owner)?.delete(sessionId);
|
|
102
|
+
owners.delete(sessionId);
|
|
103
|
+
},
|
|
104
|
+
getOwner(sessionId) {
|
|
105
|
+
return owners.get(sessionId);
|
|
106
|
+
},
|
|
107
|
+
removeClient(clientId) {
|
|
108
|
+
const sessions = clientSessions.get(clientId);
|
|
109
|
+
if (sessions) { for (const sid of sessions) owners.delete(sid); }
|
|
110
|
+
clientSessions.delete(clientId);
|
|
111
|
+
},
|
|
112
|
+
route(event) {
|
|
113
|
+
if (event.sessionId) {
|
|
114
|
+
const owner = owners.get(event.sessionId);
|
|
115
|
+
return owner ? [owner] : [];
|
|
116
|
+
}
|
|
117
|
+
return [...clientSessions.keys()];
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
};
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
type CdpBridgeDependencies = {
|
|
123
|
+
discoverWsUrl?: () => Promise<Result<string, CdpError>>;
|
|
124
|
+
createWebSocket?: (url: string) => WebSocket;
|
|
125
|
+
connectTimeoutMs?: number;
|
|
126
|
+
onAttemptSettled?: () => void;
|
|
127
|
+
};
|
|
130
128
|
|
|
131
|
-
|
|
129
|
+
export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge => {
|
|
130
|
+
const {
|
|
131
|
+
discoverWsUrl: discoverWsUrlImpl = discoverWsUrl,
|
|
132
|
+
createWebSocket: createWebSocketImpl = (url: string): WebSocket =>
|
|
133
|
+
new WebSocket(url, { perMessageDeflate: false }),
|
|
134
|
+
connectTimeoutMs = CDP_CONNECT_TIMEOUT_MS,
|
|
135
|
+
onAttemptSettled,
|
|
136
|
+
} = options;
|
|
132
137
|
|
|
133
|
-
export const createCdpBridge = (): CdpBridge => {
|
|
134
138
|
let ws: WebSocket | null = null;
|
|
135
139
|
let wsUrl: string | null = null;
|
|
136
|
-
const mux =
|
|
137
|
-
const router =
|
|
140
|
+
const mux = createIdMultiplexer();
|
|
141
|
+
const router = createEventRouter();
|
|
138
142
|
let eventHandler: EventHandler | null = null;
|
|
139
143
|
let closeHandler: CloseHandler | null = null;
|
|
140
144
|
|
|
141
|
-
// daemonId
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
clientId: string;
|
|
145
|
-
localId: number;
|
|
146
|
-
timer: ReturnType<typeof setTimeout>;
|
|
147
|
-
isAttach: boolean;
|
|
148
|
-
}>();
|
|
145
|
+
// daemonId -> { generation, clientId }: lets a failed attempt reject only its own
|
|
146
|
+
// in-flight requests, and a gone client's entries be dropped without late answers.
|
|
147
|
+
const generations = new Map<number, { generation: number; clientId: string }>();
|
|
149
148
|
|
|
150
|
-
|
|
149
|
+
let stopped = false;
|
|
150
|
+
let connecting = false;
|
|
151
|
+
let attemptGeneration = 0;
|
|
152
|
+
let activeAttempt = 0;
|
|
153
|
+
let activeSocket: WebSocket | null = null;
|
|
151
154
|
|
|
152
155
|
const onChromeMessage = (raw: string): void => {
|
|
153
|
-
let
|
|
154
|
-
try {
|
|
156
|
+
let parsed: unknown;
|
|
157
|
+
try { parsed = JSON.parse(raw); } catch { return; }
|
|
158
|
+
if (!isCdpRawMessage(parsed)) return;
|
|
159
|
+
const msg: CdpRawMessage = parsed;
|
|
155
160
|
|
|
156
|
-
// ---- Response (has id) ----
|
|
157
161
|
if (msg.id !== undefined) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
162
|
+
generations.delete(msg.id);
|
|
163
|
+
const cb = mux.take(msg.id);
|
|
164
|
+
if (!cb) return;
|
|
168
165
|
const localId = cb.localId;
|
|
169
166
|
|
|
170
167
|
if (msg.error) {
|
|
@@ -174,24 +171,19 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
174
171
|
error: { code: msg.error.code ?? -1, message: msg.error.message },
|
|
175
172
|
});
|
|
176
173
|
} else {
|
|
177
|
-
// Track session ownership for attach responses
|
|
178
174
|
if (cb.isAttach && msg.result) {
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
router.record(cb.clientId,
|
|
175
|
+
const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
|
|
176
|
+
if (sessionId) {
|
|
177
|
+
router.record(cb.clientId, sessionId);
|
|
182
178
|
}
|
|
183
179
|
}
|
|
184
180
|
cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
|
|
185
181
|
}
|
|
186
|
-
|
|
187
|
-
mux.resolve(msg.id);
|
|
188
182
|
return;
|
|
189
183
|
}
|
|
190
184
|
|
|
191
|
-
// ---- Event (no id, has method) ----
|
|
192
185
|
if (!msg.method) return;
|
|
193
186
|
|
|
194
|
-
// Inspector.detached — release session ownership
|
|
195
187
|
if (msg.method === "Inspector.detached" && msg.sessionId) {
|
|
196
188
|
router.release(msg.sessionId);
|
|
197
189
|
}
|
|
@@ -203,7 +195,6 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
203
195
|
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
204
196
|
};
|
|
205
197
|
|
|
206
|
-
// Route through the event handler for the IpcServer to broadcast
|
|
207
198
|
if (eventHandler) {
|
|
208
199
|
const targets = router.route(wireEvent);
|
|
209
200
|
if (targets.length > 0) {
|
|
@@ -212,149 +203,269 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
212
203
|
}
|
|
213
204
|
};
|
|
214
205
|
|
|
215
|
-
|
|
206
|
+
const rejectCallbacksWhere = (
|
|
207
|
+
pred: (info: { generation: number; clientId: string }) => boolean,
|
|
208
|
+
): void => {
|
|
209
|
+
for (const [daemonId, info] of generations) {
|
|
210
|
+
if (!pred(info)) continue;
|
|
211
|
+
generations.delete(daemonId);
|
|
212
|
+
const cb = mux.take(daemonId);
|
|
213
|
+
if (!cb) continue;
|
|
214
|
+
cb.send(cb.clientId, {
|
|
215
|
+
type: "response",
|
|
216
|
+
id: cb.localId,
|
|
217
|
+
error: { code: -32000, message: "Chrome disconnected" },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
};
|
|
216
221
|
|
|
217
|
-
|
|
222
|
+
const rejectAttemptCallbacks = (generation: number): void => {
|
|
223
|
+
rejectCallbacksWhere((info) => info.generation === generation);
|
|
224
|
+
};
|
|
218
225
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
226
|
+
const rejectAllPendingCallbacks = (): void => {
|
|
227
|
+
rejectCallbacksWhere(() => true);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const closeSocket = (socket: WebSocket): void => {
|
|
231
|
+
try {
|
|
232
|
+
socket.close();
|
|
233
|
+
} catch {}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const isCurrentAttempt = (generation: number): boolean =>
|
|
237
|
+
generation === activeAttempt && !stopped;
|
|
238
|
+
|
|
239
|
+
const isCurrentAttemptSocket = (generation: number, socket: WebSocket): boolean =>
|
|
240
|
+
isCurrentAttempt(generation) && activeSocket === socket;
|
|
241
|
+
|
|
242
|
+
const clearActiveAttempt = (generation: number): void => {
|
|
243
|
+
if (activeAttempt === generation) {
|
|
244
|
+
activeAttempt = 0;
|
|
245
|
+
activeSocket = null;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
222
248
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
* becomes available, the bridge connects automatically — no manual
|
|
227
|
-
* daemon restart needed.
|
|
228
|
-
*/
|
|
249
|
+
// On-demand Chrome connection. Attempts happen only from start() and from
|
|
250
|
+
// handleRequest() while disconnected; a failed or disconnected bridge stays
|
|
251
|
+
// disconnected until the next explicit browser demand. No background retry loop.
|
|
229
252
|
const tryConnect = (): void => {
|
|
230
|
-
if (stopped) return;
|
|
231
|
-
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
253
|
+
if (stopped || connecting || (ws && ws.readyState === WebSocket.OPEN)) return;
|
|
232
254
|
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
255
|
+
const generation = ++attemptGeneration;
|
|
256
|
+
activeAttempt = generation;
|
|
257
|
+
connecting = true;
|
|
236
258
|
|
|
237
|
-
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
url = d.data;
|
|
243
|
-
wsUrl = url;
|
|
244
|
-
}
|
|
259
|
+
const attempt = async (): Promise<void> => {
|
|
260
|
+
let settled = false;
|
|
261
|
+
let sock: WebSocket | null = null;
|
|
262
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
263
|
+
let settleAttempt: () => void = () => {};
|
|
245
264
|
|
|
246
|
-
const settledPromise = new Promise<void>((
|
|
247
|
-
|
|
248
|
-
const settleOnce = (): void => {
|
|
265
|
+
const settledPromise = new Promise<void>((resolve) => {
|
|
266
|
+
settleAttempt = (): void => {
|
|
249
267
|
if (settled) return;
|
|
250
268
|
settled = true;
|
|
251
|
-
|
|
269
|
+
if (timeout) {
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
timeout = null;
|
|
272
|
+
}
|
|
273
|
+
if (generation === activeAttempt && !stopped) {
|
|
274
|
+
wsUrl = null;
|
|
275
|
+
connecting = false;
|
|
276
|
+
}
|
|
277
|
+
rejectAttemptCallbacks(generation);
|
|
278
|
+
onAttemptSettled?.();
|
|
279
|
+
resolve();
|
|
252
280
|
};
|
|
281
|
+
});
|
|
253
282
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
283
|
+
const clearAttemptTimeout = (): void => {
|
|
284
|
+
if (timeout) {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
timeout = null;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const failCurrentAttempt = (notifyClose: boolean): void => {
|
|
291
|
+
clearAttemptTimeout();
|
|
292
|
+
const isCurrent = isCurrentAttempt(generation);
|
|
293
|
+
if (!isCurrent) {
|
|
294
|
+
rejectAttemptCallbacks(generation);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
clearActiveAttempt(generation);
|
|
299
|
+
if (ws === sock) ws = null;
|
|
300
|
+
wsUrl = null;
|
|
301
|
+
connecting = false;
|
|
302
|
+
// Not via settleAttempt: a connection that opened already settled its attempt, so
|
|
303
|
+
// its requests would otherwise hang to the command timeout instead of failing now.
|
|
304
|
+
rejectAttemptCallbacks(generation);
|
|
305
|
+
if (notifyClose) closeHandler?.();
|
|
306
|
+
settleAttempt();
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
timeout = setTimeout(() => {
|
|
310
|
+
if (!isCurrentAttempt(generation) || stopped) {
|
|
311
|
+
failCurrentAttempt(false);
|
|
259
312
|
return;
|
|
260
313
|
}
|
|
261
314
|
|
|
262
|
-
|
|
263
|
-
sock
|
|
264
|
-
|
|
265
|
-
|
|
315
|
+
if (sock && sock.readyState !== WebSocket.CLOSED) {
|
|
316
|
+
closeSocket(sock);
|
|
317
|
+
}
|
|
318
|
+
failCurrentAttempt(true);
|
|
319
|
+
}, connectTimeoutMs);
|
|
266
320
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
321
|
+
let url = wsUrl;
|
|
322
|
+
if (!url) {
|
|
323
|
+
const d = await discoverWsUrlImpl();
|
|
324
|
+
if (!isCurrentAttempt(generation) || stopped) {
|
|
325
|
+
failCurrentAttempt(false);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!d.success) {
|
|
329
|
+
failCurrentAttempt(false);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
url = d.data;
|
|
333
|
+
}
|
|
276
334
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
335
|
+
if (!isCurrentAttempt(generation) || stopped) {
|
|
336
|
+
failCurrentAttempt(false);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
280
339
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
340
|
+
try {
|
|
341
|
+
sock = createWebSocketImpl(url);
|
|
342
|
+
} catch {
|
|
343
|
+
failCurrentAttempt(false);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!isCurrentAttempt(generation) || stopped) {
|
|
348
|
+
closeSocket(sock);
|
|
349
|
+
failCurrentAttempt(false);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
285
352
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
id: cb.localId,
|
|
295
|
-
error: { code: -32000, message: "Chrome disconnected" },
|
|
296
|
-
});
|
|
297
|
-
callbacks.delete(daemonId);
|
|
298
|
-
mux.resolve(daemonId);
|
|
353
|
+
wsUrl = url;
|
|
354
|
+
activeSocket = sock;
|
|
355
|
+
|
|
356
|
+
const onOpen = (): void => {
|
|
357
|
+
if (!sock || !isCurrentAttemptSocket(generation, sock)) {
|
|
358
|
+
clearAttemptTimeout();
|
|
359
|
+
if (sock && sock.readyState !== WebSocket.CLOSED) {
|
|
360
|
+
closeSocket(sock);
|
|
299
361
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
362
|
+
failCurrentAttempt(false);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
clearAttemptTimeout();
|
|
367
|
+
ws = sock;
|
|
368
|
+
try {
|
|
369
|
+
ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
|
|
370
|
+
} catch {
|
|
371
|
+
failCurrentAttempt(true);
|
|
372
|
+
if (sock.readyState !== WebSocket.CLOSED) {
|
|
373
|
+
closeSocket(sock);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
console.log("[pi-browser-daemon] Connected to Chrome ✓");
|
|
378
|
+
settleAttempt();
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const onMessage = (data: WebSocket.Data): void => {
|
|
382
|
+
if (!sock || !isCurrentAttemptSocket(generation, sock)) return;
|
|
383
|
+
onChromeMessage(typeof data === "string" ? data : data.toString());
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const onError = (): void => {
|
|
387
|
+
clearAttemptTimeout();
|
|
388
|
+
const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
|
|
389
|
+
failCurrentAttempt(notifyClose);
|
|
390
|
+
if (sock && sock.readyState !== WebSocket.CLOSED) {
|
|
391
|
+
closeSocket(sock);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const onClose = (): void => {
|
|
396
|
+
clearAttemptTimeout();
|
|
397
|
+
const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
|
|
398
|
+
failCurrentAttempt(notifyClose);
|
|
399
|
+
};
|
|
305
400
|
|
|
401
|
+
sock.on("open", onOpen);
|
|
402
|
+
sock.on("message", onMessage);
|
|
403
|
+
sock.on("error", onError);
|
|
404
|
+
sock.on("close", onClose);
|
|
405
|
+
|
|
406
|
+
// The promise must always settle even on constructor errors.
|
|
306
407
|
await settledPromise;
|
|
307
|
-
// If connection didn't succeed, schedule a retry
|
|
308
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
309
|
-
scheduleRetry();
|
|
310
|
-
}
|
|
311
408
|
};
|
|
312
409
|
|
|
313
|
-
attempt().catch(() =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (stopped) return;
|
|
319
|
-
const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000);
|
|
320
|
-
reconnectAttempt++;
|
|
321
|
-
wsUrl = null; // force re-discovery on next attempt
|
|
322
|
-
reconnectTimer = setTimeout(tryConnect, delay);
|
|
410
|
+
attempt().catch(() => {
|
|
411
|
+
if (isCurrentAttempt(generation)) {
|
|
412
|
+
connecting = false;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
323
415
|
};
|
|
324
416
|
|
|
325
417
|
const start = async (): Promise<void> => {
|
|
326
418
|
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
419
|
tryConnect();
|
|
331
420
|
};
|
|
332
421
|
|
|
333
422
|
const stop = async (): Promise<void> => {
|
|
334
423
|
stopped = true;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
424
|
+
attemptGeneration += 1;
|
|
425
|
+
activeAttempt = 0;
|
|
426
|
+
activeSocket = null;
|
|
427
|
+
connecting = false;
|
|
428
|
+
rejectAllPendingCallbacks();
|
|
429
|
+
if (ws) {
|
|
430
|
+
try {
|
|
431
|
+
ws.close(1000, "Shutdown");
|
|
432
|
+
} catch {}
|
|
433
|
+
ws = null;
|
|
434
|
+
}
|
|
339
435
|
wsUrl = null;
|
|
340
436
|
};
|
|
341
437
|
|
|
438
|
+
const waitForConnection = async (): Promise<boolean> => {
|
|
439
|
+
const deadline = Date.now() + connectTimeoutMs;
|
|
440
|
+
while (connecting && Date.now() < deadline) {
|
|
441
|
+
if (ws && ws.readyState === WebSocket.OPEN) return true;
|
|
442
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
443
|
+
}
|
|
444
|
+
return ws !== null && ws.readyState === WebSocket.OPEN;
|
|
445
|
+
};
|
|
446
|
+
|
|
342
447
|
const handleRequest = async (
|
|
343
448
|
req: WireRequest,
|
|
344
449
|
clientId: string,
|
|
345
450
|
send: SendToClient,
|
|
346
451
|
): Promise<void> => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
452
|
+
let activeSocket = ws;
|
|
453
|
+
if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
|
|
454
|
+
// Connection attempts happen only in response to explicit browser work.
|
|
455
|
+
tryConnect();
|
|
456
|
+
const ready = await waitForConnection();
|
|
457
|
+
if (!ready) {
|
|
458
|
+
send(clientId, {
|
|
459
|
+
type: "response",
|
|
460
|
+
id: req.id,
|
|
461
|
+
error: { code: -32000, message: "Chrome not connected" },
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
354
464
|
}
|
|
355
465
|
}
|
|
356
466
|
|
|
357
|
-
|
|
467
|
+
activeSocket = ws;
|
|
468
|
+
if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
|
|
358
469
|
send(clientId, {
|
|
359
470
|
type: "response",
|
|
360
471
|
id: req.id,
|
|
@@ -363,27 +474,25 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
363
474
|
return;
|
|
364
475
|
}
|
|
365
476
|
|
|
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
477
|
const isDetach = req.method === "Target.detachFromTarget";
|
|
478
|
+
if (isDetach && req.sessionId) router.release(req.sessionId);
|
|
381
479
|
|
|
382
|
-
|
|
383
|
-
router.release(req.sessionId);
|
|
384
|
-
}
|
|
480
|
+
const isAttach = req.method === "Target.attachToTarget";
|
|
385
481
|
|
|
386
|
-
|
|
482
|
+
const daemonId = mux.allocate(
|
|
483
|
+
{ clientId, localId: req.id, send, isAttach },
|
|
484
|
+
(id) =>
|
|
485
|
+
setTimeout(() => {
|
|
486
|
+
mux.take(id);
|
|
487
|
+
generations.delete(id);
|
|
488
|
+
send(clientId, {
|
|
489
|
+
type: "response",
|
|
490
|
+
id: req.id,
|
|
491
|
+
error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
|
|
492
|
+
});
|
|
493
|
+
}, CDP_COMMAND_TIMEOUT_MS),
|
|
494
|
+
);
|
|
495
|
+
generations.set(daemonId, { generation: attemptGeneration, clientId });
|
|
387
496
|
|
|
388
497
|
const payload: Record<string, unknown> = {
|
|
389
498
|
id: daemonId,
|
|
@@ -393,11 +502,10 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
393
502
|
if (req.sessionId) payload["sessionId"] = req.sessionId;
|
|
394
503
|
|
|
395
504
|
try {
|
|
396
|
-
|
|
505
|
+
activeSocket.send(JSON.stringify(payload));
|
|
397
506
|
} catch (e) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
mux.resolve(daemonId);
|
|
507
|
+
mux.take(daemonId);
|
|
508
|
+
generations.delete(daemonId);
|
|
401
509
|
send(clientId, {
|
|
402
510
|
type: "response",
|
|
403
511
|
id: req.id,
|
|
@@ -411,10 +519,12 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
411
519
|
stop,
|
|
412
520
|
handleRequest,
|
|
413
521
|
isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
|
|
414
|
-
recordSession: (cid, sid) => router.record(cid, sid),
|
|
415
|
-
releaseSession: (sid) => router.release(sid),
|
|
416
522
|
getSessionOwner: (sid) => router.getOwner(sid),
|
|
417
|
-
removeClient: (cid) => {
|
|
523
|
+
removeClient: (cid) => {
|
|
524
|
+
rejectCallbacksWhere((info) => info.clientId === cid);
|
|
525
|
+
mux.clearClient(cid);
|
|
526
|
+
router.removeClient(cid);
|
|
527
|
+
},
|
|
418
528
|
onEvent: (h) => { eventHandler = h; },
|
|
419
529
|
onClose: (h) => { closeHandler = h; },
|
|
420
530
|
};
|