Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a5df5b8
feat: add `isRemote` flag and connect option types for remote browser…
l2ysho Mar 18, 2026
b012525
feat: add PlaywrightPlugin remote connection routing via `connect()` …
l2ysho Mar 18, 2026
0e83812
feat: add PuppeteerPlugin remote connection routing via `puppeteer.co…
l2ysho Mar 18, 2026
29e7aa4
feat: skip proxy/webdriver hiding for remote browsers, add remote con…
l2ysho Mar 19, 2026
ed86761
fix: require endpoint in connect options, use non-deprecated Playwrig…
l2ysho Mar 19, 2026
bd19911
feat: default `useIncognitoPages` to `true` for remote browser connec…
l2ysho Mar 19, 2026
373da36
fix: improve remote connection error handling and endpoint validation
l2ysho Mar 19, 2026
76b7d20
fix: prevent resource leaks in PuppeteerPlugin remote browser connect…
l2ysho Mar 19, 2026
01ada42
chore: add clarifying comments for remote launch path in base class
l2ysho Mar 19, 2026
f7dc7c6
fix: clarify useIncognitoPages pattern and improve warning for WebSoc…
l2ysho Mar 19, 2026
0e3218b
feat: add warnings for ignored options on remote browser connections
l2ysho Mar 19, 2026
a11370a
test: add unit tests for remote browser connections
l2ysho Mar 19, 2026
2740bf4
fix: prevent proxy URL from leaking into remote Puppeteer browser con…
l2ysho Mar 19, 2026
fb73726
docs(examples): add remote browser integration examples
l2ysho Mar 31, 2026
3be257c
Merge branch 'v4' into 1822-connect-to-remote-browser-services
l2ysho Apr 21, 2026
92b971b
Merge branch 'v4' into 1822-connect-to-remote-browser-services
l2ysho Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/browser-pool/src/abstract-classes/browser-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export abstract class BrowserPlugin<
browserPerProxy = this.browserPerProxy,
ignoreProxyCertificate = this.ignoreProxyCertificate,
proxyTier,
isRemote,
} = options;

return new LaunchContext({
Expand All @@ -167,6 +168,7 @@ export abstract class BrowserPlugin<
browserPerProxy,
ignoreProxyCertificate,
proxyTier,
isRemote,
});
}

Expand All @@ -190,15 +192,23 @@ export abstract class BrowserPlugin<
NewPageResult
> = this.createLaunchContext(),
): Promise<LaunchResult> {
// launchOptions is only used by the local launch path below — remote connections ignore it.
launchContext.launchOptions ??= {} as LibraryOptions;

const { proxyUrl, launchOptions } = launchContext;

if (proxyUrl) {
if (proxyUrl && launchContext.isRemote) {
this.log.warning(
'proxyUrl is set but will be ignored for remote browser connections. ' +
'Configure proxy settings on the remote browser service instead.',
);
}

if (proxyUrl && !launchContext.isRemote) {
await this._addProxyToLaunchOptions(launchContext);
}

if (this._isChromiumBasedBrowser(launchContext)) {
if (!launchContext.isRemote && this._isChromiumBasedBrowser(launchContext)) {
// This will set the args for chromium based browsers to hide the webdriver.
(launchOptions as Dictionary).args = this._mergeArgsToHideWebdriver(launchOptions!.args);
// When User-Agent is not set, and we're using Chromium in headless mode,
Expand All @@ -210,6 +220,10 @@ export abstract class BrowserPlugin<
}
}

if (launchContext.isRemote) {
this.log.info('Connecting to remote browser (skipping local proxy and webdriver stealth configuration).');
}

return this._launch(launchContext);
}

Expand Down
9 changes: 9 additions & 0 deletions packages/browser-pool/src/launch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export interface LaunchContextOptions<
* This is useful when using HTTPS proxies with self-signed certificates.
*/
ignoreProxyCertificate?: boolean;
/**
* Whether this launch context represents a connection to a remote browser
* rather than a locally launched one.
* @default false
*/
isRemote?: boolean;
}

export class LaunchContext<
Expand All @@ -73,6 +79,7 @@ export class LaunchContext<
browserPerProxy?: boolean;
userDataDir: string;
proxyTier?: number;
readonly isRemote: boolean;
ignoreProxyCertificate?: boolean;

private _proxyUrl?: string;
Expand All @@ -92,6 +99,7 @@ export class LaunchContext<
userDataDir = '',
proxyTier,
ignoreProxyCertificate,
isRemote,
} = options;

this.id = id;
Expand All @@ -102,6 +110,7 @@ export class LaunchContext<
this.userDataDir = userDataDir;
this.proxyTier = proxyTier;
this.ignoreProxyCertificate = ignoreProxyCertificate ?? false;
this.isRemote = isRemote ?? false;

this._proxyUrl = proxyUrl;
}
Expand Down
123 changes: 120 additions & 3 deletions packages/browser-pool/src/playwright/playwright-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import fs from 'node:fs';

import type { Browser as PlaywrightBrowser, BrowserType } from 'playwright';

import { BrowserPlugin } from '../abstract-classes/browser-plugin.js';
import type { Browser as PlaywrightBrowser, BrowserType, ConnectOverCDPOptions, ConnectOptions } from 'playwright';

import {
BrowserLaunchError,
BrowserPlugin,
type BrowserPluginOptions,
type CreateLaunchContextOptions,
} from '../abstract-classes/browser-plugin.js';
import { anonymizeProxySugar } from '../anonymize-proxy.js';
import type { createProxyServerForContainers } from '../container-proxy-server.js';
import type { LaunchContext } from '../launch-context.js';
Expand All @@ -11,6 +16,29 @@
import { PlaywrightBrowser as PlaywrightBrowserWithPersistentContext } from './playwright-browser.js';
import { PlaywrightController } from './playwright-controller.js';

/**
* Options for connecting to a remote browser via CDP.
* Mirrors `browserType.connectOverCDP(endpointURL, options?)`.
*/
export interface PlaywrightConnectOverCDPOptions extends ConnectOverCDPOptions {
/** The CDP endpoint URL to connect to (required). Overrides the deprecated optional `endpointURL` from Playwright. */
endpointURL: string;
}

/**
* Options for connecting to a remote browser via WebSocket.
* Mirrors `browserType.connect(wsEndpoint, options?)`.
*/
export interface PlaywrightConnectOptions extends ConnectOptions {
/** The WebSocket endpoint URL to connect to (required). */
wsEndpoint: string;
}

export interface PlaywrightPluginOptions extends BrowserPluginOptions<SafeParameters<BrowserType['launch']>[0]> {
connectOptions?: PlaywrightConnectOptions;
connectOverCDPOptions?: PlaywrightConnectOverCDPOptions;
}

export class PlaywrightPlugin extends BrowserPlugin<
BrowserType,
SafeParameters<BrowserType['launch']>[0],
Expand All @@ -19,7 +47,96 @@
private _browserVersion?: string;
_containerProxyServer?: Awaited<ReturnType<typeof createProxyServerForContainers>>;

connectOptions?: PlaywrightConnectOptions;
connectOverCDPOptions?: PlaywrightConnectOverCDPOptions;

constructor(library: BrowserType, options: PlaywrightPluginOptions = {}) {
const { connectOptions, connectOverCDPOptions, ...baseOptions } = options;

if (connectOptions && connectOverCDPOptions) {
throw new Error("Cannot set both 'connectOptions' and 'connectOverCDPOptions' — pick one protocol.");
}

if (connectOverCDPOptions && !connectOverCDPOptions.endpointURL) {
throw new Error("'connectOverCDPOptions.endpointURL' must be a non-empty string.");
}

if (connectOptions && !connectOptions.wsEndpoint) {
throw new Error("'connectOptions.wsEndpoint' must be a non-empty string.");
}

super(library, baseOptions);
this.connectOptions = connectOptions;
this.connectOverCDPOptions = connectOverCDPOptions;

// We check options.useIncognitoPages (not this.useIncognitoPages) because super() collapses undefined to false.
// This preserves the distinction between "not set" (undefined → default to true) and "explicitly false".
if (this.connectOptions || this.connectOverCDPOptions) {
if (options.useIncognitoPages === undefined) {
this.useIncognitoPages = true;
this.log.info('Remote browser detected — defaulting useIncognitoPages to true for session isolation.');
} else if (options.useIncognitoPages === false) {

Check failure on line 78 in packages/browser-pool/src/playwright/playwright-plugin.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(no-unnecessary-boolean-literal-compare)

This expression unnecessarily compares a boolean value to a boolean instead of using it directly.
const message = this.connectOptions
? 'useIncognitoPages is set to false with a remote WebSocket connection. ' +
'This may cause errors because browserType.connect() returns a browser with no default context.'
: 'useIncognitoPages is set to false with a remote browser connection. ' +
'Pages will share cookies and storage on the remote browser instance.';
this.log.warning(message);
}
}
}

override createLaunchContext(options: CreateLaunchContextOptions<BrowserType> = {}): LaunchContext<BrowserType> {
return super.createLaunchContext({
...options,
isRemote: options.isRemote ?? !!(this.connectOptions || this.connectOverCDPOptions),
});
}

private _sanitizeEndpointForLog(endpoint: string): string {
try {
const url = new URL(endpoint);
if (url.username || url.password) {
url.username = '***';
url.password = '***';
}
return url.toString();
} catch {
return '<invalid URL>';
}
}

protected async _launch(launchContext: LaunchContext<BrowserType>): Promise<PlaywrightBrowser> {
// Remote CDP connection — skip all local launch/proxy logic
if (this.connectOverCDPOptions) {
const { endpointURL, ...options } = this.connectOverCDPOptions;
this.log.info('Connecting to remote browser via connectOverCDP.');
try {
return await this.library.connectOverCDP(endpointURL, options);
} catch (cause) {
throw new BrowserLaunchError(
`Failed to connect to remote browser via CDP at "${this._sanitizeEndpointForLog(endpointURL)}". ` +
'Check that the endpoint is reachable and the browser is accepting CDP connections.\n\u200b',
{ cause },
);
}
}

// Remote Playwright WebSocket connection — skip all local launch/proxy logic
if (this.connectOptions) {
const { wsEndpoint, ...options } = this.connectOptions;
this.log.info('Connecting to remote browser via connect (Playwright WebSocket).');
try {
return await this.library.connect(wsEndpoint, options);
} catch (cause) {
throw new BrowserLaunchError(
`Failed to connect to remote browser via WebSocket at "${this._sanitizeEndpointForLog(wsEndpoint)}". ` +
'Check that the endpoint is reachable and the Playwright server is running.\n\u200b',
{ cause },
);
}
}

const { launchOptions, useIncognitoPages, userDataDir, proxyUrl } = launchContext;
let browser: PlaywrightBrowser;

Expand Down
Loading
Loading