cdp-skill 1.0.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/src/cdp.js ADDED
@@ -0,0 +1,905 @@
1
+ /**
2
+ * CDP Protocol and Browser Management
3
+ * Core CDP connection, discovery, target management, and browser client
4
+ */
5
+
6
+ import { timeoutError } from './utils.js';
7
+
8
+ // ============================================================================
9
+ // Connection
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Create a CDP WebSocket connection
14
+ * @param {string} wsUrl - WebSocket URL for CDP endpoint
15
+ * @param {Object} [options] - Connection options
16
+ * @param {number} [options.maxRetries=5] - Max reconnection attempts
17
+ * @param {number} [options.retryDelay=1000] - Base delay between retries
18
+ * @param {number} [options.maxRetryDelay=30000] - Maximum retry delay cap
19
+ * @param {boolean} [options.autoReconnect=false] - Enable auto reconnection
20
+ * @returns {Object} Connection interface
21
+ */
22
+ export function createConnection(wsUrl, options = {}) {
23
+ const maxRetries = options.maxRetries ?? 5;
24
+ const retryDelay = options.retryDelay ?? 1000;
25
+ const maxRetryDelay = options.maxRetryDelay ?? 30000;
26
+ const autoReconnect = options.autoReconnect ?? false;
27
+
28
+ let ws = null;
29
+ let messageId = 0;
30
+ const pendingCommands = new Map();
31
+ const eventListeners = new Map();
32
+ let connected = false;
33
+ let connecting = false;
34
+ let onCloseCallback = null;
35
+ let reconnecting = false;
36
+ let retryAttempt = 0;
37
+ let intentionalClose = false;
38
+
39
+ function emit(event, data = {}) {
40
+ const listeners = eventListeners.get(event);
41
+ if (listeners) {
42
+ for (const callback of listeners) {
43
+ try {
44
+ callback(data);
45
+ } catch (err) {
46
+ console.error(`Event handler error for ${event}:`, err);
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ function calculateBackoff(attempt) {
53
+ const delay = retryDelay * Math.pow(2, attempt);
54
+ return Math.min(delay, maxRetryDelay);
55
+ }
56
+
57
+ function sleep(ms) {
58
+ return new Promise(resolve => setTimeout(resolve, ms));
59
+ }
60
+
61
+ function rejectPendingCommands(reason) {
62
+ for (const [id, pending] of pendingCommands) {
63
+ clearTimeout(pending.timer);
64
+ pending.reject(new Error(reason));
65
+ }
66
+ pendingCommands.clear();
67
+ }
68
+
69
+ function handleMessage(data) {
70
+ let message;
71
+ try {
72
+ message = JSON.parse(data.toString());
73
+ } catch {
74
+ return;
75
+ }
76
+
77
+ if (message.id !== undefined) {
78
+ const pending = pendingCommands.get(message.id);
79
+ if (pending) {
80
+ clearTimeout(pending.timer);
81
+ pendingCommands.delete(message.id);
82
+ if (message.error) {
83
+ pending.reject(new Error(`CDP error: ${message.error.message}`));
84
+ } else {
85
+ pending.resolve(message.result);
86
+ }
87
+ }
88
+ return;
89
+ }
90
+
91
+ if (message.method) {
92
+ if (message.sessionId) {
93
+ const sessionEventKey = `${message.sessionId}:${message.method}`;
94
+ const sessionListeners = eventListeners.get(sessionEventKey);
95
+ if (sessionListeners) {
96
+ for (const callback of sessionListeners) {
97
+ try {
98
+ callback(message.params, message.sessionId);
99
+ } catch (err) {
100
+ console.error(`Event handler error for ${sessionEventKey}:`, err);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ const globalListeners = eventListeners.get(message.method);
107
+ if (globalListeners) {
108
+ for (const callback of globalListeners) {
109
+ try {
110
+ callback(message.params, message.sessionId);
111
+ } catch (err) {
112
+ console.error(`Event handler error for ${message.method}:`, err);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ function setupWebSocketListeners() {
120
+ ws.addEventListener('close', () => {
121
+ const wasConnected = connected;
122
+ connected = false;
123
+ connecting = false;
124
+ rejectPendingCommands('Connection closed');
125
+
126
+ if (wasConnected && !intentionalClose && autoReconnect) {
127
+ attemptReconnect();
128
+ } else if (wasConnected && onCloseCallback && !intentionalClose) {
129
+ onCloseCallback('Connection closed unexpectedly');
130
+ }
131
+ });
132
+
133
+ ws.addEventListener('message', (event) => handleMessage(event.data));
134
+ }
135
+
136
+ async function attemptReconnect() {
137
+ if (reconnecting || intentionalClose) return;
138
+
139
+ reconnecting = true;
140
+ retryAttempt = 0;
141
+
142
+ while (retryAttempt < maxRetries && !intentionalClose) {
143
+ const delay = calculateBackoff(retryAttempt);
144
+ emit('reconnecting', { attempt: retryAttempt + 1, delay });
145
+
146
+ await sleep(delay);
147
+ if (intentionalClose) break;
148
+
149
+ try {
150
+ await doReconnect();
151
+ reconnecting = false;
152
+ retryAttempt = 0;
153
+ emit('reconnected', {});
154
+ return;
155
+ } catch {
156
+ retryAttempt++;
157
+ }
158
+ }
159
+
160
+ reconnecting = false;
161
+ if (!intentionalClose && onCloseCallback) {
162
+ onCloseCallback('Connection closed unexpectedly after max retries');
163
+ }
164
+ }
165
+
166
+ function doReconnect() {
167
+ return new Promise((resolve, reject) => {
168
+ ws = new WebSocket(wsUrl);
169
+ ws.addEventListener('open', () => {
170
+ connected = true;
171
+ setupWebSocketListeners();
172
+ resolve();
173
+ });
174
+ ws.addEventListener('error', (event) => {
175
+ reject(new Error(`CDP reconnection error: ${event.message || 'Connection failed'}`));
176
+ });
177
+ });
178
+ }
179
+
180
+ async function connect() {
181
+ if (connected) return;
182
+ if (connecting) throw new Error('Connection already in progress');
183
+
184
+ connecting = true;
185
+ intentionalClose = false;
186
+
187
+ return new Promise((resolve, reject) => {
188
+ ws = new WebSocket(wsUrl);
189
+
190
+ ws.addEventListener('open', () => {
191
+ connected = true;
192
+ connecting = false;
193
+ resolve();
194
+ });
195
+
196
+ ws.addEventListener('error', (event) => {
197
+ connecting = false;
198
+ reject(new Error(`CDP connection error: ${event.message || 'Connection failed'}`));
199
+ });
200
+
201
+ ws.addEventListener('close', () => {
202
+ const wasConnected = connected;
203
+ connected = false;
204
+ connecting = false;
205
+ rejectPendingCommands('Connection closed');
206
+
207
+ if (wasConnected && !intentionalClose && autoReconnect) {
208
+ attemptReconnect();
209
+ } else if (wasConnected && onCloseCallback && !intentionalClose) {
210
+ onCloseCallback('Connection closed unexpectedly');
211
+ }
212
+ });
213
+
214
+ ws.addEventListener('message', (event) => handleMessage(event.data));
215
+ });
216
+ }
217
+
218
+ async function send(method, params = {}, timeout = 30000) {
219
+ if (!connected) throw new Error('Not connected to CDP');
220
+
221
+ const id = ++messageId;
222
+ const message = JSON.stringify({ id, method, params });
223
+
224
+ return new Promise((resolve, reject) => {
225
+ const timer = setTimeout(() => {
226
+ pendingCommands.delete(id);
227
+ reject(new Error(`CDP command timeout: ${method}`));
228
+ }, timeout);
229
+
230
+ pendingCommands.set(id, { resolve, reject, timer });
231
+ ws.send(message);
232
+ });
233
+ }
234
+
235
+ async function sendToSession(sessionId, method, params = {}, timeout = 30000) {
236
+ if (!connected) throw new Error('Not connected to CDP');
237
+
238
+ const id = ++messageId;
239
+ const message = JSON.stringify({ id, sessionId, method, params });
240
+
241
+ return new Promise((resolve, reject) => {
242
+ const timer = setTimeout(() => {
243
+ pendingCommands.delete(id);
244
+ reject(new Error(`CDP command timeout: ${method} (session: ${sessionId})`));
245
+ }, timeout);
246
+
247
+ pendingCommands.set(id, { resolve, reject, timer });
248
+ ws.send(message);
249
+ });
250
+ }
251
+
252
+ function on(event, callback) {
253
+ if (!eventListeners.has(event)) {
254
+ eventListeners.set(event, new Set());
255
+ }
256
+ eventListeners.get(event).add(callback);
257
+ }
258
+
259
+ function off(event, callback) {
260
+ const listeners = eventListeners.get(event);
261
+ if (listeners) {
262
+ listeners.delete(callback);
263
+ }
264
+ }
265
+
266
+ function waitForEvent(event, predicate = () => true, timeout = 30000) {
267
+ return new Promise((resolve, reject) => {
268
+ const timer = setTimeout(() => {
269
+ off(event, handler);
270
+ reject(new Error(`Timeout waiting for event: ${event}`));
271
+ }, timeout);
272
+
273
+ const handler = (params) => {
274
+ if (predicate(params)) {
275
+ clearTimeout(timer);
276
+ off(event, handler);
277
+ resolve(params);
278
+ }
279
+ };
280
+
281
+ on(event, handler);
282
+ });
283
+ }
284
+
285
+ async function close() {
286
+ intentionalClose = true;
287
+ reconnecting = false;
288
+ if (ws) {
289
+ rejectPendingCommands('Connection closed');
290
+ ws.close();
291
+ ws = null;
292
+ connected = false;
293
+ eventListeners.clear();
294
+ }
295
+ }
296
+
297
+ function removeAllListeners(event) {
298
+ if (event) {
299
+ eventListeners.delete(event);
300
+ } else {
301
+ eventListeners.clear();
302
+ }
303
+ }
304
+
305
+ function onClose(callback) {
306
+ onCloseCallback = callback;
307
+ }
308
+
309
+ return {
310
+ connect,
311
+ send,
312
+ sendToSession,
313
+ on,
314
+ off,
315
+ waitForEvent,
316
+ close,
317
+ removeAllListeners,
318
+ onClose,
319
+ isConnected: () => connected,
320
+ getWsUrl: () => wsUrl
321
+ };
322
+ }
323
+
324
+ // ============================================================================
325
+ // Discovery
326
+ // ============================================================================
327
+
328
+ /**
329
+ * Discover Chrome CDP endpoints via HTTP
330
+ * @param {string} [host='localhost'] - Chrome debugging host
331
+ * @param {number} [port=9222] - Chrome debugging port
332
+ * @param {number} [timeout=5000] - Request timeout in ms
333
+ * @returns {Object} Discovery interface
334
+ */
335
+ export function createDiscovery(host = 'localhost', port = 9222, timeout = 5000) {
336
+ const baseUrl = `http://${host}:${port}`;
337
+
338
+ function createTimeoutController() {
339
+ const controller = new AbortController();
340
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
341
+ return {
342
+ signal: controller.signal,
343
+ clear: () => clearTimeout(timeoutId)
344
+ };
345
+ }
346
+
347
+ async function getVersion() {
348
+ const timeoutCtrl = createTimeoutController();
349
+ try {
350
+ const response = await fetch(`${baseUrl}/json/version`, { signal: timeoutCtrl.signal });
351
+ if (!response.ok) {
352
+ throw new Error(`Chrome not reachable at ${baseUrl}: ${response.status}`);
353
+ }
354
+ const data = await response.json();
355
+ return {
356
+ browser: data.Browser,
357
+ protocolVersion: data['Protocol-Version'],
358
+ webSocketDebuggerUrl: data.webSocketDebuggerUrl
359
+ };
360
+ } catch (err) {
361
+ if (err.name === 'AbortError') {
362
+ throw new Error(`Chrome discovery timeout at ${baseUrl}`);
363
+ }
364
+ throw err;
365
+ } finally {
366
+ timeoutCtrl.clear();
367
+ }
368
+ }
369
+
370
+ async function getTargets() {
371
+ const timeoutCtrl = createTimeoutController();
372
+ try {
373
+ const response = await fetch(`${baseUrl}/json/list`, { signal: timeoutCtrl.signal });
374
+ if (!response.ok) {
375
+ throw new Error(`Failed to get targets: ${response.status}`);
376
+ }
377
+ return response.json();
378
+ } catch (err) {
379
+ if (err.name === 'AbortError') {
380
+ throw new Error('Chrome discovery timeout getting targets');
381
+ }
382
+ throw err;
383
+ } finally {
384
+ timeoutCtrl.clear();
385
+ }
386
+ }
387
+
388
+ async function getPages() {
389
+ const targets = await getTargets();
390
+ return targets.filter(t => t.type === 'page');
391
+ }
392
+
393
+ async function findPageByUrl(urlPattern) {
394
+ const pages = await getPages();
395
+ const regex = urlPattern instanceof RegExp ? urlPattern : new RegExp(urlPattern);
396
+ return pages.find(p => regex.test(p.url)) || null;
397
+ }
398
+
399
+ async function isAvailable() {
400
+ try {
401
+ await getVersion();
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
408
+ return {
409
+ getVersion,
410
+ getTargets,
411
+ getPages,
412
+ findPageByUrl,
413
+ isAvailable
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Convenience function to discover Chrome
419
+ * @param {string} [host='localhost'] - Chrome debugging host
420
+ * @param {number} [port=9222] - Chrome debugging port
421
+ * @param {number} [timeout=5000] - Request timeout in ms
422
+ * @returns {Promise<{wsUrl: string, version: Object, targets: Array}>}
423
+ */
424
+ export async function discoverChrome(host = 'localhost', port = 9222, timeout = 5000) {
425
+ const discovery = createDiscovery(host, port, timeout);
426
+ const version = await discovery.getVersion();
427
+ const targets = await discovery.getTargets();
428
+ return {
429
+ wsUrl: version.webSocketDebuggerUrl,
430
+ version,
431
+ targets
432
+ };
433
+ }
434
+
435
+ // ============================================================================
436
+ // Target Manager
437
+ // ============================================================================
438
+
439
+ /**
440
+ * Create a target manager for browser targets
441
+ * @param {Object} connection - CDP connection
442
+ * @returns {Object} Target manager interface
443
+ */
444
+ export function createTargetManager(connection) {
445
+ const targets = new Map();
446
+ let discoveryEnabled = false;
447
+ let boundHandlers = null;
448
+
449
+ function onTargetCreated(params) {
450
+ targets.set(params.targetInfo.targetId, params.targetInfo);
451
+ }
452
+
453
+ function onTargetDestroyed(params) {
454
+ targets.delete(params.targetId);
455
+ }
456
+
457
+ function onTargetInfoChanged(params) {
458
+ targets.set(params.targetInfo.targetId, params.targetInfo);
459
+ }
460
+
461
+ async function enableDiscovery() {
462
+ if (discoveryEnabled) return;
463
+
464
+ boundHandlers = { onTargetCreated, onTargetDestroyed, onTargetInfoChanged };
465
+ connection.on('Target.targetCreated', boundHandlers.onTargetCreated);
466
+ connection.on('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
467
+ connection.on('Target.targetInfoChanged', boundHandlers.onTargetInfoChanged);
468
+
469
+ await connection.send('Target.setDiscoverTargets', { discover: true });
470
+ discoveryEnabled = true;
471
+ }
472
+
473
+ async function disableDiscovery() {
474
+ if (!discoveryEnabled) return;
475
+
476
+ await connection.send('Target.setDiscoverTargets', { discover: false });
477
+
478
+ if (boundHandlers) {
479
+ connection.off('Target.targetCreated', boundHandlers.onTargetCreated);
480
+ connection.off('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
481
+ connection.off('Target.targetInfoChanged', boundHandlers.onTargetInfoChanged);
482
+ }
483
+
484
+ discoveryEnabled = false;
485
+ }
486
+
487
+ async function getTargets(filter = null) {
488
+ const result = await connection.send('Target.getTargets', {
489
+ filter: filter ? [filter] : undefined
490
+ });
491
+
492
+ for (const info of result.targetInfos) {
493
+ targets.set(info.targetId, info);
494
+ }
495
+
496
+ return result.targetInfos;
497
+ }
498
+
499
+ async function getPages() {
500
+ const allTargets = await getTargets();
501
+ return allTargets.filter(t => t.type === 'page');
502
+ }
503
+
504
+ async function createTarget(url = 'about:blank', options = {}) {
505
+ const result = await connection.send('Target.createTarget', {
506
+ url,
507
+ width: options.width,
508
+ height: options.height,
509
+ background: options.background ?? false,
510
+ newWindow: options.newWindow ?? false
511
+ });
512
+ return result.targetId;
513
+ }
514
+
515
+ async function closeTarget(targetId) {
516
+ const result = await connection.send('Target.closeTarget', { targetId });
517
+ targets.delete(targetId);
518
+ return result.success ?? true;
519
+ }
520
+
521
+ async function activateTarget(targetId) {
522
+ await connection.send('Target.activateTarget', { targetId });
523
+ }
524
+
525
+ async function getTargetInfo(targetId) {
526
+ const result = await connection.send('Target.getTargetInfo', { targetId });
527
+ targets.set(targetId, result.targetInfo);
528
+ return result.targetInfo;
529
+ }
530
+
531
+ function getCachedTarget(targetId) {
532
+ return targets.get(targetId);
533
+ }
534
+
535
+ function getCachedTargets() {
536
+ return new Map(targets);
537
+ }
538
+
539
+ async function cleanup() {
540
+ await disableDiscovery();
541
+ targets.clear();
542
+ }
543
+
544
+ return {
545
+ enableDiscovery,
546
+ disableDiscovery,
547
+ getTargets,
548
+ getPages,
549
+ createTarget,
550
+ closeTarget,
551
+ activateTarget,
552
+ getTargetInfo,
553
+ getCachedTarget,
554
+ getCachedTargets,
555
+ cleanup
556
+ };
557
+ }
558
+
559
+ // ============================================================================
560
+ // Session Registry
561
+ // ============================================================================
562
+
563
+ /**
564
+ * Create a session registry for managing CDP sessions
565
+ * @param {Object} connection - CDP connection
566
+ * @returns {Object} Session registry interface
567
+ */
568
+ export function createSessionRegistry(connection) {
569
+ const sessions = new Map();
570
+ const targetToSession = new Map();
571
+ const pendingAttach = new Map();
572
+ let boundHandlers = null;
573
+
574
+ function onAttached(params) {
575
+ const { sessionId, targetInfo } = params;
576
+ sessions.set(sessionId, { targetId: targetInfo.targetId, attached: true });
577
+ targetToSession.set(targetInfo.targetId, sessionId);
578
+ }
579
+
580
+ function onDetached(params) {
581
+ const { sessionId } = params;
582
+ const session = sessions.get(sessionId);
583
+ if (session) {
584
+ targetToSession.delete(session.targetId);
585
+ sessions.delete(sessionId);
586
+ }
587
+ }
588
+
589
+ function onTargetDestroyed(params) {
590
+ const { targetId } = params;
591
+ const sessionId = targetToSession.get(targetId);
592
+ if (sessionId) {
593
+ sessions.delete(sessionId);
594
+ targetToSession.delete(targetId);
595
+ }
596
+ }
597
+
598
+ // Setup handlers on creation
599
+ boundHandlers = { onAttached, onDetached, onTargetDestroyed };
600
+ connection.on('Target.attachedToTarget', boundHandlers.onAttached);
601
+ connection.on('Target.detachedFromTarget', boundHandlers.onDetached);
602
+ connection.on('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
603
+
604
+ async function doAttach(targetId) {
605
+ const result = await connection.send('Target.attachToTarget', {
606
+ targetId,
607
+ flatten: true
608
+ });
609
+
610
+ const sessionId = result.sessionId;
611
+ sessions.set(sessionId, { targetId, attached: true });
612
+ targetToSession.set(targetId, sessionId);
613
+
614
+ return sessionId;
615
+ }
616
+
617
+ async function attach(targetId) {
618
+ const existing = targetToSession.get(targetId);
619
+ if (existing) return existing;
620
+
621
+ const pending = pendingAttach.get(targetId);
622
+ if (pending) return pending;
623
+
624
+ const attachPromise = doAttach(targetId);
625
+ pendingAttach.set(targetId, attachPromise);
626
+
627
+ try {
628
+ return await attachPromise;
629
+ } finally {
630
+ pendingAttach.delete(targetId);
631
+ }
632
+ }
633
+
634
+ async function detach(sessionId) {
635
+ const session = sessions.get(sessionId);
636
+ if (!session) return;
637
+
638
+ await connection.send('Target.detachFromTarget', { sessionId });
639
+ sessions.delete(sessionId);
640
+ targetToSession.delete(session.targetId);
641
+ }
642
+
643
+ async function detachByTarget(targetId) {
644
+ const sessionId = targetToSession.get(targetId);
645
+ if (sessionId) {
646
+ await detach(sessionId);
647
+ }
648
+ }
649
+
650
+ function getSessionForTarget(targetId) {
651
+ return targetToSession.get(targetId);
652
+ }
653
+
654
+ function getTargetForSession(sessionId) {
655
+ return sessions.get(sessionId)?.targetId;
656
+ }
657
+
658
+ function isAttached(targetId) {
659
+ return targetToSession.has(targetId);
660
+ }
661
+
662
+ function getAllSessions() {
663
+ return Array.from(sessions.entries()).map(([sessionId, data]) => ({
664
+ sessionId,
665
+ targetId: data.targetId
666
+ }));
667
+ }
668
+
669
+ async function detachAll() {
670
+ const sessionIds = Array.from(sessions.keys());
671
+ await Promise.all(sessionIds.map(s => detach(s)));
672
+ }
673
+
674
+ async function cleanup() {
675
+ await detachAll();
676
+ if (boundHandlers) {
677
+ connection.off('Target.attachedToTarget', boundHandlers.onAttached);
678
+ connection.off('Target.detachedFromTarget', boundHandlers.onDetached);
679
+ connection.off('Target.targetDestroyed', boundHandlers.onTargetDestroyed);
680
+ }
681
+ sessions.clear();
682
+ targetToSession.clear();
683
+ pendingAttach.clear();
684
+ }
685
+
686
+ return {
687
+ attach,
688
+ detach,
689
+ detachByTarget,
690
+ getSessionForTarget,
691
+ getTargetForSession,
692
+ isAttached,
693
+ getAllSessions,
694
+ detachAll,
695
+ cleanup
696
+ };
697
+ }
698
+
699
+ // ============================================================================
700
+ // Page Session
701
+ // ============================================================================
702
+
703
+ /**
704
+ * Create a page session for CDP communication with a specific page
705
+ * @param {Object} connection - CDP connection
706
+ * @param {string} sessionId - Session ID
707
+ * @param {string} targetId - Target ID
708
+ * @returns {Object} Page session interface
709
+ */
710
+ export function createPageSession(connection, sessionId, targetId) {
711
+ let valid = true;
712
+ let detachHandler = null;
713
+
714
+ function onDetached(params) {
715
+ if (params.sessionId === sessionId) {
716
+ valid = false;
717
+ if (detachHandler) {
718
+ connection.off('Target.detachedFromTarget', detachHandler);
719
+ }
720
+ }
721
+ }
722
+
723
+ detachHandler = onDetached;
724
+ connection.on('Target.detachedFromTarget', detachHandler);
725
+
726
+ async function send(method, params = {}) {
727
+ if (!valid) {
728
+ throw new Error(`Session ${sessionId} is no longer valid (target was closed or detached)`);
729
+ }
730
+ return connection.sendToSession(sessionId, method, params);
731
+ }
732
+
733
+ function on(event, callback) {
734
+ connection.on(`${sessionId}:${event}`, callback);
735
+ }
736
+
737
+ function off(event, callback) {
738
+ connection.off(`${sessionId}:${event}`, callback);
739
+ }
740
+
741
+ function dispose() {
742
+ valid = false;
743
+ if (detachHandler) {
744
+ connection.off('Target.detachedFromTarget', detachHandler);
745
+ }
746
+ }
747
+
748
+ return {
749
+ send,
750
+ on,
751
+ off,
752
+ dispose,
753
+ isValid: () => valid,
754
+ get sessionId() { return sessionId; },
755
+ get targetId() { return targetId; }
756
+ };
757
+ }
758
+
759
+ // ============================================================================
760
+ // Browser Client
761
+ // ============================================================================
762
+
763
+ /**
764
+ * Create a high-level browser client
765
+ * @param {Object} [options] - Configuration options
766
+ * @param {string} [options.host='localhost'] - Chrome host
767
+ * @param {number} [options.port=9222] - Chrome debugging port
768
+ * @param {number} [options.connectTimeout=30000] - Connection timeout in ms
769
+ * @returns {Object} Browser client interface
770
+ */
771
+ export function createBrowser(options = {}) {
772
+ const host = options.host ?? 'localhost';
773
+ const port = options.port ?? 9222;
774
+ const connectTimeout = options.connectTimeout ?? 30000;
775
+
776
+ let discovery = createDiscovery(host, port, connectTimeout);
777
+ let connection = null;
778
+ let targetManager = null;
779
+ let sessionRegistry = null;
780
+ let connected = false;
781
+ const targetLocks = new Map();
782
+
783
+ async function acquireLock(targetId) {
784
+ // Wait for any existing lock to be released
785
+ while (targetLocks.has(targetId)) {
786
+ await targetLocks.get(targetId);
787
+ }
788
+ // Create a new lock - this Promise will resolve when releaseLock is called
789
+ let releaseFn;
790
+ const lockPromise = new Promise(resolve => {
791
+ releaseFn = resolve;
792
+ });
793
+ targetLocks.set(targetId, lockPromise);
794
+ // Return a lock handle that can be used to release
795
+ return { promise: lockPromise, release: releaseFn };
796
+ }
797
+
798
+ function releaseLock(targetId, lock) {
799
+ if (targetLocks.get(targetId) === lock.promise) {
800
+ targetLocks.delete(targetId);
801
+ }
802
+ lock.release();
803
+ }
804
+
805
+ function ensureConnected() {
806
+ if (!connected) {
807
+ throw new Error('BrowserClient not connected. Call connect() first.');
808
+ }
809
+ }
810
+
811
+ async function doConnect() {
812
+ const version = await discovery.getVersion();
813
+ connection = createConnection(version.webSocketDebuggerUrl);
814
+ await connection.connect();
815
+
816
+ targetManager = createTargetManager(connection);
817
+ sessionRegistry = createSessionRegistry(connection);
818
+
819
+ await targetManager.enableDiscovery();
820
+ connected = true;
821
+ }
822
+
823
+ async function connect() {
824
+ if (connected) return;
825
+
826
+ const connectPromise = doConnect();
827
+ const timeoutPromise = new Promise((_, reject) => {
828
+ setTimeout(() => {
829
+ reject(timeoutError(`Connection to Chrome timed out after ${connectTimeout}ms`));
830
+ }, connectTimeout);
831
+ });
832
+
833
+ await Promise.race([connectPromise, timeoutPromise]);
834
+ }
835
+
836
+ async function disconnect() {
837
+ if (!connected) return;
838
+
839
+ await sessionRegistry.cleanup();
840
+ await targetManager.cleanup();
841
+ await connection.close();
842
+ connected = false;
843
+ }
844
+
845
+ async function getPages() {
846
+ ensureConnected();
847
+ return targetManager.getPages();
848
+ }
849
+
850
+ async function newPage(url = 'about:blank') {
851
+ ensureConnected();
852
+
853
+ const targetId = await targetManager.createTarget(url);
854
+ const sessionId = await sessionRegistry.attach(targetId);
855
+
856
+ return createPageSession(connection, sessionId, targetId);
857
+ }
858
+
859
+ async function attachToPage(targetId) {
860
+ ensureConnected();
861
+ const lock = await acquireLock(targetId);
862
+ try {
863
+ const sessionId = await sessionRegistry.attach(targetId);
864
+ return createPageSession(connection, sessionId, targetId);
865
+ } finally {
866
+ releaseLock(targetId, lock);
867
+ }
868
+ }
869
+
870
+ async function findPage(urlPattern) {
871
+ ensureConnected();
872
+
873
+ const pages = await getPages();
874
+ const regex = urlPattern instanceof RegExp ? urlPattern : new RegExp(urlPattern);
875
+ const target = pages.find(p => regex.test(p.url));
876
+
877
+ if (!target) return null;
878
+ return attachToPage(target.targetId);
879
+ }
880
+
881
+ async function closePage(targetId) {
882
+ ensureConnected();
883
+ const lock = await acquireLock(targetId);
884
+ try {
885
+ await sessionRegistry.detachByTarget(targetId);
886
+ await targetManager.closeTarget(targetId);
887
+ } finally {
888
+ releaseLock(targetId, lock);
889
+ }
890
+ }
891
+
892
+ return {
893
+ connect,
894
+ disconnect,
895
+ getPages,
896
+ newPage,
897
+ attachToPage,
898
+ findPage,
899
+ closePage,
900
+ isConnected: () => connected,
901
+ get connection() { return connection; },
902
+ get targets() { return targetManager; },
903
+ get sessions() { return sessionRegistry; }
904
+ };
905
+ }