fix(browser): cache Page objects by targetId for extension relay

When using the Chrome extension relay (profile=chrome), CDP session-based
targetId resolution fails because Chrome blocks Target.attachToBrowserTarget.

This causes 'tab not found' errors when trying to interact with a page after
navigation, as the page cannot be re-resolved on subsequent requests.

This fix:
1. Adds a WeakRef cache that maps cdpUrl::targetId to Page objects
2. Checks the cache before attempting CDP/URL-based resolution
3. Caches successful resolutions for reuse in subsequent requests
4. Adds URL+title based fallback (similar to closed PR #1999) for cases
   where neither CDP nor cache works
5. Properly cleans up cache entries when pages close

The cache uses WeakRef to allow garbage collection of Page objects that
are no longer referenced elsewhere.

Fixes navigation + subsequent action workflows for extension relay profiles.
This commit is contained in:
Joao Paulo Furtado 2026-01-25 19:39:50 -08:00
parent 8f6542409a
commit 4244b6ef0f

View File

@ -101,6 +101,47 @@ const MAX_NETWORK_REQUESTS = 500;
let cached: ConnectedBrowser | null = null;
let connecting: Promise<ConnectedBrowser> | null = null;
// Cache Page objects by targetId to handle extension relay profiles where CDP session fails.
// This allows us to reuse the same Page object across requests without re-resolving.
const pagesByTargetId = new Map<string, WeakRef<Page>>();
const MAX_PAGE_CACHE = 50;
function pageTargetIdKey(cdpUrl: string, targetId: string) {
return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
}
function cachePageByTargetId(cdpUrl: string, targetId: string, page: Page): void {
const key = pageTargetIdKey(cdpUrl, targetId);
pagesByTargetId.set(key, new WeakRef(page));
// Cleanup when page closes
page.once("close", () => {
const cached = pagesByTargetId.get(key);
if (cached?.deref() === page) {
pagesByTargetId.delete(key);
}
});
// Evict old entries if cache is full
while (pagesByTargetId.size > MAX_PAGE_CACHE) {
const first = pagesByTargetId.keys().next();
if (first.done) break;
pagesByTargetId.delete(first.value);
}
}
function getCachedPageByTargetId(cdpUrl: string, targetId: string): Page | null {
const key = pageTargetIdKey(cdpUrl, targetId);
const ref = pagesByTargetId.get(key);
if (!ref) return null;
const page = ref.deref();
if (!page || page.isClosed()) {
pagesByTargetId.delete(key);
return null;
}
return page;
}
function normalizeCdpUrl(raw: string) {
return raw.replace(/\/$/, "");
}
@ -337,12 +378,78 @@ async function pageTargetId(page: Page): Promise<string | null> {
}
}
async function findPageByTargetId(browser: Browser, targetId: string): Promise<Page | null> {
async function findPageByTargetId(
browser: Browser,
targetId: string,
cdpUrl?: string,
): Promise<Page | null> {
// First, check the cache for a previously resolved page
if (cdpUrl) {
const cachedPage = getCachedPageByTargetId(cdpUrl, targetId);
if (cachedPage) return cachedPage;
}
const pages = await getAllPages(browser);
// Try the standard CDP session approach
for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null);
if (tid && tid === targetId) return page;
if (tid && tid === targetId) {
// Cache the successful mapping
if (cdpUrl) cachePageByTargetId(cdpUrl, targetId, page);
return page;
}
}
// If CDP sessions fail (e.g., extension relay), try URL+title matching via /json/list
// This is a fallback for when Target.attachToBrowserTarget is blocked
if (cdpUrl) {
try {
const baseUrl = cdpUrl
.replace(/\/+$/, "")
.replace(/^ws:/, "http:")
.replace(/\/cdp$/, "");
const response = await fetch(`${baseUrl}/json/list`);
if (response.ok) {
const targets = (await response.json()) as Array<{
id: string;
url: string;
title: string;
}>;
const target = targets.find((t) => t.id === targetId);
if (target) {
// Try to find a page with matching URL
const urlMatch = pages.filter((p) => p.url() === target.url);
if (urlMatch.length === 1) {
cachePageByTargetId(cdpUrl, targetId, urlMatch[0]);
return urlMatch[0];
}
// If multiple URL matches, try to narrow down by title
if (urlMatch.length > 1 && target.title) {
for (const page of urlMatch) {
const title = await page.title().catch(() => "");
if (title === target.title) {
cachePageByTargetId(cdpUrl, targetId, page);
return page;
}
}
}
// If we still have multiple matches, use index-based matching as last resort
const sameUrlTargets = targets.filter((t) => t.url === target.url);
if (sameUrlTargets.length === urlMatch.length) {
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
if (idx >= 0 && idx < urlMatch.length) {
cachePageByTargetId(cdpUrl, targetId, urlMatch[idx]);
return urlMatch[idx];
}
}
}
}
} catch {
// Ignore fetch errors and fall through to return null
}
}
return null;
}
@ -355,12 +462,16 @@ export async function getPageForTargetId(opts: {
if (!pages.length) throw new Error("No pages available in the connected browser.");
const first = pages[0];
if (!opts.targetId) return first;
const found = await findPageByTargetId(browser, opts.targetId);
const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
// only exposes a single Page, use it as a best-effort fallback.
if (pages.length === 1) return first;
if (pages.length === 1) {
// Cache this fallback mapping for future requests
cachePageByTargetId(opts.cdpUrl, opts.targetId, first);
return first;
}
throw new Error("tab not found");
}
return found;
@ -496,7 +607,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
targetId: string;
}): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
@ -512,7 +623,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
targetId: string;
}): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}