Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ compatibility_flags = [ "nodejs_compat" ]
browser = { binding = "MYBROWSER" }
```

## Route selected browser requests through your Worker

Browser Run RPC bindings accept an `outboundByHost` map. Each value is a Worker
Fetcher created by the caller. Browser Run sends requests for that hostname to
the Fetcher, so the request can use the caller's authentication or private
network access.

```ts
const browser = await launch(env.MYBROWSER, {
outboundByHost: {
'app.example.com': ctx.exports.MyApp({ props: {} }),
},
});
```

Create the Fetcher and launch the browser in the same Worker invocation. The
map carries live Worker capabilities and is not supported by URL endpoints or
legacy HTTP-only bindings.

## CDP Protocol Support

[Browser Run now has full CDP support](https://developers.cloudflare.com/changelog/post/2026-04-10-browser-rendering-cdp-endpoint/),
Expand Down
65 changes: 58 additions & 7 deletions packages/playwright-cloudflare/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export interface SessionGuardrails {
*/
export interface BrowserWorker {
fetch: typeof fetch;
launch?: (options?: BrowserRunOptions) => Promise<BrowserRunConnection>;
acquire?: (options?: BrowserRunOptions) => Promise<AcquireResponse>;
connectSession?: (sessionId: string, options?: BrowserRunConnectOptions) => Promise<BrowserRunConnection>;
}

export type BrowserEndpoint = BrowserWorker | string | URL;
Expand All @@ -76,6 +79,21 @@ export type BrowserEndpoint = BrowserWorker | string | URL;
*/
export interface AcquireResponse {
sessionId: string;
webSocketDebuggerUrl?: string;
targets?: BrowserRunTarget[];
}

/**
* @public
*/
export interface BrowserRunTarget {
id: string;
type: string;
url: string;
title?: string;
description?: string;
webSocketDebuggerUrl?: string;
devtoolsFrontendUrl?: string;
}

/**
Expand All @@ -99,10 +117,6 @@ export interface ClosedSession extends ActiveSession {
closeReasonText: string; // close reason description
}

export interface AcquireResponse {
sessionId: string;
}

/**
* @public
*/
Expand Down Expand Up @@ -135,28 +149,65 @@ export interface WorkersLaunchOptions {
recording?: boolean;
lab?: boolean;
browser?: 'kitesurf'; // when set to 'kitesurf', no session is acquired and the connection is made directly to /v1/devtools/browser
outboundByHost?: Record<string, BrowserWorker>;
// restricts the outbound traffic of the session being acquired, latched for
// its lifetime
guardrails?: SessionGuardrails;
}

/**
* Options accepted by Browser Run's RPC binding methods. The RPC surface uses
* camelCase and does not accept URL-only options such as `browser` or
* `persistent`.
*
* @public
*/
export interface BrowserRunOptions {
keepAlive?: number;
recording?: boolean;
lab?: boolean;
location?: string;
guardrails?: SessionGuardrails;
outboundByHost?: Record<string, BrowserWorker>;
targets?: boolean;
liveViewUrlExpiresInMs?: number;
}

/**
* @public
*/
export interface WorkersConnectOptions {
sessionId: string; // session ID to connect to
}

/**
* @public
*/
export interface BrowserRunConnectOptions {
targetId?: string;
}

/**
* @public
*/
export interface BrowserRunConnection {
sessionId: string;
webSocket: BrowserWorker;
webSocketDebuggerUrl?: string;
targets?: BrowserRunTarget[];
}

// Extracts the keys whose values match a specified type `ValueType`
type KeysByValueType<T, ValueType> = {
[K in keyof T]: T[K] extends ValueType ? K : never;
}[keyof T];

export type BrowserBindingKey = KeysByValueType<typeof env, BrowserWorker>;

// `guardrails` is excluded: they are sent in the acquire request body, so an endpoint
// URL has no way to carry them and accepting one here would silently drop it.
export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, options?: Omit<WorkersLaunchOptions, 'guardrails'> | WorkersConnectOptions): string;
// `guardrails` and `outboundByHost` are excluded: they are sent through the RPC
// acquire call, so an endpoint URL cannot carry them and accepting them here
// would silently drop them.
export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, options?: Omit<WorkersLaunchOptions, 'guardrails' | 'outboundByHost'> | WorkersConnectOptions): string;

export function connect(endpoint: string | URL): Promise<Browser>;
export function connect(endpoint: BrowserWorker, sessionIdOrOptions: string | WorkersConnectOptions): Promise<Browser>;
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cloudflare/playwright",
"description": "Playwright for Cloudflare Browser Run (formerly Browser Rendering)",
"version": "1.3.6-next",
"version": "1.3.7-next",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down
79 changes: 72 additions & 7 deletions packages/playwright-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as packageJson from '../package.json';

import type { ProtocolRequest } from 'playwright-core/lib/server/transport';
import type { CRBrowser } from 'playwright-core/lib/server/chromium/crBrowser';
import type { AcquireResponse, ActiveSession, Browser, BrowserBindingKey, BrowserEndpoint, BrowserWorker, ClosedSession, ConnectOverCDPOptions, HistoryResponse, LimitsResponse, SessionGuardrails, SessionsResponse, WorkersLaunchOptions } from '..';
import type { AcquireResponse, ActiveSession, Browser, BrowserBindingKey, BrowserEndpoint, BrowserRunOptions, BrowserWorker, ClosedSession, ConnectOverCDPOptions, HistoryResponse, LimitsResponse, SessionGuardrails, SessionsResponse, WorkersLaunchOptions } from '..';
import type { ChannelOwner } from 'playwright-core/lib/client/channelOwner';

function resetMonotonicTime() {
Expand All @@ -28,6 +28,7 @@ wrapClientApis();

const HTTP_FAKE_HOST = 'http://fake.host';
const WS_FAKE_HOST = 'ws://fake.host';
const rpcBindings = new WeakSet<object>();

const originalConnectOverCDP = playwright.chromium.connectOverCDP;
// HACK this is a major hack, but we need it to make playwright-mcp and stagehand work without modifying their code extensively.
Expand Down Expand Up @@ -88,6 +89,18 @@ function extractOptions(endpoint: BrowserEndpoint): { sessionId?: string, keep_a
return {};
}

function validateKitesurfOptions(options?: WorkersLaunchOptions): void {
if (options?.browser !== 'kitesurf')
return;
const incompatible: string[] = [];
if (options.lab)
incompatible.push('lab');
if (options.outboundByHost)
incompatible.push('outboundByHost');
if (incompatible.length)
throw new Error(`Options not supported with browser="kitesurf": ${incompatible.join(', ')}`);
}

export function endpointURLString(binding: BrowserWorker | BrowserBindingKey, options?: { sessionId?: string, persistent?: boolean, keepAlive?: number, browser?: 'kitesurf' }): string {
const bindingKey = typeof binding === 'string' ? binding : Object.keys(env).find(key => (env as any)[key] === binding);
if (!bindingKey || !(bindingKey in env))
Expand Down Expand Up @@ -137,14 +150,44 @@ export async function connect(endpoint: BrowserEndpoint, sessionIdOrOptions?: st
if (!options.sessionId)
throw new Error(`Session ID is required for connect()`);

const webSocket = await connectDevtools(getBrowserBinding(endpoint), options as { sessionId: string });
const binding = getBrowserBinding(endpoint);
let connectionEndpoint = binding;
if (rpcBindings.has(binding)) {
const connection = await binding.connectSession!(options.sessionId);
connectionEndpoint = connection.webSocket;
}
const webSocket = await connectDevtools(connectionEndpoint, options as { sessionId: string });
const transport = new WebSocketTransport(webSocket, options.sessionId);
// keeps the endpoint and options for client -> server async communication
return await createBrowser(transport, options);
}

export async function launch(endpoint: BrowserEndpoint, launchOptions?: WorkersLaunchOptions & { persistent?: boolean }): Promise<Browser> {
const options = { ...extractOptions(endpoint), ...launchOptions };
validateKitesurfOptions(options);
const binding = getBrowserBinding(endpoint);

const wantsRpcLaunch = options.lab || options.outboundByHost;
if (options.outboundByHost && (options.browser || typeof binding.launch !== 'function'))
throw new Error('outboundByHost requires a Browser Run RPC binding');

if (wantsRpcLaunch && !options.browser && typeof binding.launch === 'function') {
const connection = await binding.launch(toBrowserRunOptions(options));
const webSocket = await connectDevtools(connection.webSocket, {
sessionId: connection.sessionId,
persistent: options.persistent,
});
const transport = new WebSocketTransport(webSocket, connection.sessionId);
const browser = await createBrowser(transport, options) as Browser & ChannelOwner;
const browserImpl = browser._connection.toImpl!(browser) as CRBrowser;
const doClose = async () => {
const message: ProtocolRequest = { method: 'Browser.close', id: kBrowserCloseMessageId, params: {} };
transport.send(message);
};
browserImpl.options.browserProcess = { close: doClose, kill: doClose };
return browser;
}

// kitesurf browsers acquire and connect in one go, skip acquire
// and connect straight to the devtools endpoint without a session id
const sessionId = options.browser === 'kitesurf' ? undefined : (await acquire(endpoint, launchOptions)).sessionId;
Expand All @@ -165,20 +208,30 @@ export async function launch(endpoint: BrowserEndpoint, launchOptions?: WorkersL

export async function acquire(endpoint: BrowserEndpoint, options?: WorkersLaunchOptions): Promise<AcquireResponse> {
options = { ...extractOptions(endpoint), ...options };
validateKitesurfOptions(options);
const binding = getBrowserBinding(endpoint);
const wantsRpcAcquire = options.lab || options.outboundByHost;
if (options.outboundByHost && (options.browser || typeof binding.acquire !== 'function'))
throw new Error('outboundByHost requires a Browser Run RPC binding');
if (wantsRpcAcquire && !options.browser && typeof binding.acquire === 'function') {
const response = await binding.acquire(toBrowserRunOptions(options));
rpcBindings.add(binding);
return response;
}

// add options to acquire endpoint as query parameters
const searchParams = new URLSearchParams();
if (options?.keep_alive)
searchParams.set("keep_alive", options.keep_alive.toString());
searchParams.set('keep_alive', options.keep_alive.toString());
if (options?.recording)
searchParams.set("recording", options.recording.toString());
searchParams.set('recording', options.recording.toString());
if (options?.lab)
searchParams.set("lab", options.lab.toString());
searchParams.set('lab', options.lab.toString());

// POST /v1/devtools/browser rather than GET /v1/acquire: it takes the same query
// parameters and is the only acquire endpoint that accepts a guardrails policy.
const acquireUrl = `${HTTP_FAKE_HOST}/v1/devtools/browser?${searchParams.toString()}`;
const res = await getBrowserBinding(endpoint).fetch(acquireUrl, {
const res = await binding.fetch(acquireUrl, {
method: 'POST',
// Guardrails travel in the body here, unlike the websocket upgrades that have to
// use a header.
Expand All @@ -193,14 +246,26 @@ export async function acquire(endpoint: BrowserEndpoint, options?: WorkersLaunch
const text = await res.text();
if (status !== 200) {
throw new Error(
`Unable to create new browser: code: ${status}: message: ${text}`
`Unable to create new browser: code: ${status}: message: ${text}`
);
}
// Got a 200, so response text is actually an AcquireResponse
const response: AcquireResponse = JSON.parse(text);
return response;
}

function toBrowserRunOptions(options?: WorkersLaunchOptions & { persistent?: boolean }): BrowserRunOptions {
const rpcOptions = { ...options };
const keepAlive = rpcOptions.keep_alive;
delete rpcOptions.keep_alive;
delete rpcOptions.browser;
delete rpcOptions.persistent;
return {
...rpcOptions,
...(keepAlive === undefined ? {} : { keepAlive }),
} as BrowserRunOptions;
}

export async function sessions(endpoint: BrowserEndpoint): Promise<ActiveSession[]> {
const res = await getBrowserBinding(endpoint).fetch(`${HTTP_FAKE_HOST}/v1/sessions`);
const status = res.status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,16 @@ test(`should reject guardrails combined with browser=kitesurf`, async ({ binding
await expect(launch(binding, { browser: 'kitesurf', guardrails: { allowedDomains: ['*.example.com'] } }))
.rejects.toThrow(/code: 400.*browser=kitesurf/);
});

test(`should reject lab combined with browser=kitesurf`, async ({ binding }) => {
await expect(launch(binding, { browser: 'kitesurf', lab: true }))
.rejects.toThrow(/browser="kitesurf".*lab/);
});

test(`should reject outbound workers combined with browser=kitesurf`, async ({ binding }) => {
await expect(launch(binding, {
browser: 'kitesurf',
outboundByHost: { 'app.example.com': {} as never },
}))
.rejects.toThrow(/browser="kitesurf".*outboundByHost/);
});
Loading
Loading