feat: support remote CDP WebSocket URLs in --cdp flag (#99)
Previously, the --cdp flag only accepted a port number and connected via http://localhost:{port}. This made it impossible to connect to remote browser services like Kernel, Browserless, etc. that provide WebSocket URLs. The --cdp flag now accepts either: - A port number (e.g., 9222) for local connections - A full WebSocket URL (e.g., wss://...) for remote browser services Changes: - Added cdpUrl field to LaunchCommand type - Updated protocol validation to accept URL format with scheme validation - Modified connectViaCDP to detect and handle both formats - Handle numeric strings for JSON serialization edge cases - Updated CLI to send cdpUrl or cdpPort based on input format - Updated README with examples for remote connections Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
c4139fa389
commit
e892bceadf
+43
-18
@@ -68,7 +68,7 @@ interface PageError {
|
||||
*/
|
||||
export class BrowserManager {
|
||||
private browser: Browser | null = null;
|
||||
private cdpPort: number | null = null;
|
||||
private cdpEndpoint: string | null = null; // stores port number or full URL
|
||||
private isPersistentContext: boolean = false;
|
||||
private browserbaseSessionId: string | null = null;
|
||||
private browserbaseApiKey: string | null = null;
|
||||
@@ -639,9 +639,9 @@ export class BrowserManager {
|
||||
/**
|
||||
* Check if CDP connection needs to be re-established
|
||||
*/
|
||||
private needsCdpReconnect(cdpPort: number): boolean {
|
||||
private needsCdpReconnect(cdpEndpoint: string): boolean {
|
||||
if (!this.browser?.isConnected()) return true;
|
||||
if (this.cdpPort !== cdpPort) return true;
|
||||
if (this.cdpEndpoint !== cdpEndpoint) return true;
|
||||
if (!this.isCdpConnectionAlive()) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -815,16 +815,18 @@ export class BrowserManager {
|
||||
* If already launched, this is a no-op (browser stays open)
|
||||
*/
|
||||
async launch(options: LaunchCommand): Promise<void> {
|
||||
const cdpPort = options.cdpPort;
|
||||
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
||||
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
||||
const hasExtensions = !!options.extensions?.length;
|
||||
|
||||
if (hasExtensions && cdpPort) {
|
||||
if (hasExtensions && cdpEndpoint) {
|
||||
throw new Error('Extensions cannot be used with CDP connection');
|
||||
}
|
||||
|
||||
if (this.isLaunched()) {
|
||||
const needsRelaunch =
|
||||
(!cdpPort && this.cdpPort !== null) || (!!cdpPort && this.needsCdpReconnect(cdpPort));
|
||||
(!cdpEndpoint && this.cdpEndpoint !== null) ||
|
||||
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint));
|
||||
if (needsRelaunch) {
|
||||
await this.close();
|
||||
} else {
|
||||
@@ -832,8 +834,8 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (cdpPort) {
|
||||
await this.connectViaCDP(cdpPort);
|
||||
if (cdpEndpoint) {
|
||||
await this.connectViaCDP(cdpEndpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -880,7 +882,7 @@ export class BrowserManager {
|
||||
headless: options.headless ?? true,
|
||||
executablePath: options.executablePath,
|
||||
});
|
||||
this.cdpPort = null;
|
||||
this.cdpEndpoint = null;
|
||||
context = await this.browser.newContext({
|
||||
viewport,
|
||||
extraHTTPHeaders: options.headers,
|
||||
@@ -899,16 +901,39 @@ export class BrowserManager {
|
||||
|
||||
/**
|
||||
* Connect to a running browser via CDP (Chrome DevTools Protocol)
|
||||
* @param cdpEndpoint Either a port number (as string) or a full WebSocket URL (ws:// or wss://)
|
||||
*/
|
||||
private async connectViaCDP(cdpPort: number | undefined): Promise<void> {
|
||||
if (!cdpPort) {
|
||||
throw new Error('cdpPort is required for CDP connection');
|
||||
private async connectViaCDP(cdpEndpoint: string | undefined): Promise<void> {
|
||||
if (!cdpEndpoint) {
|
||||
throw new Error('CDP endpoint is required for CDP connection');
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(`http://localhost:${cdpPort}`).catch(() => {
|
||||
// Determine the connection URL:
|
||||
// - If it starts with ws://, wss://, http://, or https://, use it directly
|
||||
// - If it's a numeric string (e.g., "9222"), treat as port for localhost
|
||||
// - Otherwise, treat it as a port number for localhost
|
||||
let cdpUrl: string;
|
||||
if (
|
||||
cdpEndpoint.startsWith('ws://') ||
|
||||
cdpEndpoint.startsWith('wss://') ||
|
||||
cdpEndpoint.startsWith('http://') ||
|
||||
cdpEndpoint.startsWith('https://')
|
||||
) {
|
||||
cdpUrl = cdpEndpoint;
|
||||
} else if (/^\d+$/.test(cdpEndpoint)) {
|
||||
// Numeric string - treat as port number (handles JSON serialization quirks)
|
||||
cdpUrl = `http://localhost:${cdpEndpoint}`;
|
||||
} else {
|
||||
// Unknown format - still try as port for backward compatibility
|
||||
cdpUrl = `http://localhost:${cdpEndpoint}`;
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(cdpUrl).catch(() => {
|
||||
throw new Error(
|
||||
`Failed to connect via CDP on port ${cdpPort}. ` +
|
||||
`Make sure the app is running with --remote-debugging-port=${cdpPort}`
|
||||
`Failed to connect via CDP to ${cdpUrl}. ` +
|
||||
(cdpUrl.includes('localhost')
|
||||
? `Make sure the app is running with --remote-debugging-port=${cdpEndpoint}`
|
||||
: 'Make sure the remote browser is accessible and the URL is correct.')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -928,7 +953,7 @@ export class BrowserManager {
|
||||
|
||||
// All validation passed - commit state
|
||||
this.browser = browser;
|
||||
this.cdpPort = cdpPort;
|
||||
this.cdpEndpoint = cdpEndpoint;
|
||||
|
||||
for (const context of contexts) {
|
||||
this.contexts.push(context);
|
||||
@@ -1554,7 +1579,7 @@ export class BrowserManager {
|
||||
}
|
||||
);
|
||||
this.browser = null;
|
||||
} else if (this.cdpPort !== null) {
|
||||
} else if (this.cdpEndpoint !== null) {
|
||||
// CDP: only disconnect, don't close external app's pages
|
||||
if (this.browser) {
|
||||
await this.browser.close().catch(() => {});
|
||||
@@ -1576,7 +1601,7 @@ export class BrowserManager {
|
||||
|
||||
this.pages = [];
|
||||
this.contexts = [];
|
||||
this.cdpPort = null;
|
||||
this.cdpEndpoint = null;
|
||||
this.browserbaseSessionId = null;
|
||||
this.browserbaseApiKey = null;
|
||||
this.browserUseSessionId = null;
|
||||
|
||||
@@ -19,6 +19,18 @@ const launchSchema = baseCommandSchema.extend({
|
||||
.optional(),
|
||||
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
||||
cdpPort: z.number().positive().optional(),
|
||||
cdpUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(url) =>
|
||||
url.startsWith('ws://') ||
|
||||
url.startsWith('wss://') ||
|
||||
url.startsWith('http://') ||
|
||||
url.startsWith('https://'),
|
||||
{ message: 'CDP URL must start with ws://, wss://, http://, or https://' }
|
||||
)
|
||||
.optional(),
|
||||
executablePath: z.string().optional(),
|
||||
extensions: z.array(z.string()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LaunchCommand extends BaseCommand {
|
||||
headers?: Record<string, string>;
|
||||
executablePath?: string;
|
||||
cdpPort?: number;
|
||||
cdpUrl?: string;
|
||||
extensions?: string[];
|
||||
proxy?: {
|
||||
server: string;
|
||||
|
||||
Reference in New Issue
Block a user