diff --git a/src/browser/pw-ai.ts b/src/browser/pw-ai.ts index 4b572c47d..3e8cf4a3a 100644 --- a/src/browser/pw-ai.ts +++ b/src/browser/pw-ai.ts @@ -1,8 +1,11 @@ export { type BrowserConsoleMessage, + closePageByTargetIdViaPlaywright, closePlaywrightBrowserConnection, + createPageViaPlaywright, ensurePageState, getPageForTargetId, + listPagesViaPlaywright, refLocator, type WithSnapshotForAI, } from "./pw-session.js"; diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index df82b43c3..f99694dc8 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -389,3 +389,106 @@ export async function closePlaywrightBrowserConnection(): Promise { if (!cur) return; await cur.browser.close().catch(() => {}); } + +/** + * List all pages/tabs from the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/list is ephemeral. + */ +export async function listPagesViaPlaywright(opts: { + cdpUrl: string; +}): Promise< + Array<{ + targetId: string; + title: string; + url: string; + type: string; + }> +> { + const { browser } = await connectBrowser(opts.cdpUrl); + const pages = await getAllPages(browser); + const results: Array<{ + targetId: string; + title: string; + url: string; + type: string; + }> = []; + + for (const page of pages) { + const tid = await pageTargetId(page).catch(() => null); + if (tid) { + results.push({ + targetId: tid, + title: await page.title().catch(() => ""), + url: page.url(), + type: "page", + }); + } + } + return results; +} + +/** + * Create a new page/tab using the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/new is ephemeral. + * Returns the new page's targetId and metadata. + */ +export async function createPageViaPlaywright(opts: { + cdpUrl: string; + url: string; +}): Promise<{ + targetId: string; + title: string; + url: string; + type: string; +}> { + const { browser } = await connectBrowser(opts.cdpUrl); + const contexts = browser.contexts(); + // Use the first context if available, otherwise this is a fresh connection + // and we need to use the default context that Browserless provides + let context = contexts[0]; + if (!context) { + // For Browserless over CDP, there should be at least one context + // If not, we can try accessing pages directly from contexts + throw new Error("No browser context available for creating a new page"); + } + + const page = await context.newPage(); + ensurePageState(page); + + // Navigate to the URL + const targetUrl = opts.url.trim() || "about:blank"; + if (targetUrl !== "about:blank") { + await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { + // Navigation might fail for some URLs, but page is still created + }); + } + + // Get the targetId for this page + const tid = await pageTargetId(page).catch(() => null); + if (!tid) { + throw new Error("Failed to get targetId for new page"); + } + + return { + targetId: tid, + title: await page.title().catch(() => ""), + url: page.url(), + type: "page", + }; +} + +/** + * Close a page/tab by targetId using the persistent Playwright connection. + * Used for remote profiles where HTTP-based /json/close is ephemeral. + */ +export async function closePageByTargetIdViaPlaywright(opts: { + cdpUrl: string; + targetId: string; +}): Promise { + const { browser } = await connectBrowser(opts.cdpUrl); + const page = await findPageByTargetId(browser, opts.targetId); + if (!page) { + throw new Error("tab not found"); + } + await page.close(); +} diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 5d1543089..e18deeeb3 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -100,6 +100,22 @@ function createProfileContext( }; const listTabs = async (): Promise => { + // For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions + if (!profile.cdpIsLoopback) { + try { + const mod = await import("./pw-ai.js"); + const pages = await mod.listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); + return pages.map((p) => ({ + targetId: p.targetId, + title: p.title, + url: p.url, + type: p.type, + })); + } catch { + // Fall back to HTTP-based listing if Playwright is not available + } + } + const raw = await fetchJson< Array<{ id?: string; @@ -121,6 +137,30 @@ function createProfileContext( }; const openTab = async (url: string): Promise => { + // For remote profiles, use Playwright's persistent connection to create tabs + // This ensures the tab persists beyond a single request + if (!profile.cdpIsLoopback) { + try { + const mod = await import("./pw-ai.js"); + const page = await mod.createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url }); + const profileState = getProfileState(); + profileState.lastTargetId = page.targetId; + return { + targetId: page.targetId, + title: page.title, + url: page.url, + type: page.type, + }; + } catch (err) { + // Fall back to HTTP-based tab creation if Playwright is not available + // (though it will likely be ephemeral for remote profiles) + if (String(err).includes("No browser context")) { + // This is a real error, not a missing Playwright issue + throw err; + } + } + } + const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, @@ -321,7 +361,11 @@ function createProfileContext( } const tabs = await listTabs(); - const candidates = profile.driver === "extension" ? tabs : tabs.filter((t) => Boolean(t.wsUrl)); + // For remote profiles using Playwright's persistent connection, we don't need wsUrl + // because we access pages directly through Playwright, not via individual WebSocket URLs. + const candidates = profile.driver === "extension" || !profile.cdpIsLoopback + ? tabs + : tabs.filter((t) => Boolean(t.wsUrl)); const resolveById = (raw: string) => { const resolved = resolveTargetIdFromTabs(raw, candidates); @@ -379,6 +423,21 @@ function createProfileContext( } throw new Error("tab not found"); } + + // For remote profiles, use Playwright's persistent connection to close tabs + if (!profile.cdpIsLoopback) { + try { + const mod = await import("./pw-ai.js"); + await mod.closePageByTargetIdViaPlaywright({ + cdpUrl: profile.cdpUrl, + targetId: resolved.targetId, + }); + return; + } catch { + // Fall back to HTTP-based close if Playwright is not available + } + } + await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`)); };