@canaryai/cli 0.1.5 → 0.1.9

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,671 @@
1
+ // src/local-browser/host.ts
2
+ import { chromium } from "playwright";
3
+ var HEARTBEAT_INTERVAL_MS = 3e4;
4
+ var RECONNECT_DELAY_MS = 1e3;
5
+ var MAX_RECONNECT_DELAY_MS = 3e4;
6
+ var MAX_RECONNECT_ATTEMPTS = 10;
7
+ var LocalBrowserHost = class {
8
+ options;
9
+ ws = null;
10
+ browser = null;
11
+ context = null;
12
+ page = null;
13
+ pendingDialogs = [];
14
+ heartbeatTimer = null;
15
+ reconnectAttempts = 0;
16
+ isShuttingDown = false;
17
+ lastSnapshotYaml = "";
18
+ constructor(options) {
19
+ this.options = options;
20
+ }
21
+ log(level, message, data) {
22
+ if (this.options.onLog) {
23
+ this.options.onLog(level, message, data);
24
+ } else {
25
+ const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
26
+ fn(`[LocalBrowserHost] ${message}`, data ?? "");
27
+ }
28
+ }
29
+ // =========================================================================
30
+ // Lifecycle
31
+ // =========================================================================
32
+ async start() {
33
+ this.log("info", "Starting local browser host", {
34
+ browserMode: this.options.browserMode,
35
+ sessionId: this.options.sessionId
36
+ });
37
+ await this.connectWebSocket();
38
+ await this.launchBrowser();
39
+ this.sendSessionEvent("browser_ready");
40
+ }
41
+ async stop() {
42
+ this.isShuttingDown = true;
43
+ this.log("info", "Stopping local browser host");
44
+ this.stopHeartbeat();
45
+ if (this.ws) {
46
+ try {
47
+ this.ws.close(1e3, "Shutdown");
48
+ } catch {
49
+ }
50
+ this.ws = null;
51
+ }
52
+ if (this.context) {
53
+ try {
54
+ await this.context.close();
55
+ } catch {
56
+ }
57
+ this.context = null;
58
+ }
59
+ if (this.browser) {
60
+ try {
61
+ await this.browser.close();
62
+ } catch {
63
+ }
64
+ this.browser = null;
65
+ }
66
+ this.page = null;
67
+ this.log("info", "Local browser host stopped");
68
+ }
69
+ // =========================================================================
70
+ // WebSocket Connection
71
+ // =========================================================================
72
+ async connectWebSocket() {
73
+ return new Promise((resolve, reject) => {
74
+ const wsUrl = `${this.options.apiUrl.replace("http", "ws")}/local-browser/sessions/${this.options.sessionId}/connect?token=${this.options.wsToken}`;
75
+ this.log("info", "Connecting to cloud API", { url: wsUrl.replace(/token=.*/, "token=***") });
76
+ const ws = new WebSocket(wsUrl);
77
+ ws.onopen = () => {
78
+ this.log("info", "Connected to cloud API");
79
+ this.ws = ws;
80
+ this.reconnectAttempts = 0;
81
+ this.startHeartbeat();
82
+ resolve();
83
+ };
84
+ ws.onmessage = (event) => {
85
+ this.handleMessage(event.data);
86
+ };
87
+ ws.onerror = (event) => {
88
+ this.log("error", "WebSocket error", event);
89
+ };
90
+ ws.onclose = () => {
91
+ this.log("info", "WebSocket closed");
92
+ this.stopHeartbeat();
93
+ this.ws = null;
94
+ if (!this.isShuttingDown) {
95
+ this.scheduleReconnect();
96
+ }
97
+ };
98
+ setTimeout(() => {
99
+ if (!this.ws) {
100
+ reject(new Error("WebSocket connection timeout"));
101
+ }
102
+ }, 3e4);
103
+ });
104
+ }
105
+ scheduleReconnect() {
106
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
107
+ this.log("error", "Max reconnection attempts reached, giving up");
108
+ this.stop();
109
+ return;
110
+ }
111
+ const delay = Math.min(
112
+ RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
113
+ MAX_RECONNECT_DELAY_MS
114
+ );
115
+ this.reconnectAttempts++;
116
+ this.log("info", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
117
+ setTimeout(async () => {
118
+ try {
119
+ await this.connectWebSocket();
120
+ this.sendSessionEvent("connected");
121
+ if (this.page) {
122
+ this.sendSessionEvent("browser_ready");
123
+ }
124
+ } catch (error) {
125
+ this.log("error", "Reconnection failed", error);
126
+ this.scheduleReconnect();
127
+ }
128
+ }, delay);
129
+ }
130
+ // =========================================================================
131
+ // Heartbeat
132
+ // =========================================================================
133
+ startHeartbeat() {
134
+ this.stopHeartbeat();
135
+ this.heartbeatTimer = setInterval(() => {
136
+ if (this.ws?.readyState === WebSocket.OPEN) {
137
+ const ping = {
138
+ type: "heartbeat",
139
+ id: crypto.randomUUID(),
140
+ timestamp: Date.now(),
141
+ direction: "pong"
142
+ };
143
+ this.ws.send(JSON.stringify(ping));
144
+ }
145
+ }, HEARTBEAT_INTERVAL_MS);
146
+ }
147
+ stopHeartbeat() {
148
+ if (this.heartbeatTimer) {
149
+ clearInterval(this.heartbeatTimer);
150
+ this.heartbeatTimer = null;
151
+ }
152
+ }
153
+ // =========================================================================
154
+ // Browser Management
155
+ // =========================================================================
156
+ async launchBrowser() {
157
+ const { browserMode, cdpUrl, headless = true, storageStatePath } = this.options;
158
+ if (browserMode === "cdp" && cdpUrl) {
159
+ this.log("info", "Connecting to existing Chrome via CDP", { cdpUrl });
160
+ this.browser = await chromium.connectOverCDP(cdpUrl);
161
+ const contexts = this.browser.contexts();
162
+ this.context = contexts[0] ?? await this.browser.newContext();
163
+ const pages = this.context.pages();
164
+ this.page = pages[0] ?? await this.context.newPage();
165
+ } else {
166
+ this.log("info", "Launching new Playwright browser", { headless });
167
+ this.browser = await chromium.launch({
168
+ headless,
169
+ args: ["--no-sandbox"]
170
+ });
171
+ const contextOptions = {
172
+ viewport: { width: 1920, height: 1080 }
173
+ };
174
+ if (storageStatePath) {
175
+ try {
176
+ await Bun.file(storageStatePath).exists();
177
+ contextOptions.storageState = storageStatePath;
178
+ this.log("info", "Loading storage state", { storageStatePath });
179
+ } catch {
180
+ this.log("debug", "Storage state file not found, starting fresh");
181
+ }
182
+ }
183
+ this.context = await this.browser.newContext(contextOptions);
184
+ this.page = await this.context.newPage();
185
+ }
186
+ this.page.on("dialog", (dialog) => {
187
+ this.pendingDialogs.push(dialog);
188
+ });
189
+ this.log("info", "Browser ready");
190
+ }
191
+ // =========================================================================
192
+ // Message Handling
193
+ // =========================================================================
194
+ handleMessage(data) {
195
+ try {
196
+ const message = JSON.parse(data);
197
+ if (message.type === "heartbeat" && message.direction === "ping") {
198
+ const pong = {
199
+ type: "heartbeat",
200
+ id: crypto.randomUUID(),
201
+ timestamp: Date.now(),
202
+ direction: "pong"
203
+ };
204
+ this.ws?.send(JSON.stringify(pong));
205
+ return;
206
+ }
207
+ if (message.type === "command") {
208
+ this.handleCommand(message);
209
+ return;
210
+ }
211
+ this.log("debug", "Received unknown message type", message);
212
+ } catch (error) {
213
+ this.log("error", "Failed to parse message", { error, data });
214
+ }
215
+ }
216
+ async handleCommand(command) {
217
+ const startTime = Date.now();
218
+ this.log("debug", `Executing command: ${command.method}`, { id: command.id });
219
+ try {
220
+ const result = await this.executeMethod(command.method, command.args);
221
+ const response = {
222
+ type: "response",
223
+ id: crypto.randomUUID(),
224
+ timestamp: Date.now(),
225
+ requestId: command.id,
226
+ success: true,
227
+ result
228
+ };
229
+ this.ws?.send(JSON.stringify(response));
230
+ this.log("debug", `Command completed: ${command.method}`, {
231
+ id: command.id,
232
+ durationMs: Date.now() - startTime
233
+ });
234
+ } catch (error) {
235
+ const errorMessage = error instanceof Error ? error.message : String(error);
236
+ const response = {
237
+ type: "response",
238
+ id: crypto.randomUUID(),
239
+ timestamp: Date.now(),
240
+ requestId: command.id,
241
+ success: false,
242
+ error: errorMessage,
243
+ stack: error instanceof Error ? error.stack : void 0
244
+ };
245
+ this.ws?.send(JSON.stringify(response));
246
+ this.log("error", `Command failed: ${command.method}`, {
247
+ id: command.id,
248
+ error: errorMessage
249
+ });
250
+ }
251
+ }
252
+ sendSessionEvent(event, error) {
253
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
254
+ const message = {
255
+ type: "session",
256
+ id: crypto.randomUUID(),
257
+ timestamp: Date.now(),
258
+ event,
259
+ browserMode: this.options.browserMode,
260
+ error
261
+ };
262
+ this.ws.send(JSON.stringify(message));
263
+ }
264
+ // =========================================================================
265
+ // Method Execution
266
+ // =========================================================================
267
+ async executeMethod(method, args) {
268
+ switch (method) {
269
+ // Lifecycle
270
+ case "connect":
271
+ return this.connect(args[0]);
272
+ case "disconnect":
273
+ return this.disconnect();
274
+ // Navigation
275
+ case "navigate":
276
+ return this.navigate(args[0], args[1]);
277
+ case "navigateBack":
278
+ return this.navigateBack(args[0]);
279
+ // Page Inspection
280
+ case "snapshot":
281
+ return this.snapshot(args[0]);
282
+ case "takeScreenshot":
283
+ return this.takeScreenshot(args[0]);
284
+ case "evaluate":
285
+ return this.evaluate(args[0], args[1]);
286
+ case "runCode":
287
+ return this.runCode(args[0], args[1]);
288
+ case "consoleMessages":
289
+ return this.consoleMessages(args[0]);
290
+ case "networkRequests":
291
+ return this.networkRequests(args[0]);
292
+ // Interaction
293
+ case "click":
294
+ return this.click(args[0], args[1], args[2]);
295
+ case "clickAtCoordinates":
296
+ return this.clickAtCoordinates(
297
+ args[0],
298
+ args[1],
299
+ args[2],
300
+ args[3]
301
+ );
302
+ case "moveToCoordinates":
303
+ return this.moveToCoordinates(
304
+ args[0],
305
+ args[1],
306
+ args[2],
307
+ args[3]
308
+ );
309
+ case "dragCoordinates":
310
+ return this.dragCoordinates(
311
+ args[0],
312
+ args[1],
313
+ args[2],
314
+ args[3],
315
+ args[4],
316
+ args[5]
317
+ );
318
+ case "hover":
319
+ return this.hover(args[0], args[1], args[2]);
320
+ case "drag":
321
+ return this.drag(
322
+ args[0],
323
+ args[1],
324
+ args[2],
325
+ args[3],
326
+ args[4]
327
+ );
328
+ case "type":
329
+ return this.type(
330
+ args[0],
331
+ args[1],
332
+ args[2],
333
+ args[3],
334
+ args[4]
335
+ );
336
+ case "pressKey":
337
+ return this.pressKey(args[0], args[1]);
338
+ case "fillForm":
339
+ return this.fillForm(args[0], args[1]);
340
+ case "selectOption":
341
+ return this.selectOption(
342
+ args[0],
343
+ args[1],
344
+ args[2],
345
+ args[3]
346
+ );
347
+ case "fileUpload":
348
+ return this.fileUpload(args[0], args[1]);
349
+ // Dialogs
350
+ case "handleDialog":
351
+ return this.handleDialog(args[0], args[1], args[2]);
352
+ // Waiting
353
+ case "waitFor":
354
+ return this.waitFor(args[0]);
355
+ // Browser Management
356
+ case "close":
357
+ return this.closePage(args[0]);
358
+ case "resize":
359
+ return this.resize(args[0], args[1], args[2]);
360
+ case "tabs":
361
+ return this.tabs(args[0], args[1], args[2]);
362
+ // Storage
363
+ case "getStorageState":
364
+ return this.getStorageState(args[0]);
365
+ case "getCurrentUrl":
366
+ return this.getCurrentUrl(args[0]);
367
+ case "getTitle":
368
+ return this.getTitle(args[0]);
369
+ case "getLinks":
370
+ return this.getLinks(args[0]);
371
+ case "getElementBoundingBox":
372
+ return this.getElementBoundingBox(args[0], args[1]);
373
+ // Tracing
374
+ case "startTracing":
375
+ return this.startTracing(args[0]);
376
+ case "stopTracing":
377
+ return this.stopTracing(args[0]);
378
+ // Video
379
+ case "isVideoRecordingEnabled":
380
+ return false;
381
+ // Video not supported in CLI host currently
382
+ case "saveVideo":
383
+ return null;
384
+ case "getVideoPath":
385
+ return null;
386
+ default:
387
+ throw new Error(`Unknown method: ${method}`);
388
+ }
389
+ }
390
+ // =========================================================================
391
+ // IBrowserClient Method Implementations
392
+ // =========================================================================
393
+ getPage() {
394
+ if (!this.page) throw new Error("No page available");
395
+ return this.page;
396
+ }
397
+ resolveRef(ref) {
398
+ return this.getPage().locator(`aria-ref=${ref}`);
399
+ }
400
+ async connect(_options) {
401
+ return;
402
+ }
403
+ async disconnect() {
404
+ await this.stop();
405
+ }
406
+ async navigate(url, _opts) {
407
+ const page = this.getPage();
408
+ await page.goto(url, { waitUntil: "domcontentloaded" });
409
+ await page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
410
+ });
411
+ return this.captureSnapshot();
412
+ }
413
+ async navigateBack(_opts) {
414
+ await this.getPage().goBack();
415
+ return this.captureSnapshot();
416
+ }
417
+ async snapshot(_opts) {
418
+ return this.captureSnapshot();
419
+ }
420
+ async captureSnapshot() {
421
+ const page = this.getPage();
422
+ this.lastSnapshotYaml = await page._snapshotForAI({ mode: "full" });
423
+ return this.lastSnapshotYaml;
424
+ }
425
+ async takeScreenshot(opts) {
426
+ const page = this.getPage();
427
+ const buffer = await page.screenshot({
428
+ type: opts?.type ?? "jpeg",
429
+ fullPage: opts?.fullPage ?? false
430
+ });
431
+ const mime = opts?.type === "png" ? "image/png" : "image/jpeg";
432
+ return `data:${mime};base64,${buffer.toString("base64")}`;
433
+ }
434
+ async evaluate(fn, _opts) {
435
+ const page = this.getPage();
436
+ return page.evaluate(new Function(`return (${fn})()`));
437
+ }
438
+ async runCode(code, _opts) {
439
+ const page = this.getPage();
440
+ const fn = new Function("page", `return (async () => { ${code} })()`);
441
+ return fn(page);
442
+ }
443
+ async consoleMessages(_opts) {
444
+ return "Console message capture not implemented in CLI host";
445
+ }
446
+ async networkRequests(_opts) {
447
+ return "Network request capture not implemented in CLI host";
448
+ }
449
+ async click(ref, _elementDesc, opts) {
450
+ const locator = this.resolveRef(ref);
451
+ await locator.scrollIntoViewIfNeeded({ timeout: 5e3 }).catch(() => {
452
+ });
453
+ const box = await locator.boundingBox();
454
+ if (box) {
455
+ const centerX = box.x + box.width / 2;
456
+ const centerY = box.y + box.height / 2;
457
+ const page = this.getPage();
458
+ if (opts?.modifiers?.length) {
459
+ for (const mod of opts.modifiers) {
460
+ await page.keyboard.down(mod);
461
+ }
462
+ }
463
+ if (opts?.doubleClick) {
464
+ await page.mouse.dblclick(centerX, centerY);
465
+ } else {
466
+ await page.mouse.click(centerX, centerY);
467
+ }
468
+ if (opts?.modifiers?.length) {
469
+ for (const mod of opts.modifiers) {
470
+ await page.keyboard.up(mod);
471
+ }
472
+ }
473
+ } else {
474
+ if (opts?.doubleClick) {
475
+ await locator.dblclick({ timeout: opts?.timeoutMs ?? 3e4 });
476
+ } else {
477
+ await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
478
+ }
479
+ }
480
+ }
481
+ async clickAtCoordinates(x, y, _elementDesc, opts) {
482
+ const page = this.getPage();
483
+ if (opts?.doubleClick) {
484
+ await page.mouse.dblclick(x, y);
485
+ } else {
486
+ await page.mouse.click(x, y);
487
+ }
488
+ }
489
+ async moveToCoordinates(x, y, _elementDesc, _opts) {
490
+ await this.getPage().mouse.move(x, y);
491
+ }
492
+ async dragCoordinates(startX, startY, endX, endY, _elementDesc, _opts) {
493
+ const page = this.getPage();
494
+ await page.mouse.move(startX, startY);
495
+ await page.mouse.down();
496
+ await page.mouse.move(endX, endY);
497
+ await page.mouse.up();
498
+ }
499
+ async hover(ref, _elementDesc, opts) {
500
+ await this.resolveRef(ref).hover({ timeout: opts?.timeoutMs ?? 3e4 });
501
+ }
502
+ async drag(startRef, _startElement, endRef, _endElement, opts) {
503
+ const startLocator = this.resolveRef(startRef);
504
+ const endLocator = this.resolveRef(endRef);
505
+ await startLocator.dragTo(endLocator, { timeout: opts?.timeoutMs ?? 6e4 });
506
+ }
507
+ async type(ref, text, _elementDesc, submit, opts) {
508
+ const locator = this.resolveRef(ref);
509
+ await locator.clear();
510
+ await locator.pressSequentially(text, {
511
+ delay: opts?.delay ?? 0,
512
+ timeout: opts?.timeoutMs ?? 3e4
513
+ });
514
+ if (submit) {
515
+ await locator.press("Enter");
516
+ }
517
+ }
518
+ async pressKey(key, _opts) {
519
+ await this.getPage().keyboard.press(key);
520
+ }
521
+ async fillForm(fields, opts) {
522
+ for (const field of fields) {
523
+ const locator = this.resolveRef(field.ref);
524
+ const fieldType = field.type ?? "textbox";
525
+ switch (fieldType) {
526
+ case "checkbox": {
527
+ const isChecked = await locator.isChecked();
528
+ const shouldBeChecked = field.value === "true";
529
+ if (shouldBeChecked !== isChecked) {
530
+ await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
531
+ }
532
+ break;
533
+ }
534
+ case "radio":
535
+ await locator.check({ timeout: opts?.timeoutMs ?? 3e4 });
536
+ break;
537
+ case "combobox":
538
+ await locator.selectOption(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
539
+ break;
540
+ default:
541
+ await locator.fill(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
542
+ }
543
+ }
544
+ }
545
+ async selectOption(ref, value, _elementDesc, opts) {
546
+ await this.resolveRef(ref).selectOption(value, { timeout: opts?.timeoutMs ?? 3e4 });
547
+ }
548
+ async fileUpload(paths, opts) {
549
+ const fileChooser = await this.getPage().waitForEvent("filechooser", {
550
+ timeout: opts?.timeoutMs ?? 3e4
551
+ });
552
+ await fileChooser.setFiles(paths);
553
+ }
554
+ async handleDialog(action, promptText, _opts) {
555
+ const dialog = this.pendingDialogs.shift();
556
+ if (dialog) {
557
+ if (action === "accept") {
558
+ await dialog.accept(promptText);
559
+ } else {
560
+ await dialog.dismiss();
561
+ }
562
+ }
563
+ }
564
+ async waitFor(opts) {
565
+ const page = this.getPage();
566
+ const timeout = opts?.timeout ?? opts?.timeoutMs ?? 3e4;
567
+ if (opts?.timeSec) {
568
+ await page.waitForTimeout(opts.timeSec * 1e3);
569
+ return;
570
+ }
571
+ if (opts?.text) {
572
+ await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
573
+ return;
574
+ }
575
+ if (opts?.textGone) {
576
+ await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
577
+ return;
578
+ }
579
+ if (opts?.selector) {
580
+ await page.locator(opts.selector).waitFor({
581
+ state: opts.state ?? "visible",
582
+ timeout
583
+ });
584
+ }
585
+ }
586
+ async closePage(_opts) {
587
+ await this.getPage().close();
588
+ this.page = null;
589
+ }
590
+ async resize(width, height, _opts) {
591
+ await this.getPage().setViewportSize({ width, height });
592
+ }
593
+ async tabs(action, index, _opts) {
594
+ if (!this.context) throw new Error("No context available");
595
+ const pages = this.context.pages();
596
+ switch (action) {
597
+ case "list":
598
+ return Promise.all(
599
+ pages.map(async (p, i) => ({
600
+ index: i,
601
+ url: p.url(),
602
+ title: await p.title().catch(() => "")
603
+ }))
604
+ );
605
+ case "new": {
606
+ const newPage = await this.context.newPage();
607
+ this.page = newPage;
608
+ newPage.on("dialog", (dialog) => this.pendingDialogs.push(dialog));
609
+ return { index: pages.length };
610
+ }
611
+ case "close":
612
+ if (index !== void 0 && pages[index]) {
613
+ await pages[index].close();
614
+ } else {
615
+ await this.page?.close();
616
+ }
617
+ this.page = this.context.pages()[0] ?? null;
618
+ break;
619
+ case "select":
620
+ if (index !== void 0 && pages[index]) {
621
+ this.page = pages[index];
622
+ }
623
+ break;
624
+ }
625
+ return null;
626
+ }
627
+ async getStorageState(_opts) {
628
+ if (!this.context) throw new Error("No context available");
629
+ return this.context.storageState();
630
+ }
631
+ async getCurrentUrl(_opts) {
632
+ return this.getPage().url();
633
+ }
634
+ async getTitle(_opts) {
635
+ return this.getPage().title();
636
+ }
637
+ async getLinks(_opts) {
638
+ const page = this.getPage();
639
+ return page.$$eval(
640
+ "a[href]",
641
+ (links) => links.map((a) => a.href).filter((h) => !!h && (h.startsWith("http://") || h.startsWith("https://")))
642
+ );
643
+ }
644
+ async getElementBoundingBox(ref, _opts) {
645
+ const locator = this.resolveRef(ref);
646
+ const box = await locator.boundingBox();
647
+ if (!box) return null;
648
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
649
+ }
650
+ async startTracing(_opts) {
651
+ if (!this.context) throw new Error("No context available");
652
+ await this.context.tracing.start({ screenshots: true, snapshots: true });
653
+ }
654
+ async stopTracing(_opts) {
655
+ if (!this.context) throw new Error("No context available");
656
+ const tracePath = `/tmp/trace-${Date.now()}.zip`;
657
+ await this.context.tracing.stop({ path: tracePath });
658
+ return {
659
+ trace: tracePath,
660
+ network: "",
661
+ resources: "",
662
+ directory: null,
663
+ legend: `Trace saved to ${tracePath}`
664
+ };
665
+ }
666
+ };
667
+
668
+ export {
669
+ LocalBrowserHost
670
+ };
671
+ //# sourceMappingURL=chunk-G2X3H7AM.js.map