@mastra/browser-viewer 0.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/README.md +101 -0
- package/dist/index.cjs +796 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +335 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.js +768 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
BrowserViewer: () => BrowserViewer,
|
|
24
|
+
BrowserViewerThreadManager: () => BrowserViewerThreadManager
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/browser-viewer.ts
|
|
29
|
+
var import_browser2 = require("@mastra/core/browser");
|
|
30
|
+
|
|
31
|
+
// src/thread-manager.ts
|
|
32
|
+
var import_node_fs = require("fs");
|
|
33
|
+
var import_node_path = require("path");
|
|
34
|
+
var import_browser = require("@mastra/core/browser");
|
|
35
|
+
var import_playwright_core = require("playwright-core");
|
|
36
|
+
var BrowserViewerThreadManager = class extends import_browser.ThreadManager {
|
|
37
|
+
browserConfig;
|
|
38
|
+
onBrowserCreated;
|
|
39
|
+
onBrowserClosed;
|
|
40
|
+
/** Map of thread ID to session info (for 'thread' scope) */
|
|
41
|
+
threadSessions = /* @__PURE__ */ new Map();
|
|
42
|
+
/** Shared session info (for 'shared' scope) */
|
|
43
|
+
sharedSession = null;
|
|
44
|
+
/** Cached CDP sessions for input injection, keyed by threadId */
|
|
45
|
+
inputCdpSessions = /* @__PURE__ */ new Map();
|
|
46
|
+
constructor(config) {
|
|
47
|
+
super(config);
|
|
48
|
+
this.browserConfig = config.browserConfig;
|
|
49
|
+
this.onBrowserCreated = config.onBrowserCreated;
|
|
50
|
+
this.onBrowserClosed = config.onBrowserClosed;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if a thread should use the shared session slot.
|
|
54
|
+
* In shared scope, all threads use the shared session.
|
|
55
|
+
* In thread scope, DEFAULT_THREAD_ID also uses the shared session.
|
|
56
|
+
*/
|
|
57
|
+
usesSharedSlot(threadId) {
|
|
58
|
+
return this.scope === "shared" || threadId === import_browser.DEFAULT_THREAD_ID;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the viewer session for a thread, using consistent routing.
|
|
62
|
+
* Handles both shared and thread-scoped sessions.
|
|
63
|
+
*/
|
|
64
|
+
getViewerSession(threadId) {
|
|
65
|
+
if (this.usesSharedSlot(threadId)) {
|
|
66
|
+
return this.sharedSession;
|
|
67
|
+
}
|
|
68
|
+
return this.threadSessions.get(threadId) ?? null;
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Session Storage & Cleanup Helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
/**
|
|
74
|
+
* Store a session in the appropriate slot based on scope.
|
|
75
|
+
* Consolidates session storage logic used by createSession, createSharedSession,
|
|
76
|
+
* createSharedSessionFromCdp, and connectToExternalCdp.
|
|
77
|
+
*/
|
|
78
|
+
storeSession(session, threadId) {
|
|
79
|
+
if (this.usesSharedSlot(threadId)) {
|
|
80
|
+
this.sharedSession = session;
|
|
81
|
+
this.sessions.set(import_browser.DEFAULT_THREAD_ID, session);
|
|
82
|
+
this.setSharedManager(session.browser);
|
|
83
|
+
} else {
|
|
84
|
+
this.threadSessions.set(threadId, session);
|
|
85
|
+
this.sessions.set(threadId, session);
|
|
86
|
+
this.threadManagers.set(threadId, session.browser);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Clear a session from the appropriate slot based on scope.
|
|
91
|
+
* Must be called BEFORE async cleanup operations to prevent double callbacks
|
|
92
|
+
* from disconnect handlers.
|
|
93
|
+
*/
|
|
94
|
+
clearSessionState(threadId) {
|
|
95
|
+
if (this.usesSharedSlot(threadId)) {
|
|
96
|
+
this.sharedSession = null;
|
|
97
|
+
this.clearSharedManager();
|
|
98
|
+
this.sessions.delete(import_browser.DEFAULT_THREAD_ID);
|
|
99
|
+
} else {
|
|
100
|
+
this.threadSessions.delete(threadId);
|
|
101
|
+
this.threadManagers.delete(threadId);
|
|
102
|
+
this.sessions.delete(threadId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Clean up a session's resources (CDP session, browser, server).
|
|
107
|
+
* Consolidates cleanup logic used by closeThreadBrowser, closeSharedBrowser,
|
|
108
|
+
* and doDestroySession.
|
|
109
|
+
*
|
|
110
|
+
* @param session - The session to clean up
|
|
111
|
+
* @param threadId - The thread ID (for onBrowserClosed callback)
|
|
112
|
+
*/
|
|
113
|
+
async cleanupSession(session, threadId) {
|
|
114
|
+
this.clearSessionState(threadId);
|
|
115
|
+
this.inputCdpSessions.delete(threadId);
|
|
116
|
+
if (session.cdpSession) {
|
|
117
|
+
try {
|
|
118
|
+
await session.cdpSession.detach();
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await session.browser.close();
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
if (session.browserServer) {
|
|
127
|
+
try {
|
|
128
|
+
await session.browserServer.close();
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
this.onBrowserClosed?.(threadId);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Launch a new browser instance and return the components.
|
|
136
|
+
* Consolidates the launch logic shared by createSession and createSharedSession.
|
|
137
|
+
*
|
|
138
|
+
* @param threadId - Thread ID for logging and disconnect handler
|
|
139
|
+
*/
|
|
140
|
+
async launchBrowser(threadId) {
|
|
141
|
+
const cdpPort = this.browserConfig.cdpPort ?? 0;
|
|
142
|
+
this.logger?.debug?.(`Launching Chrome for thread ${threadId} with remote-debugging-port=${cdpPort}`);
|
|
143
|
+
const launchOptions = {
|
|
144
|
+
headless: this.browserConfig.headless ?? false,
|
|
145
|
+
args: [`--remote-debugging-port=${cdpPort}`, "--no-first-run", "--no-default-browser-check"]
|
|
146
|
+
};
|
|
147
|
+
if (this.browserConfig.executablePath) {
|
|
148
|
+
launchOptions.executablePath = this.browserConfig.executablePath;
|
|
149
|
+
}
|
|
150
|
+
let browserServer = null;
|
|
151
|
+
let browser = null;
|
|
152
|
+
try {
|
|
153
|
+
browserServer = await import_playwright_core.chromium.launchServer(launchOptions);
|
|
154
|
+
const cdpUrl = this.discoverCdpUrl(browserServer);
|
|
155
|
+
browser = await import_playwright_core.chromium.connect(browserServer.wsEndpoint());
|
|
156
|
+
const context = await browser.newContext({
|
|
157
|
+
viewport: this.browserConfig.viewport ?? { width: 1280, height: 720 }
|
|
158
|
+
});
|
|
159
|
+
await context.newPage();
|
|
160
|
+
const pages = context.pages();
|
|
161
|
+
const cdpSession = pages[0] ? await context.newCDPSession(pages[0]) : null;
|
|
162
|
+
let disconnectHandled = false;
|
|
163
|
+
const handleDisconnect = () => {
|
|
164
|
+
if (disconnectHandled) return;
|
|
165
|
+
disconnectHandled = true;
|
|
166
|
+
this.handleBrowserDisconnected(threadId);
|
|
167
|
+
};
|
|
168
|
+
browserServer.on("close", handleDisconnect);
|
|
169
|
+
browser.on("disconnected", handleDisconnect);
|
|
170
|
+
try {
|
|
171
|
+
const browserCdpSession = await browser.newBrowserCDPSession();
|
|
172
|
+
await browserCdpSession.send("Target.setDiscoverTargets", { discover: true });
|
|
173
|
+
browserCdpSession.on("Target.targetDestroyed", async () => {
|
|
174
|
+
try {
|
|
175
|
+
const { targetInfos } = await browserCdpSession.send("Target.getTargets");
|
|
176
|
+
const pageTargets = targetInfos.filter(
|
|
177
|
+
(t) => t.type === "page" && !t.url.startsWith("chrome://") && !t.url.startsWith("devtools://")
|
|
178
|
+
);
|
|
179
|
+
if (pageTargets.length === 0) {
|
|
180
|
+
handleDisconnect();
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
handleDisconnect();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
browserCdpSession.on("Inspector.detached", handleDisconnect);
|
|
187
|
+
} catch {
|
|
188
|
+
this.logger?.debug?.("Failed to set up browser-level CDP target watching");
|
|
189
|
+
}
|
|
190
|
+
return { browserServer, browser, context, cdpSession, cdpUrl };
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.logger?.warn?.(`Failed to launch browser for thread ${threadId}: ${error}`);
|
|
193
|
+
await browser?.close().catch(() => {
|
|
194
|
+
});
|
|
195
|
+
await browserServer?.close().catch(() => {
|
|
196
|
+
});
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Get CDP URL for a specific thread.
|
|
202
|
+
*/
|
|
203
|
+
getCdpUrlForThread(threadId) {
|
|
204
|
+
const effectiveThreadId = threadId ?? import_browser.DEFAULT_THREAD_ID;
|
|
205
|
+
return this.getViewerSession(effectiveThreadId)?.cdpUrl ?? null;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get the active page for a thread.
|
|
209
|
+
*/
|
|
210
|
+
async getActivePageForThread(threadId) {
|
|
211
|
+
const effectiveThreadId = threadId ?? import_browser.DEFAULT_THREAD_ID;
|
|
212
|
+
const session = this.getViewerSession(effectiveThreadId);
|
|
213
|
+
if (!session?.context) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
return this.resolveActivePage(session.context);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the active page from a browser context.
|
|
220
|
+
* Uses last page (most recently opened) with fallback to first page.
|
|
221
|
+
*/
|
|
222
|
+
resolveActivePage(context) {
|
|
223
|
+
const pages = context.pages();
|
|
224
|
+
return pages[pages.length - 1] ?? pages[0] ?? null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Get or create a CDP session for the active page in a thread.
|
|
228
|
+
*
|
|
229
|
+
* CDP sessions are page-scoped, so we create a fresh one for the currently active page
|
|
230
|
+
* rather than caching one that may point to a closed or inactive page.
|
|
231
|
+
*/
|
|
232
|
+
async getCdpSessionForThread(threadId) {
|
|
233
|
+
const effectiveThreadId = threadId ?? import_browser.DEFAULT_THREAD_ID;
|
|
234
|
+
const session = this.getViewerSession(effectiveThreadId);
|
|
235
|
+
if (!session?.context) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (session.browser && !session.browser.isConnected()) {
|
|
239
|
+
this.handleBrowserDisconnected(effectiveThreadId);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const activePage = this.resolveActivePage(session.context);
|
|
243
|
+
if (!activePage) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const cached = this.inputCdpSessions.get(effectiveThreadId);
|
|
247
|
+
const currentUrl = activePage.url();
|
|
248
|
+
if (cached && cached.pageUrl === currentUrl) {
|
|
249
|
+
return cached.session;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const cdpSession = await session.context.newCDPSession(activePage);
|
|
253
|
+
this.inputCdpSessions.set(effectiveThreadId, { session: cdpSession, pageUrl: currentUrl });
|
|
254
|
+
return cdpSession;
|
|
255
|
+
} catch {
|
|
256
|
+
this.inputCdpSessions.delete(effectiveThreadId);
|
|
257
|
+
this.handleBrowserDisconnected(effectiveThreadId);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Get the browser context for a thread.
|
|
263
|
+
*/
|
|
264
|
+
getContextForThread(threadId) {
|
|
265
|
+
const effectiveThreadId = threadId ?? import_browser.DEFAULT_THREAD_ID;
|
|
266
|
+
return this.getViewerSession(effectiveThreadId)?.context ?? null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Create a fresh CDP session for the active page (not cached).
|
|
270
|
+
* Used by screencast which needs fresh sessions on tab switches.
|
|
271
|
+
*/
|
|
272
|
+
async createFreshCdpSession(threadId) {
|
|
273
|
+
const effectiveThreadId = threadId ?? import_browser.DEFAULT_THREAD_ID;
|
|
274
|
+
const session = this.getViewerSession(effectiveThreadId);
|
|
275
|
+
if (!session?.context) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const activePage = this.resolveActivePage(session.context);
|
|
279
|
+
if (!activePage) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return await session.context.newCDPSession(activePage);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Create a new session for a thread.
|
|
290
|
+
*/
|
|
291
|
+
async createSession(threadId) {
|
|
292
|
+
const savedState = this.getSavedBrowserState(threadId);
|
|
293
|
+
const { browserServer, browser, context, cdpSession, cdpUrl } = await this.launchBrowser(threadId);
|
|
294
|
+
const session = {
|
|
295
|
+
threadId,
|
|
296
|
+
createdAt: Date.now(),
|
|
297
|
+
browserState: savedState,
|
|
298
|
+
browserServer,
|
|
299
|
+
browser,
|
|
300
|
+
context,
|
|
301
|
+
cdpSession,
|
|
302
|
+
cdpUrl
|
|
303
|
+
};
|
|
304
|
+
this.storeSession(session, threadId);
|
|
305
|
+
this.logger?.debug?.(`Chrome launched for thread ${threadId}, CDP URL: ${cdpUrl}`);
|
|
306
|
+
this.onBrowserCreated?.(browser, threadId, cdpUrl);
|
|
307
|
+
return session;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Discover the actual CDP WebSocket URL from Chrome's DevToolsActivePort file.
|
|
311
|
+
*
|
|
312
|
+
* Playwright's BrowserServer exposes _userDataDirForTest which points to Chrome's
|
|
313
|
+
* user data directory. Chrome writes a DevToolsActivePort file there containing:
|
|
314
|
+
* Line 1: The debugging port number
|
|
315
|
+
* Line 2: The browser WebSocket path (e.g., /devtools/browser/<guid>)
|
|
316
|
+
*
|
|
317
|
+
* This gives us the real CDP URL that external tools like agent-browser can connect to.
|
|
318
|
+
* Returns null if discovery fails - callers should handle this case.
|
|
319
|
+
*/
|
|
320
|
+
discoverCdpUrl(browserServer) {
|
|
321
|
+
const userDataDir = browserServer._userDataDirForTest;
|
|
322
|
+
if (!userDataDir) {
|
|
323
|
+
this.logger?.warn?.("Could not access browser user data directory");
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const portFilePath = (0, import_node_path.join)(userDataDir, "DevToolsActivePort");
|
|
327
|
+
const deadline = Date.now() + 1500;
|
|
328
|
+
while (!(0, import_node_fs.existsSync)(portFilePath) && Date.now() < deadline) {
|
|
329
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
330
|
+
}
|
|
331
|
+
if (!(0, import_node_fs.existsSync)(portFilePath)) {
|
|
332
|
+
this.logger?.warn?.("DevToolsActivePort file not found");
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const content = (0, import_node_fs.readFileSync)(portFilePath, "utf-8").trim().split("\n");
|
|
337
|
+
const port = content[0];
|
|
338
|
+
const browserPath = content[1];
|
|
339
|
+
if (!port || !browserPath) {
|
|
340
|
+
this.logger?.warn?.("Invalid DevToolsActivePort content");
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
const cdpUrl = `ws://127.0.0.1:${port}${browserPath}`;
|
|
344
|
+
this.logger?.debug?.(`Discovered CDP URL from DevToolsActivePort: ${cdpUrl}`);
|
|
345
|
+
return cdpUrl;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.logger?.warn?.("Failed to read DevToolsActivePort file:", error);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Create a shared session by connecting to an existing browser via CDP URL.
|
|
353
|
+
* Used when BrowserViewer is configured with a cdpUrl to connect to an external browser.
|
|
354
|
+
*/
|
|
355
|
+
async createSharedSessionFromCdp(cdpUrl) {
|
|
356
|
+
if (this.sharedSession) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
await this.connectToCdp(cdpUrl, import_browser.DEFAULT_THREAD_ID);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Create a shared session (for 'shared' scope).
|
|
363
|
+
*/
|
|
364
|
+
async createSharedSession() {
|
|
365
|
+
if (this.sharedSession) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const { browserServer, browser, context, cdpSession, cdpUrl } = await this.launchBrowser(import_browser.DEFAULT_THREAD_ID);
|
|
369
|
+
const session = {
|
|
370
|
+
threadId: import_browser.DEFAULT_THREAD_ID,
|
|
371
|
+
createdAt: Date.now(),
|
|
372
|
+
browserServer,
|
|
373
|
+
browser,
|
|
374
|
+
context,
|
|
375
|
+
cdpSession,
|
|
376
|
+
cdpUrl
|
|
377
|
+
};
|
|
378
|
+
this.storeSession(session, import_browser.DEFAULT_THREAD_ID);
|
|
379
|
+
this.logger?.debug?.(`Shared Chrome launched, CDP URL: ${cdpUrl}`);
|
|
380
|
+
this.onBrowserCreated?.(browser, import_browser.DEFAULT_THREAD_ID, cdpUrl);
|
|
381
|
+
this.onSessionCreated?.(session);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Handle browser disconnection for a thread.
|
|
385
|
+
*/
|
|
386
|
+
handleBrowserDisconnected(threadId) {
|
|
387
|
+
this.logger?.debug?.(`Browser disconnected for thread ${threadId}`);
|
|
388
|
+
if (!this.getViewerSession(threadId)) return;
|
|
389
|
+
this.clearSessionState(threadId);
|
|
390
|
+
this.onBrowserClosed?.(threadId);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Connect to an external browser via CDP URL for screencast.
|
|
394
|
+
*
|
|
395
|
+
* This is used when an agent is using their own external CDP (e.g., browser-use cloud).
|
|
396
|
+
* We connect Playwright to the external browser to enable screencast without launching
|
|
397
|
+
* our own browser.
|
|
398
|
+
*
|
|
399
|
+
* @param cdpUrl - The external CDP WebSocket URL (wss://... or ws://...)
|
|
400
|
+
* @param threadId - Thread ID to associate the session with
|
|
401
|
+
*/
|
|
402
|
+
async connectToExternalCdp(cdpUrl, threadId) {
|
|
403
|
+
if (this.getViewerSession(threadId)) {
|
|
404
|
+
if (this.usesSharedSlot(threadId)) {
|
|
405
|
+
await this.closeSharedBrowser();
|
|
406
|
+
} else {
|
|
407
|
+
await this.closeThreadBrowser(threadId);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return this.connectToCdp(cdpUrl, threadId);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Connect to a browser via CDP URL and create a session.
|
|
414
|
+
* Shared implementation for createSharedSessionFromCdp and connectToExternalCdp.
|
|
415
|
+
*/
|
|
416
|
+
async connectToCdp(cdpUrl, threadId) {
|
|
417
|
+
const effectiveThreadId = this.usesSharedSlot(threadId) ? import_browser.DEFAULT_THREAD_ID : threadId;
|
|
418
|
+
this.logger?.debug?.(`Connecting to CDP for thread ${effectiveThreadId}: ${cdpUrl}`);
|
|
419
|
+
let browser = null;
|
|
420
|
+
try {
|
|
421
|
+
browser = await import_playwright_core.chromium.connectOverCDP(cdpUrl);
|
|
422
|
+
const contexts = browser.contexts();
|
|
423
|
+
const context = contexts[0] ?? await browser.newContext();
|
|
424
|
+
let pages = context.pages();
|
|
425
|
+
if (pages.length === 0) {
|
|
426
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
427
|
+
pages = context.pages();
|
|
428
|
+
if (pages.length === 0) {
|
|
429
|
+
await context.newPage();
|
|
430
|
+
pages = context.pages();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const cdpSession = pages[0] ? await context.newCDPSession(pages[0]) : null;
|
|
434
|
+
let disconnectHandled = false;
|
|
435
|
+
const handleDisconnect = () => {
|
|
436
|
+
if (disconnectHandled) return;
|
|
437
|
+
disconnectHandled = true;
|
|
438
|
+
this.handleBrowserDisconnected(effectiveThreadId);
|
|
439
|
+
};
|
|
440
|
+
context.on("close", handleDisconnect);
|
|
441
|
+
browser.on("disconnected", handleDisconnect);
|
|
442
|
+
const session = {
|
|
443
|
+
threadId: effectiveThreadId,
|
|
444
|
+
createdAt: Date.now(),
|
|
445
|
+
browserServer: null,
|
|
446
|
+
// We don't own the server for external CDP connections
|
|
447
|
+
browser,
|
|
448
|
+
context,
|
|
449
|
+
cdpSession,
|
|
450
|
+
cdpUrl
|
|
451
|
+
};
|
|
452
|
+
this.storeSession(session, threadId);
|
|
453
|
+
this.logger?.debug?.(`Connected to CDP for thread ${effectiveThreadId}`);
|
|
454
|
+
this.onBrowserCreated?.(browser, effectiveThreadId, cdpUrl);
|
|
455
|
+
this.onSessionCreated?.(session);
|
|
456
|
+
return session;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
this.logger?.warn?.(`Failed to connect to CDP: ${error}`);
|
|
459
|
+
await browser?.close().catch(() => {
|
|
460
|
+
});
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Close a specific thread's browser.
|
|
466
|
+
*/
|
|
467
|
+
async closeThreadBrowser(threadId) {
|
|
468
|
+
const session = this.threadSessions.get(threadId);
|
|
469
|
+
if (!session) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
await this.cleanupSession(session, threadId);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Close the shared browser.
|
|
476
|
+
*/
|
|
477
|
+
async closeSharedBrowser() {
|
|
478
|
+
if (!this.sharedSession) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
await this.cleanupSession(this.sharedSession, import_browser.DEFAULT_THREAD_ID);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Close all browsers.
|
|
485
|
+
*/
|
|
486
|
+
async closeAll() {
|
|
487
|
+
const threadIds = Array.from(this.threadSessions.keys());
|
|
488
|
+
await Promise.all(threadIds.map((id) => this.closeThreadBrowser(id)));
|
|
489
|
+
await this.closeSharedBrowser();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Get the manager for a session.
|
|
493
|
+
* Required by base class.
|
|
494
|
+
*/
|
|
495
|
+
getManagerForSession(session) {
|
|
496
|
+
const viewerSession = session;
|
|
497
|
+
return viewerSession.browser;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Get the shared manager.
|
|
501
|
+
* Required by base class.
|
|
502
|
+
*/
|
|
503
|
+
getSharedManager() {
|
|
504
|
+
if (!this.sharedSession) {
|
|
505
|
+
throw new Error("Shared browser not launched. Call createSharedSession() first.");
|
|
506
|
+
}
|
|
507
|
+
return this.sharedSession.browser;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Destroy a session and clean up resources.
|
|
511
|
+
* Required by base class.
|
|
512
|
+
*/
|
|
513
|
+
async doDestroySession(session) {
|
|
514
|
+
const viewerSession = this.getViewerSession(session.threadId);
|
|
515
|
+
if (!viewerSession) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
await this.cleanupSession(viewerSession, session.threadId);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Check if browser is running for a thread.
|
|
522
|
+
*/
|
|
523
|
+
isBrowserRunning(threadId) {
|
|
524
|
+
const effectiveThreadId = threadId ?? import_browser.DEFAULT_THREAD_ID;
|
|
525
|
+
return this.getViewerSession(effectiveThreadId) !== null;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// src/browser-viewer.ts
|
|
530
|
+
var BrowserViewer = class extends import_browser2.MastraBrowser {
|
|
531
|
+
id;
|
|
532
|
+
name = "BrowserViewer";
|
|
533
|
+
provider = "browser-viewer";
|
|
534
|
+
providerType = "cli";
|
|
535
|
+
/** Which CLI the agent uses */
|
|
536
|
+
cli;
|
|
537
|
+
/** Viewer-specific config (stored for reference) */
|
|
538
|
+
viewerConfig;
|
|
539
|
+
constructor(config) {
|
|
540
|
+
const effectiveScope = config.cdpUrl ? config.scope ?? "shared" : config.scope ?? "thread";
|
|
541
|
+
const { cli: _cli, cdpPort: _cdpPort, userDataDir: _userDataDir, ...baseConfig } = config;
|
|
542
|
+
super({
|
|
543
|
+
...baseConfig,
|
|
544
|
+
scope: effectiveScope
|
|
545
|
+
});
|
|
546
|
+
this.id = `browser-viewer-${Date.now()}`;
|
|
547
|
+
this.cli = config.cli;
|
|
548
|
+
this.viewerConfig = config;
|
|
549
|
+
this.threadManager = new BrowserViewerThreadManager({
|
|
550
|
+
scope: effectiveScope,
|
|
551
|
+
browserConfig: config,
|
|
552
|
+
logger: this.logger,
|
|
553
|
+
onSessionCreated: (session) => {
|
|
554
|
+
this.notifyBrowserReady(session.threadId);
|
|
555
|
+
},
|
|
556
|
+
onBrowserCreated: (_browser, threadId, _cdpUrl) => {
|
|
557
|
+
this.logger?.debug?.(`Browser created for thread ${threadId}`);
|
|
558
|
+
},
|
|
559
|
+
onBrowserClosed: (threadId) => {
|
|
560
|
+
this.logger?.debug?.(`Browser closed for thread ${threadId}`);
|
|
561
|
+
this.notifyBrowserClosed(threadId);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// CDP URL Access
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
/**
|
|
569
|
+
* Get the CDP WebSocket URL for CLI tools to connect.
|
|
570
|
+
* For thread scope, returns the CDP URL for the specified thread.
|
|
571
|
+
* For shared scope, returns the single shared CDP URL.
|
|
572
|
+
*
|
|
573
|
+
* @param threadId - Thread identifier (optional, uses current thread if not specified)
|
|
574
|
+
* @returns CDP URL or null if browser not running for that thread
|
|
575
|
+
*/
|
|
576
|
+
getCdpUrl(threadId) {
|
|
577
|
+
return this.threadManager.getCdpUrlForThread(threadId ?? this.getCurrentThread());
|
|
578
|
+
}
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
// Lifecycle (implements MastraBrowser abstract methods)
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
async doLaunch() {
|
|
583
|
+
const scope = this.threadManager.getScope();
|
|
584
|
+
const cdpUrl = this.config.cdpUrl;
|
|
585
|
+
if (cdpUrl) {
|
|
586
|
+
const url = typeof cdpUrl === "function" ? await cdpUrl() : cdpUrl;
|
|
587
|
+
await this.connectToExisting(url);
|
|
588
|
+
} else if (scope === "shared") {
|
|
589
|
+
await this.threadManager.createSharedSession();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async doClose() {
|
|
593
|
+
await this.threadManager.closeAll();
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Connect to an existing browser via CDP URL.
|
|
597
|
+
*/
|
|
598
|
+
async connectToExisting(cdpUrl) {
|
|
599
|
+
this.logger?.debug?.(`Connecting to existing browser at ${cdpUrl}`);
|
|
600
|
+
await this.threadManager.createSharedSessionFromCdp(cdpUrl);
|
|
601
|
+
this.logger?.debug?.("Connected to existing browser");
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Ensure browser is ready for the current thread.
|
|
605
|
+
* For thread scope, creates a new browser if needed.
|
|
606
|
+
*/
|
|
607
|
+
async ensureReady() {
|
|
608
|
+
const scope = this.threadManager.getScope();
|
|
609
|
+
const threadId = this.getCurrentThread();
|
|
610
|
+
if (scope === "thread" && !this.threadManager.isBrowserRunning(threadId)) {
|
|
611
|
+
await this.threadManager.getManagerForThread(threadId);
|
|
612
|
+
}
|
|
613
|
+
await super.ensureReady();
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Check if browser is running (for current thread in thread scope).
|
|
617
|
+
*/
|
|
618
|
+
isBrowserRunning(threadId) {
|
|
619
|
+
return this.threadManager.isBrowserRunning(threadId ?? this.getCurrentThread());
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Launch browser, optionally for a specific thread.
|
|
623
|
+
* For thread scope, creates a browser for that thread.
|
|
624
|
+
* For shared scope, launches the single shared browser.
|
|
625
|
+
*/
|
|
626
|
+
async launch(threadId) {
|
|
627
|
+
const scope = this.threadManager.getScope();
|
|
628
|
+
const effectiveThreadId = threadId ?? this.getCurrentThread();
|
|
629
|
+
if (scope === "shared") {
|
|
630
|
+
if (!this.threadManager.isBrowserRunning()) {
|
|
631
|
+
await super.launch();
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
if (!this.threadManager.isBrowserRunning(effectiveThreadId)) {
|
|
635
|
+
await this.threadManager.getManagerForThread(effectiveThreadId);
|
|
636
|
+
this.status = "ready";
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Handle browser disconnection.
|
|
642
|
+
* Overrides base class method.
|
|
643
|
+
*/
|
|
644
|
+
handleBrowserDisconnected() {
|
|
645
|
+
super.handleBrowserDisconnected();
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Connect to an external browser via CDP URL for screencast.
|
|
649
|
+
*
|
|
650
|
+
* Use this when an agent is using their own external CDP (e.g., browser-use cloud).
|
|
651
|
+
* Connects Playwright to the external browser to enable screencast without launching
|
|
652
|
+
* our own browser.
|
|
653
|
+
*
|
|
654
|
+
* @param cdpUrl - The external CDP WebSocket URL (wss://... or ws://...)
|
|
655
|
+
* @param threadId - Thread ID to associate the session with
|
|
656
|
+
*/
|
|
657
|
+
async connectToExternalCdp(cdpUrl, threadId) {
|
|
658
|
+
const effectiveThreadId = threadId ?? this.getCurrentThread();
|
|
659
|
+
await this.threadManager.connectToExternalCdp(cdpUrl, effectiveThreadId);
|
|
660
|
+
this.status = "ready";
|
|
661
|
+
}
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// Browser State (implements MastraBrowser abstract methods)
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
async getActivePage(threadId) {
|
|
666
|
+
return this.threadManager.getActivePageForThread(threadId ?? this.getCurrentThread());
|
|
667
|
+
}
|
|
668
|
+
getBrowserStateForThread(threadId) {
|
|
669
|
+
const context = this.threadManager.getContextForThread(threadId ?? this.getCurrentThread());
|
|
670
|
+
if (!context) {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
const pages = context.pages();
|
|
674
|
+
const activeIndex = pages.length > 0 ? pages.length - 1 : 0;
|
|
675
|
+
const tabs = pages.map((page, index) => ({
|
|
676
|
+
url: page.url(),
|
|
677
|
+
title: "",
|
|
678
|
+
// Would need async call to get title
|
|
679
|
+
isActive: index === activeIndex
|
|
680
|
+
}));
|
|
681
|
+
return {
|
|
682
|
+
tabs,
|
|
683
|
+
activeTabIndex: activeIndex
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
// Screencast Support
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
async startScreencast(options) {
|
|
690
|
+
const threadId = options?.threadId ?? this.getCurrentThread();
|
|
691
|
+
const provider = {
|
|
692
|
+
getCdpSession: async () => {
|
|
693
|
+
const cdpSession = await this.threadManager.createFreshCdpSession(threadId);
|
|
694
|
+
if (!cdpSession) {
|
|
695
|
+
throw new Error("No browser context available for screencast");
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
send: async (method, params) => {
|
|
699
|
+
return cdpSession.send(method, params);
|
|
700
|
+
},
|
|
701
|
+
on: (event, handler) => {
|
|
702
|
+
cdpSession.on(event, handler);
|
|
703
|
+
},
|
|
704
|
+
off: (event, handler) => {
|
|
705
|
+
cdpSession.off(event, handler);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
},
|
|
709
|
+
isBrowserRunning: () => this.isBrowserRunning(threadId)
|
|
710
|
+
};
|
|
711
|
+
const stream = new import_browser2.ScreencastStreamImpl(provider, {
|
|
712
|
+
format: options?.format ?? "jpeg",
|
|
713
|
+
quality: options?.quality ?? 80,
|
|
714
|
+
maxWidth: options?.maxWidth ?? 1280,
|
|
715
|
+
maxHeight: options?.maxHeight ?? 720,
|
|
716
|
+
everyNthFrame: options?.everyNthFrame ?? 1
|
|
717
|
+
});
|
|
718
|
+
const context = this.threadManager.getContextForThread(threadId);
|
|
719
|
+
if (context) {
|
|
720
|
+
const pageListeners = /* @__PURE__ */ new Map();
|
|
721
|
+
const onNewPage = () => {
|
|
722
|
+
setTimeout(() => {
|
|
723
|
+
if (stream.isActive()) {
|
|
724
|
+
stream.reconnect().catch(() => {
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}, 100);
|
|
728
|
+
};
|
|
729
|
+
const onPageCreated = (page) => {
|
|
730
|
+
setupPageListeners(page);
|
|
731
|
+
};
|
|
732
|
+
const setupPageListeners = (page) => {
|
|
733
|
+
page.once("close", () => {
|
|
734
|
+
pageListeners.delete(page);
|
|
735
|
+
setTimeout(() => {
|
|
736
|
+
if (stream.isActive() && context.pages().length > 0) {
|
|
737
|
+
stream.reconnect().catch(() => {
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}, 100);
|
|
741
|
+
});
|
|
742
|
+
const onFrameNavigated = (frame) => {
|
|
743
|
+
if (!frame.parentFrame()) {
|
|
744
|
+
stream.emitUrl(frame.url());
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
page.on("framenavigated", onFrameNavigated);
|
|
748
|
+
pageListeners.set(page, onFrameNavigated);
|
|
749
|
+
};
|
|
750
|
+
context.on("page", onNewPage);
|
|
751
|
+
context.on("page", onPageCreated);
|
|
752
|
+
for (const page of context.pages()) {
|
|
753
|
+
setupPageListeners(page);
|
|
754
|
+
}
|
|
755
|
+
stream.once("stop", () => {
|
|
756
|
+
context.off("page", onNewPage);
|
|
757
|
+
context.off("page", onPageCreated);
|
|
758
|
+
for (const [page, listener] of pageListeners) {
|
|
759
|
+
page.off("framenavigated", listener);
|
|
760
|
+
}
|
|
761
|
+
pageListeners.clear();
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
await stream.start();
|
|
765
|
+
return stream;
|
|
766
|
+
}
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
// Input Injection
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
async injectMouseEvent(params, threadId) {
|
|
771
|
+
const cdpSession = await this.threadManager.getCdpSessionForThread(threadId ?? this.getCurrentThread());
|
|
772
|
+
if (!cdpSession) {
|
|
773
|
+
throw new Error("CDP session not available for mouse injection");
|
|
774
|
+
}
|
|
775
|
+
await cdpSession.send("Input.dispatchMouseEvent", params);
|
|
776
|
+
}
|
|
777
|
+
async injectKeyboardEvent(params, threadId) {
|
|
778
|
+
const cdpSession = await this.threadManager.getCdpSessionForThread(threadId ?? this.getCurrentThread());
|
|
779
|
+
if (!cdpSession) {
|
|
780
|
+
throw new Error("CDP session not available for keyboard injection");
|
|
781
|
+
}
|
|
782
|
+
await cdpSession.send("Input.dispatchKeyEvent", params);
|
|
783
|
+
}
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
// Tools (CLI agents don't use SDK tools - they use workspace commands)
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
getTools() {
|
|
788
|
+
return {};
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
792
|
+
0 && (module.exports = {
|
|
793
|
+
BrowserViewer,
|
|
794
|
+
BrowserViewerThreadManager
|
|
795
|
+
});
|
|
796
|
+
//# sourceMappingURL=index.cjs.map
|