pi-browser-harness 0.5.0 → 0.6.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 +12 -1
- package/package.json +2 -1
- package/skills/pi-browser-harness/SKILL.md +17 -1
- package/src/cdp/daemon-transport.ts +277 -0
- package/src/client.ts +16 -2
- package/src/daemon/bridge.ts +411 -0
- package/src/daemon/index.ts +114 -0
- package/src/daemon/protocol.ts +162 -0
- package/src/daemon/server.ts +245 -0
- package/src/daemon/spawn.ts +85 -0
- package/src/domains/navigate.ts +17 -3
- package/src/domains/setup.ts +41 -0
- package/src/index.ts +46 -25
- package/src/registry.ts +3 -0
- package/src/setup.ts +45 -39
- package/src/util/tool.ts +19 -0
|
@@ -0,0 +1,411 @@
|
|
|
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
|
+
import WebSocket from "ws";
|
|
14
|
+
import { discoverWsUrl } from "../cdp/discovery";
|
|
15
|
+
import type { CdpRawMessage } from "../cdp/types";
|
|
16
|
+
import type { WireRequest, WireResponse, WireEvent } from "./protocol";
|
|
17
|
+
import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
|
|
18
|
+
|
|
19
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
|
|
22
|
+
|
|
23
|
+
export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void;
|
|
24
|
+
|
|
25
|
+
export type CloseHandler = () => void;
|
|
26
|
+
|
|
27
|
+
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
|
+
start(): Promise<void>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
handleRequest(req: WireRequest, clientId: string, send: SendToClient): void;
|
|
33
|
+
/** True when the Chrome WebSocket is open and ready. */
|
|
34
|
+
isAlive(): boolean;
|
|
35
|
+
recordSession(clientId: string, sessionId: string): void;
|
|
36
|
+
releaseSession(sessionId: string): void;
|
|
37
|
+
getSessionOwner(sessionId: string): string | undefined;
|
|
38
|
+
removeClient(clientId: string): void;
|
|
39
|
+
onEvent(handler: EventHandler): void;
|
|
40
|
+
onClose(handler: CloseHandler): void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ── ID Multiplexer ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface PendingEntry {
|
|
46
|
+
clientId: string;
|
|
47
|
+
localId: number;
|
|
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
|
+
}
|
|
125
|
+
|
|
126
|
+
allClients(): string[] {
|
|
127
|
+
return [...this.clientSessions.keys()];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Implementation ─────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export const createCdpBridge = (): CdpBridge => {
|
|
134
|
+
let ws: WebSocket | null = null;
|
|
135
|
+
let wsUrl: string | null = null;
|
|
136
|
+
const mux = new IdMultiplexer();
|
|
137
|
+
const router = new EventRouter();
|
|
138
|
+
let eventHandler: EventHandler | null = null;
|
|
139
|
+
let closeHandler: CloseHandler | null = null;
|
|
140
|
+
|
|
141
|
+
// daemonId → { send, timer, isAttach }
|
|
142
|
+
const callbacks = new Map<number, {
|
|
143
|
+
send: SendToClient;
|
|
144
|
+
clientId: string;
|
|
145
|
+
localId: number;
|
|
146
|
+
timer: ReturnType<typeof setTimeout>;
|
|
147
|
+
isAttach: boolean;
|
|
148
|
+
}>();
|
|
149
|
+
|
|
150
|
+
// ── Handle raw CDP messages from Chrome ─────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const onChromeMessage = (raw: string): void => {
|
|
153
|
+
let msg: CdpRawMessage;
|
|
154
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
155
|
+
|
|
156
|
+
// ---- Response (has id) ----
|
|
157
|
+
if (msg.id !== undefined) {
|
|
158
|
+
const cb = callbacks.get(msg.id);
|
|
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
|
+
|
|
168
|
+
const localId = cb.localId;
|
|
169
|
+
|
|
170
|
+
if (msg.error) {
|
|
171
|
+
cb.send(cb.clientId, {
|
|
172
|
+
type: "response",
|
|
173
|
+
id: localId,
|
|
174
|
+
error: { code: msg.error.code ?? -1, message: msg.error.message },
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
// Track session ownership for attach responses
|
|
178
|
+
if (cb.isAttach && msg.result) {
|
|
179
|
+
const result = msg.result as { sessionId?: string };
|
|
180
|
+
if (result.sessionId) {
|
|
181
|
+
router.record(cb.clientId, result.sessionId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
mux.resolve(msg.id);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- Event (no id, has method) ----
|
|
192
|
+
if (!msg.method) return;
|
|
193
|
+
|
|
194
|
+
// Inspector.detached — release session ownership
|
|
195
|
+
if (msg.method === "Inspector.detached" && msg.sessionId) {
|
|
196
|
+
router.release(msg.sessionId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const wireEvent: WireEvent = {
|
|
200
|
+
type: "event",
|
|
201
|
+
method: msg.method,
|
|
202
|
+
...(msg.params !== undefined ? { params: msg.params } : {}),
|
|
203
|
+
...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Route through the event handler for the IpcServer to broadcast
|
|
207
|
+
if (eventHandler) {
|
|
208
|
+
const targets = router.route(wireEvent);
|
|
209
|
+
if (targets.length > 0) {
|
|
210
|
+
eventHandler(wireEvent, targets);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ── Connect to Chrome ───────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
// ── Auto-reconnecting Chrome connection ───────────────────────────────
|
|
218
|
+
|
|
219
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
220
|
+
let reconnectAttempt = 0;
|
|
221
|
+
let stopped = false;
|
|
222
|
+
|
|
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
|
+
const tryConnect = (): void => {
|
|
230
|
+
if (stopped) return;
|
|
231
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
232
|
+
|
|
233
|
+
const attempt = async (): Promise<void> => {
|
|
234
|
+
// Already connected — nothing to do.
|
|
235
|
+
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
236
|
+
|
|
237
|
+
// Resolve URL (discover if not cached, or clear cache on retry)
|
|
238
|
+
let url = wsUrl;
|
|
239
|
+
if (!url || reconnectAttempt > 0) {
|
|
240
|
+
const d = await discoverWsUrl();
|
|
241
|
+
if (!d.success) { scheduleRetry(); return; }
|
|
242
|
+
url = d.data;
|
|
243
|
+
wsUrl = url;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const settledPromise = new Promise<void>((settle) => {
|
|
247
|
+
let settled = false;
|
|
248
|
+
const settleOnce = (): void => {
|
|
249
|
+
if (settled) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
settle();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
let sock: WebSocket;
|
|
255
|
+
try {
|
|
256
|
+
sock = new WebSocket(url!, { perMessageDeflate: false });
|
|
257
|
+
} catch {
|
|
258
|
+
scheduleRetry();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
sock.close();
|
|
264
|
+
settleOnce();
|
|
265
|
+
}, CDP_CONNECT_TIMEOUT_MS);
|
|
266
|
+
|
|
267
|
+
sock.on("open", () => {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
ws = sock;
|
|
270
|
+
reconnectAttempt = 0;
|
|
271
|
+
// Enable target discovery so we see page events
|
|
272
|
+
ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
|
|
273
|
+
console.log("[pi-browser-daemon] Connected to Chrome ✓");
|
|
274
|
+
settleOnce();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
sock.on("message", (data: WebSocket.Data) => {
|
|
278
|
+
onChromeMessage(typeof data === "string" ? data : data.toString());
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
sock.on("error", () => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
settleOnce();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
sock.on("close", () => {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
ws = null;
|
|
289
|
+
// Reject all pending callbacks
|
|
290
|
+
for (const [daemonId, cb] of callbacks) {
|
|
291
|
+
clearTimeout(cb.timer);
|
|
292
|
+
cb.send(cb.clientId, {
|
|
293
|
+
type: "response",
|
|
294
|
+
id: cb.localId,
|
|
295
|
+
error: { code: -32000, message: "Chrome disconnected" },
|
|
296
|
+
});
|
|
297
|
+
callbacks.delete(daemonId);
|
|
298
|
+
mux.resolve(daemonId);
|
|
299
|
+
}
|
|
300
|
+
closeHandler?.();
|
|
301
|
+
// Auto-reconnect on unexpected close
|
|
302
|
+
scheduleRetry();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await settledPromise;
|
|
307
|
+
// If connection didn't succeed, schedule a retry
|
|
308
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
309
|
+
scheduleRetry();
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
attempt().catch(() => scheduleRetry());
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */
|
|
317
|
+
const scheduleRetry = (): void => {
|
|
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);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const start = async (): Promise<void> => {
|
|
326
|
+
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
|
+
tryConnect();
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const stop = async (): Promise<void> => {
|
|
334
|
+
stopped = true;
|
|
335
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
336
|
+
for (const [, cb] of callbacks) clearTimeout(cb.timer);
|
|
337
|
+
callbacks.clear();
|
|
338
|
+
if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }
|
|
339
|
+
wsUrl = null;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const handleRequest = (
|
|
343
|
+
req: WireRequest,
|
|
344
|
+
clientId: string,
|
|
345
|
+
send: SendToClient,
|
|
346
|
+
): void => {
|
|
347
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
348
|
+
send(clientId, {
|
|
349
|
+
type: "response",
|
|
350
|
+
id: req.id,
|
|
351
|
+
error: { code: -32000, message: "Chrome not connected" },
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const daemonId = mux.allocate(clientId, req.id);
|
|
357
|
+
|
|
358
|
+
// Store callback for the response
|
|
359
|
+
const timer = setTimeout(() => {
|
|
360
|
+
callbacks.delete(daemonId);
|
|
361
|
+
mux.resolve(daemonId);
|
|
362
|
+
send(clientId, {
|
|
363
|
+
type: "response",
|
|
364
|
+
id: req.id,
|
|
365
|
+
error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
|
|
366
|
+
});
|
|
367
|
+
}, CDP_COMMAND_TIMEOUT_MS);
|
|
368
|
+
|
|
369
|
+
const isAttach = req.method === "Target.attachToTarget";
|
|
370
|
+
const isDetach = req.method === "Target.detachFromTarget";
|
|
371
|
+
|
|
372
|
+
if (isDetach && req.sessionId) {
|
|
373
|
+
router.release(req.sessionId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
callbacks.set(daemonId, { send, clientId, localId: req.id, timer, isAttach });
|
|
377
|
+
|
|
378
|
+
const payload: Record<string, unknown> = {
|
|
379
|
+
id: daemonId,
|
|
380
|
+
method: req.method,
|
|
381
|
+
params: req.params ?? {},
|
|
382
|
+
};
|
|
383
|
+
if (req.sessionId) payload["sessionId"] = req.sessionId;
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
ws.send(JSON.stringify(payload));
|
|
387
|
+
} catch (e) {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
callbacks.delete(daemonId);
|
|
390
|
+
mux.resolve(daemonId);
|
|
391
|
+
send(clientId, {
|
|
392
|
+
type: "response",
|
|
393
|
+
id: req.id,
|
|
394
|
+
error: { code: -32000, message: e instanceof Error ? e.message : String(e) },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
start,
|
|
401
|
+
stop,
|
|
402
|
+
handleRequest,
|
|
403
|
+
isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
|
|
404
|
+
recordSession: (cid, sid) => router.record(cid, sid),
|
|
405
|
+
releaseSession: (sid) => router.release(sid),
|
|
406
|
+
getSessionOwner: (sid) => router.getOwner(sid),
|
|
407
|
+
removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
|
|
408
|
+
onEvent: (h) => { eventHandler = h; },
|
|
409
|
+
onClose: (h) => { closeHandler = h; },
|
|
410
|
+
};
|
|
411
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
import { createIpcServer } from "./server";
|
|
12
|
+
import { createCdpBridge, type SendToClient } from "./bridge";
|
|
13
|
+
import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol";
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
console.log("[pi-browser-daemon] Starting...");
|
|
17
|
+
|
|
18
|
+
const ipcServer = createIpcServer();
|
|
19
|
+
const cdpBridge = createCdpBridge();
|
|
20
|
+
|
|
21
|
+
// ── Wire IpcServer → CdpBridge ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
// When a pi client sends a CDP request, proxy it through the bridge to Chrome.
|
|
24
|
+
ipcServer.onMessage((msg, client) => {
|
|
25
|
+
if (msg.type !== "request") return;
|
|
26
|
+
|
|
27
|
+
const send: SendToClient = (cid, resp) => {
|
|
28
|
+
ipcServer.send(cid, resp);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
cdpBridge.handleRequest(msg, client.id, send);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// When Chrome sends an event, route it to the correct pi client(s).
|
|
35
|
+
cdpBridge.onEvent((event, targetClientIds) => {
|
|
36
|
+
for (const cid of targetClientIds) {
|
|
37
|
+
ipcServer.send(cid, event);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// When a client disconnects, clean up its CDP state.
|
|
42
|
+
ipcServer.onDisconnect((client) => {
|
|
43
|
+
cdpBridge.removeClient(client.id);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// When Chrome disconnects, notify all clients.
|
|
47
|
+
cdpBridge.onClose(() => {
|
|
48
|
+
console.log("[pi-browser-daemon] Chrome disconnected");
|
|
49
|
+
ipcServer.broadcast({
|
|
50
|
+
type: "control",
|
|
51
|
+
action: "shutdown",
|
|
52
|
+
reason: "chrome_disconnected",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
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
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
|
|
63
|
+
const resetIdleTimer = (): void => {
|
|
64
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
65
|
+
if (ipcServer.clientCount() === 0) {
|
|
66
|
+
idleTimer = setTimeout(() => {
|
|
67
|
+
console.log("[pi-browser-daemon] Idle timeout — no clients for " +
|
|
68
|
+
`${DAEMON_IDLE_TIMEOUT_MS / 60000} minutes. Shutting down.`);
|
|
69
|
+
shutdown();
|
|
70
|
+
}, DAEMON_IDLE_TIMEOUT_MS);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const cancelIdleTimer = (): void => {
|
|
75
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
ipcServer.onConnect(() => { cancelIdleTimer(); });
|
|
79
|
+
ipcServer.onDisconnect(() => { resetIdleTimer(); });
|
|
80
|
+
|
|
81
|
+
// ── Start ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await ipcServer.start();
|
|
85
|
+
console.log("[pi-browser-daemon] IPC server listening");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error("[pi-browser-daemon] Failed to start IPC server:", e);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Connect to Chrome (auto-retries with backoff in the background).
|
|
92
|
+
// The daemon is ready immediately — Chrome may connect later.
|
|
93
|
+
await cdpBridge.start();
|
|
94
|
+
|
|
95
|
+
// Start the idle timer (no clients yet at startup).
|
|
96
|
+
resetIdleTimer();
|
|
97
|
+
|
|
98
|
+
// ── Graceful shutdown ──────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const shutdown = async () => {
|
|
101
|
+
console.log("[pi-browser-daemon] Shutting down...");
|
|
102
|
+
await cdpBridge.stop();
|
|
103
|
+
await ipcServer.stop();
|
|
104
|
+
process.exit(0);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
process.on("SIGINT", shutdown);
|
|
108
|
+
process.on("SIGTERM", shutdown);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch((e) => {
|
|
112
|
+
console.error("[pi-browser-daemon] Fatal:", e);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unix socket daemon protocol — wire message types, type guards, and constants.
|
|
3
|
+
*
|
|
4
|
+
* The daemon speaks JSON-line protocol over a Unix domain socket.
|
|
5
|
+
* One JSON object per newline. Four message types: request, response, event, control.
|
|
6
|
+
*
|
|
7
|
+
* References:
|
|
8
|
+
* - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
|
|
9
|
+
* - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
|
|
10
|
+
* - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Message types ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** A CDP command sent from a pi client to the daemon. */
|
|
16
|
+
export type WireRequest = {
|
|
17
|
+
readonly type: "request";
|
|
18
|
+
/** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */
|
|
19
|
+
readonly id: number;
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly params?: Record<string, unknown>;
|
|
22
|
+
/** Optional CDP session id. Omitted for browser-level commands. */
|
|
23
|
+
readonly sessionId?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A CDP response (result or error) sent from the daemon back to the pi client. */
|
|
27
|
+
export type WireResponse = {
|
|
28
|
+
readonly type: "response";
|
|
29
|
+
/** Matches the id from the corresponding WireRequest. */
|
|
30
|
+
readonly id: number;
|
|
31
|
+
readonly result?: unknown;
|
|
32
|
+
readonly error?: {
|
|
33
|
+
readonly code: number;
|
|
34
|
+
readonly message: string;
|
|
35
|
+
readonly data?: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A CDP event forwarded from Chrome through the daemon to pi client(s). */
|
|
40
|
+
export type WireEvent = {
|
|
41
|
+
readonly type: "event";
|
|
42
|
+
readonly method: string;
|
|
43
|
+
readonly params?: Record<string, unknown>;
|
|
44
|
+
/** Session-scoped events carry this; browser-level events omit it. */
|
|
45
|
+
readonly sessionId?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Daemon lifecycle control messages. Bidirectional. */
|
|
49
|
+
export type WireControl = {
|
|
50
|
+
readonly type: "control";
|
|
51
|
+
readonly action: "register" | "registered" | "deregister" | "shutdown";
|
|
52
|
+
/** pi client namespace (required for register/registered/deregister). */
|
|
53
|
+
readonly clientId?: string;
|
|
54
|
+
/** Human-readable reason for shutdown. */
|
|
55
|
+
readonly reason?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** All messages that may appear on the Unix socket. */
|
|
59
|
+
export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
|
|
60
|
+
|
|
61
|
+
// ── Type guard ─────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
64
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validates that an unknown parsed-JSON value is a well-formed WireMessage.
|
|
68
|
+
* Does NOT validate the full CDP semantics — only structural shape.
|
|
69
|
+
*/
|
|
70
|
+
export const isWireMessage = (v: unknown): v is WireMessage => {
|
|
71
|
+
if (!isObject(v)) return false;
|
|
72
|
+
const t = v["type"];
|
|
73
|
+
if (typeof t !== "string") return false;
|
|
74
|
+
|
|
75
|
+
switch (t) {
|
|
76
|
+
case "request": {
|
|
77
|
+
if (typeof v["id"] !== "number") return false;
|
|
78
|
+
if (typeof v["method"] !== "string") return false;
|
|
79
|
+
const sid = v["sessionId"];
|
|
80
|
+
if (sid !== undefined && typeof sid !== "string") return false;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "response": {
|
|
84
|
+
if (typeof v["id"] !== "number") return false;
|
|
85
|
+
const errVal = v["error"];
|
|
86
|
+
if (errVal !== undefined) {
|
|
87
|
+
if (!isObject(errVal)) return false;
|
|
88
|
+
if (typeof errVal["message"] !== "string") return false;
|
|
89
|
+
if (typeof errVal["code"] !== "number") return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
case "event": {
|
|
94
|
+
if (typeof v["method"] !== "string") return false;
|
|
95
|
+
const sid = v["sessionId"];
|
|
96
|
+
if (sid !== undefined && typeof sid !== "string") return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
case "control": {
|
|
100
|
+
const action = v["action"];
|
|
101
|
+
if (typeof action !== "string") return false;
|
|
102
|
+
if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false;
|
|
103
|
+
const cid = v["clientId"];
|
|
104
|
+
if (
|
|
105
|
+
(action === "register" || action === "registered" || action === "deregister") &&
|
|
106
|
+
typeof cid !== "string"
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── Wire message constructors ──────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */
|
|
120
|
+
export const serialize = (msg: WireMessage): string => JSON.stringify(msg);
|
|
121
|
+
|
|
122
|
+
/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */
|
|
123
|
+
export const deserialize = (line: string): WireMessage | null => {
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(line);
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!isWireMessage(parsed)) return null;
|
|
131
|
+
return parsed;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** Filesystem path for the daemon's Unix domain socket. */
|
|
137
|
+
export const DAEMON_SOCKET_PATH = "/tmp/pi-browser-daemon.sock";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How long the daemon stays alive with zero connected clients before exiting
|
|
141
|
+
* (milliseconds). After this period, the daemon cleans up and shuts down.
|
|
142
|
+
*/
|
|
143
|
+
export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Hard cap on concurrent pi client connections. Extra connections are rejected
|
|
147
|
+
* immediately to prevent resource exhaustion.
|
|
148
|
+
*/
|
|
149
|
+
export const DAEMON_MAX_CLIENTS = 16;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* If true, the daemon unlinks (deletes) any stale socket file from a previous
|
|
153
|
+
* daemon crash before calling server.listen(). Without this, a crashed daemon
|
|
154
|
+
* leaves a socket inode that blocks the next listen() call with EADDRINUSE.
|
|
155
|
+
*/
|
|
156
|
+
export const DAEMON_STALE_SOCKET_CLEANUP = true;
|
|
157
|
+
|
|
158
|
+
/** CDP WebSocket connection timeout (ms). */
|
|
159
|
+
export const CDP_CONNECT_TIMEOUT_MS = 10_000;
|
|
160
|
+
|
|
161
|
+
/** CDP command timeout when proxying through daemon (ms). */
|
|
162
|
+
export const CDP_COMMAND_TIMEOUT_MS = 10_000;
|