browser-pilot-cli 0.1.5 → 0.2.1

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.
@@ -0,0 +1,490 @@
1
+ // src/cdp.ts
2
+ import WebSocket from "ws";
3
+
4
+ // src/protocol/errors.ts
5
+ var BrowserPilotError = class extends Error {
6
+ code;
7
+ retryable;
8
+ context;
9
+ remediation;
10
+ rpcCode;
11
+ constructor(code, message, options = {}) {
12
+ super(message, { cause: options.cause });
13
+ this.name = "BrowserPilotError";
14
+ this.code = code;
15
+ this.retryable = options.retryable ?? false;
16
+ this.context = options.context;
17
+ this.remediation = options.remediation;
18
+ this.rpcCode = options.rpcCode ?? -32e3;
19
+ }
20
+ toData() {
21
+ return {
22
+ code: this.code,
23
+ retryable: this.retryable,
24
+ ...this.context ? { context: this.context } : {},
25
+ ...this.remediation ? { remediation: this.remediation } : {}
26
+ };
27
+ }
28
+ toJsonRpcError() {
29
+ return {
30
+ code: this.rpcCode,
31
+ message: this.message,
32
+ data: this.toData()
33
+ };
34
+ }
35
+ };
36
+
37
+ // src/cdp.ts
38
+ var CDPClient = class {
39
+ ws;
40
+ nextId = 1;
41
+ callbacks = /* @__PURE__ */ new Map();
42
+ eventHandlers = /* @__PURE__ */ new Map();
43
+ anyEventHandlers = /* @__PURE__ */ new Set();
44
+ connectionHandlers = /* @__PURE__ */ new Set();
45
+ state = "disconnected";
46
+ closeRequested = false;
47
+ get connectionState() {
48
+ return this.state;
49
+ }
50
+ connect(wsUrl2) {
51
+ if (this.state === "connecting" || this.state === "connected") {
52
+ return Promise.reject(new Error("CDP transport is already connected or connecting"));
53
+ }
54
+ this.closeRequested = false;
55
+ this.transition("connecting");
56
+ return new Promise((resolve, reject) => {
57
+ const socket = new WebSocket(wsUrl2);
58
+ this.ws = socket;
59
+ let settled = false;
60
+ const rejectConnect = (error) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ reject(error);
64
+ };
65
+ const resolveConnect = () => {
66
+ if (settled) return;
67
+ settled = true;
68
+ resolve();
69
+ };
70
+ socket.on("error", (error) => {
71
+ if (socket !== this.ws) return;
72
+ const stable = this.disconnectedError(error);
73
+ this.failPending(stable);
74
+ rejectConnect(stable);
75
+ this.transition(this.closeRequested ? "closed" : "disconnected", stable);
76
+ if (!this.closeRequested && socket.readyState !== WebSocket.CLOSED) socket.terminate();
77
+ });
78
+ const onOpen = () => {
79
+ if (socket !== this.ws) return;
80
+ this.transition("connected");
81
+ resolveConnect();
82
+ };
83
+ socket.once("open", onOpen);
84
+ socket.on("message", (data) => {
85
+ if (socket !== this.ws) return;
86
+ let msg;
87
+ try {
88
+ msg = JSON.parse(data.toString());
89
+ } catch {
90
+ return;
91
+ }
92
+ if ("id" in msg) {
93
+ const cb = this.callbacks.get(msg.id);
94
+ if (cb) {
95
+ this.callbacks.delete(msg.id);
96
+ msg.error ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result ?? {});
97
+ }
98
+ } else if (msg.method) {
99
+ if (msg.sessionId) {
100
+ for (const h of this.eventHandlers.get(`${msg.sessionId}:${msg.method}`) ?? []) {
101
+ h(msg.params, msg.sessionId);
102
+ }
103
+ }
104
+ for (const h of this.eventHandlers.get(msg.method) ?? []) {
105
+ h(msg.params, msg.sessionId);
106
+ }
107
+ for (const h of this.anyEventHandlers) {
108
+ h(msg.method, msg.params, msg.sessionId);
109
+ }
110
+ }
111
+ });
112
+ socket.on("close", () => {
113
+ if (socket !== this.ws) return;
114
+ this.ws = void 0;
115
+ const error = this.disconnectedError();
116
+ this.failPending(error);
117
+ rejectConnect(error);
118
+ this.transition(this.closeRequested ? "closed" : "disconnected", error);
119
+ });
120
+ });
121
+ }
122
+ send(method, params, sessionId) {
123
+ const socket = this.ws;
124
+ if (this.state !== "connected" || !socket || socket.readyState !== WebSocket.OPEN) {
125
+ return Promise.reject(this.disconnectedError());
126
+ }
127
+ return new Promise((resolve, reject) => {
128
+ const id = this.nextId++;
129
+ const msg = { id, method };
130
+ if (params && Object.keys(params).length > 0) msg.params = params;
131
+ if (sessionId) msg.sessionId = sessionId;
132
+ const timeout = setTimeout(() => {
133
+ this.callbacks.delete(id);
134
+ reject(new Error(`CDP timeout: ${method}`));
135
+ }, 3e4);
136
+ this.callbacks.set(id, {
137
+ resolve: (v) => {
138
+ clearTimeout(timeout);
139
+ resolve(v);
140
+ },
141
+ reject: (e) => {
142
+ clearTimeout(timeout);
143
+ reject(e);
144
+ }
145
+ });
146
+ socket.send(JSON.stringify(msg), (error) => {
147
+ if (!error) return;
148
+ const callback = this.callbacks.get(id);
149
+ if (!callback) return;
150
+ this.callbacks.delete(id);
151
+ callback.reject(this.disconnectedError(error));
152
+ });
153
+ });
154
+ }
155
+ on(method, handler) {
156
+ const handlers = this.eventHandlers.get(method) ?? [];
157
+ handlers.push(handler);
158
+ this.eventHandlers.set(method, handlers);
159
+ }
160
+ onAny(handler) {
161
+ this.anyEventHandlers.add(handler);
162
+ return () => this.anyEventHandlers.delete(handler);
163
+ }
164
+ onConnectionState(handler) {
165
+ this.connectionHandlers.add(handler);
166
+ return () => this.connectionHandlers.delete(handler);
167
+ }
168
+ close() {
169
+ this.closeRequested = true;
170
+ const socket = this.ws;
171
+ if (!socket) {
172
+ this.transition("closed");
173
+ return Promise.resolve();
174
+ }
175
+ if (socket.readyState !== WebSocket.CONNECTING && socket.readyState !== WebSocket.OPEN) {
176
+ this.ws = void 0;
177
+ this.failPending(this.disconnectedError());
178
+ this.transition("closed");
179
+ return Promise.resolve();
180
+ }
181
+ return new Promise((resolve) => {
182
+ let settled = false;
183
+ const finish2 = () => {
184
+ if (settled) return;
185
+ settled = true;
186
+ clearTimeout(timer);
187
+ resolve();
188
+ };
189
+ const timer = setTimeout(() => {
190
+ socket.terminate();
191
+ if (this.ws === socket) {
192
+ this.ws = void 0;
193
+ this.failPending(this.disconnectedError());
194
+ this.transition("closed");
195
+ }
196
+ finish2();
197
+ }, 1e3);
198
+ timer.unref();
199
+ socket.once("close", finish2);
200
+ socket.close();
201
+ });
202
+ }
203
+ disconnectedError(cause) {
204
+ return new BrowserPilotError("browser_disconnected", "Chrome DevTools connection is unavailable", {
205
+ retryable: true,
206
+ ...cause ? { cause } : {}
207
+ });
208
+ }
209
+ failPending(error) {
210
+ for (const callback of this.callbacks.values()) callback.reject(error);
211
+ this.callbacks.clear();
212
+ }
213
+ transition(state, error) {
214
+ const previousState = this.state;
215
+ if (state === previousState) return;
216
+ this.state = state;
217
+ const event = {
218
+ state,
219
+ previousState,
220
+ ...error ? { error } : {}
221
+ };
222
+ for (const handler of this.connectionHandlers) {
223
+ try {
224
+ handler(event);
225
+ } catch {
226
+ }
227
+ }
228
+ }
229
+ };
230
+
231
+ // src/managed-target-janitor.ts
232
+ var MAX_LINE_BYTES = 64 * 1024;
233
+ var MAX_TRACKED_TARGETS = 4096;
234
+ var CLEANUP_TIMEOUT_MS = 5e3;
235
+ var wsUrl = process.argv[2];
236
+ if (!wsUrl || !/^wss?:\/\//.test(wsUrl)) {
237
+ process.stderr.write("Managed target janitor requires a CDP WebSocket URL\n");
238
+ process.exit(2);
239
+ }
240
+ var cdp = new CDPClient();
241
+ var ownedTargets = /* @__PURE__ */ new Map();
242
+ var pending = Buffer.alloc(0);
243
+ var commandTail = Promise.resolve();
244
+ var finishing = false;
245
+ var outputAvailable = true;
246
+ function emit(value) {
247
+ if (typeof process.send === "function" && process.connected) {
248
+ process.send(value);
249
+ return;
250
+ }
251
+ if (!outputAvailable || process.stdout.destroyed || !process.stdout.writable) return;
252
+ process.stdout.write(`${JSON.stringify(value)}
253
+ `);
254
+ }
255
+ function boundedMessage(error) {
256
+ const message = error instanceof Error ? error.message : String(error);
257
+ return message.slice(0, 1024);
258
+ }
259
+ function validateRequest(value) {
260
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid janitor request");
261
+ const request = value;
262
+ if (!Number.isSafeInteger(request.id) || Number(request.id) < 1) throw new Error("Invalid janitor request ID");
263
+ if (request.method !== "create" && request.method !== "adopt" && request.method !== "cdp.send") {
264
+ throw new Error("Invalid janitor method");
265
+ }
266
+ if (!request.params || typeof request.params !== "object" || Array.isArray(request.params)) {
267
+ throw new Error("Invalid janitor params");
268
+ }
269
+ return request;
270
+ }
271
+ function parseRequest(line) {
272
+ if (line.length === 0 || line.length > MAX_LINE_BYTES) throw new Error("Invalid janitor request size");
273
+ return validateRequest(JSON.parse(line.toString("utf8")));
274
+ }
275
+ function notifyOwned(targetId, openerTargetId) {
276
+ emit({ event: "owned", targetId, ...openerTargetId ? { openerTargetId } : {} });
277
+ }
278
+ async function trackTarget(targetId, openerTargetId) {
279
+ if (ownedTargets.has(targetId)) return true;
280
+ if (ownedTargets.size >= MAX_TRACKED_TARGETS) {
281
+ await cdp.send("Target.closeTarget", { targetId }).catch(() => {
282
+ });
283
+ return false;
284
+ }
285
+ ownedTargets.set(targetId, openerTargetId);
286
+ notifyOwned(targetId, openerTargetId);
287
+ return true;
288
+ }
289
+ async function createTarget(params) {
290
+ if (params.url !== "about:blank") throw new Error("Managed targets must start at about:blank");
291
+ const newWindow = params.newWindow === true;
292
+ const windowId = params.windowId;
293
+ if (newWindow === (windowId !== void 0)) throw new Error("Exactly one managed target window selector is required");
294
+ if (windowId !== void 0 && (!Number.isSafeInteger(windowId) || Number(windowId) < 0)) {
295
+ throw new Error("Invalid managed target window ID");
296
+ }
297
+ const created = await cdp.send("Target.createTarget", {
298
+ url: "about:blank",
299
+ ...newWindow ? { newWindow: true } : { windowId }
300
+ });
301
+ if (typeof created?.targetId !== "string" || created.targetId.length === 0) {
302
+ throw new Error("Chrome returned an invalid managed target ID");
303
+ }
304
+ if (!await trackTarget(created.targetId)) throw new Error("Managed target capacity reached");
305
+ return { targetId: created.targetId };
306
+ }
307
+ async function adoptTargets(params) {
308
+ if (!Array.isArray(params.targetIds) || params.targetIds.length > MAX_TRACKED_TARGETS) {
309
+ throw new Error("Invalid managed target adoption list");
310
+ }
311
+ const requested = params.targetIds;
312
+ if (requested.some((value) => typeof value !== "string" || value.length === 0 || value.length > 1024)) {
313
+ throw new Error("Invalid managed target ID");
314
+ }
315
+ const result = await cdp.send("Target.getTargets");
316
+ const infos = Array.isArray(result?.targetInfos) ? result.targetInfos : [];
317
+ const live = /* @__PURE__ */ new Map();
318
+ for (const info of infos) {
319
+ if (!info || typeof info.targetId !== "string") continue;
320
+ live.set(info.targetId, typeof info.openerId === "string" ? info.openerId : void 0);
321
+ }
322
+ const before = ownedTargets.size;
323
+ for (const targetId of requested) {
324
+ if (live.has(targetId)) await trackTarget(targetId, live.get(targetId));
325
+ }
326
+ let changed = true;
327
+ while (changed) {
328
+ changed = false;
329
+ for (const [targetId, openerTargetId] of live) {
330
+ if (!openerTargetId || !ownedTargets.has(openerTargetId) || ownedTargets.has(targetId)) continue;
331
+ if (await trackTarget(targetId, openerTargetId)) changed = true;
332
+ }
333
+ }
334
+ return { adopted: ownedTargets.size - before };
335
+ }
336
+ async function forwardCdp(params) {
337
+ if (typeof params.method !== "string" || params.method.length === 0 || params.method.length > 256) {
338
+ throw new Error("Invalid CDP method");
339
+ }
340
+ if (params.params !== void 0 && (!params.params || typeof params.params !== "object" || Array.isArray(params.params))) {
341
+ throw new Error("Invalid CDP params");
342
+ }
343
+ if (params.sessionId !== void 0 && (typeof params.sessionId !== "string" || params.sessionId.length === 0 || params.sessionId.length > 1024)) {
344
+ throw new Error("Invalid CDP session ID");
345
+ }
346
+ return cdp.send(
347
+ params.method,
348
+ params.params,
349
+ params.sessionId
350
+ );
351
+ }
352
+ async function handle(request) {
353
+ try {
354
+ const result = request.method === "create" ? await createTarget(request.params) : request.method === "adopt" ? await adoptTargets(request.params) : await forwardCdp(request.params);
355
+ emit({ id: request.id, result });
356
+ } catch (error) {
357
+ emit({
358
+ id: request.id,
359
+ error: {
360
+ code: request.method === "cdp.send" ? "cdp_error" : "janitor_error",
361
+ message: boundedMessage(error)
362
+ }
363
+ });
364
+ }
365
+ }
366
+ function cleanupOrder() {
367
+ const depth = (targetId) => {
368
+ const seen = /* @__PURE__ */ new Set();
369
+ let current = targetId;
370
+ let value = 0;
371
+ while (current && !seen.has(current)) {
372
+ seen.add(current);
373
+ const opener = ownedTargets.get(current);
374
+ if (!opener || !ownedTargets.has(opener)) break;
375
+ value += 1;
376
+ current = opener;
377
+ }
378
+ return value;
379
+ };
380
+ return [...ownedTargets.keys()].sort((left, right) => depth(right) - depth(left));
381
+ }
382
+ async function closeOwnedTargets() {
383
+ const targetIds = cleanupOrder();
384
+ for (let offset = 0; offset < targetIds.length; offset += 32) {
385
+ await Promise.all(targetIds.slice(offset, offset + 32).map((targetId) => cdp.send("Target.closeTarget", { targetId }).catch(() => {
386
+ })));
387
+ }
388
+ }
389
+ async function finish(cleanup, exitCode = 0) {
390
+ if (finishing) return;
391
+ finishing = true;
392
+ process.stdin.pause();
393
+ if (cleanup && cdp.connectionState === "connected") {
394
+ await Promise.race([
395
+ closeOwnedTargets(),
396
+ new Promise((resolve) => setTimeout(resolve, CLEANUP_TIMEOUT_MS))
397
+ ]).catch(() => {
398
+ });
399
+ }
400
+ await cdp.close();
401
+ process.exitCode = exitCode;
402
+ setImmediate(() => process.exit(exitCode));
403
+ }
404
+ async function main() {
405
+ process.stdout.on("error", () => {
406
+ outputAvailable = false;
407
+ });
408
+ process.stderr.on("error", () => {
409
+ });
410
+ process.stdin.on("end", () => {
411
+ void commandTail.finally(() => finish(true));
412
+ });
413
+ process.stdin.on("error", () => {
414
+ void finish(true, 1);
415
+ });
416
+ process.on("disconnect", () => {
417
+ void commandTail.finally(() => finish(true));
418
+ });
419
+ process.on("SIGTERM", () => {
420
+ void finish(true);
421
+ });
422
+ process.on("SIGINT", () => {
423
+ void finish(true);
424
+ });
425
+ process.stdin.resume();
426
+ cdp.on("Target.targetCreated", (params) => {
427
+ const info = params?.targetInfo;
428
+ if (typeof info?.targetId !== "string" || typeof info?.openerId !== "string" || !ownedTargets.has(info.openerId)) return;
429
+ void trackTarget(info.targetId, info.openerId);
430
+ });
431
+ cdp.on("Target.targetDestroyed", (params) => {
432
+ if (typeof params?.targetId !== "string" || !ownedTargets.delete(params.targetId)) return;
433
+ emit({ event: "destroyed", targetId: params.targetId });
434
+ });
435
+ cdp.onAny((method, params, sessionId) => {
436
+ emit({ event: "cdp", method, params, ...sessionId ? { sessionId } : {} });
437
+ });
438
+ cdp.onConnectionState((event) => {
439
+ if (event.state === "disconnected" && !finishing) void finish(false, 1);
440
+ });
441
+ await cdp.connect(wsUrl);
442
+ await cdp.send("Target.setDiscoverTargets", { discover: true });
443
+ emit({ event: "ready" });
444
+ const enqueue = (request) => {
445
+ if (request.method === "cdp.send") {
446
+ void handle(request);
447
+ return;
448
+ }
449
+ commandTail = commandTail.then(() => handle(request));
450
+ };
451
+ if (typeof process.send === "function") {
452
+ process.on("message", (value) => {
453
+ try {
454
+ enqueue(validateRequest(value));
455
+ } catch (error) {
456
+ process.stderr.write(`${boundedMessage(error)}
457
+ `);
458
+ void finish(true, 2);
459
+ }
460
+ });
461
+ } else {
462
+ process.stdin.on("data", (value) => {
463
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
464
+ if (pending.length + chunk.length > MAX_LINE_BYTES * 2) {
465
+ void finish(true, 2);
466
+ return;
467
+ }
468
+ pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
469
+ let newline = pending.indexOf(10);
470
+ while (newline >= 0) {
471
+ const line = pending.subarray(0, newline);
472
+ pending = pending.subarray(newline + 1);
473
+ try {
474
+ enqueue(parseRequest(line));
475
+ } catch (error) {
476
+ process.stderr.write(`${boundedMessage(error)}
477
+ `);
478
+ void finish(true, 2);
479
+ return;
480
+ }
481
+ newline = pending.indexOf(10);
482
+ }
483
+ });
484
+ }
485
+ }
486
+ main().catch((error) => {
487
+ if (!finishing) process.stderr.write(`${boundedMessage(error)}
488
+ `);
489
+ void finish(false, 1);
490
+ });