From 0de3bc8db62e83159d6de48659b70ece638d135e Mon Sep 17 00:00:00 2001 From: Niklas Huthmann Date: Tue, 15 Sep 2026 14:57:43 +0100 Subject: [PATCH 1/2] feat(cloudflare): support outbound workers in Browser Run --- README.md | 19 +++++ packages/playwright-cloudflare/index.d.ts | 65 +++++++++++++-- packages/playwright-cloudflare/src/index.ts | 79 +++++++++++++++++-- .../src/browser-rendering/guardrails.spec.ts | 13 +++ .../session-management.spec.ts | 71 ++++++++++++++++- .../tests/test-d/guardrails.test-d.ts | 8 ++ 6 files changed, 238 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7b0ca3464..9e0979835 100644 --- a/README.md +++ b/README.md @@ -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/), diff --git a/packages/playwright-cloudflare/index.d.ts b/packages/playwright-cloudflare/index.d.ts index f8a3a6486..4373e7f9b 100644 --- a/packages/playwright-cloudflare/index.d.ts +++ b/packages/playwright-cloudflare/index.d.ts @@ -67,6 +67,9 @@ export interface SessionGuardrails { */ export interface BrowserWorker { fetch: typeof fetch; + launch?: (options?: BrowserRunOptions) => Promise; + acquire?: (options?: BrowserRunOptions) => Promise; + connectSession?: (sessionId: string, options?: BrowserRunConnectOptions) => Promise; } export type BrowserEndpoint = BrowserWorker | string | URL; @@ -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; } /** @@ -99,10 +117,6 @@ export interface ClosedSession extends ActiveSession { closeReasonText: string; // close reason description } -export interface AcquireResponse { - sessionId: string; -} - /** * @public */ @@ -135,11 +149,30 @@ 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; // 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; + targets?: boolean; + liveViewUrlExpiresInMs?: number; +} + /** * @public */ @@ -147,6 +180,23 @@ 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 = { [K in keyof T]: T[K] extends ValueType ? K : never; @@ -154,9 +204,10 @@ type KeysByValueType = { export type BrowserBindingKey = KeysByValueType; -// `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 | 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 | WorkersConnectOptions): string; export function connect(endpoint: string | URL): Promise; export function connect(endpoint: BrowserWorker, sessionIdOrOptions: string | WorkersConnectOptions): Promise; diff --git a/packages/playwright-cloudflare/src/index.ts b/packages/playwright-cloudflare/src/index.ts index 941389f4e..e3f6168f5 100644 --- a/packages/playwright-cloudflare/src/index.ts +++ b/packages/playwright-cloudflare/src/index.ts @@ -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() { @@ -28,6 +28,7 @@ wrapClientApis(); const HTTP_FAKE_HOST = 'http://fake.host'; const WS_FAKE_HOST = 'ws://fake.host'; +const rpcBindings = new WeakSet(); 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. @@ -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)) @@ -137,7 +150,13 @@ 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); @@ -145,6 +164,30 @@ export async function connect(endpoint: BrowserEndpoint, sessionIdOrOptions?: st export async function launch(endpoint: BrowserEndpoint, launchOptions?: WorkersLaunchOptions & { persistent?: boolean }): Promise { 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; @@ -165,20 +208,30 @@ export async function launch(endpoint: BrowserEndpoint, launchOptions?: WorkersL export async function acquire(endpoint: BrowserEndpoint, options?: WorkersLaunchOptions): Promise { 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. @@ -193,7 +246,7 @@ 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 @@ -201,6 +254,18 @@ export async function acquire(endpoint: BrowserEndpoint, options?: WorkersLaunch 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 { const res = await getBrowserBinding(endpoint).fetch(`${HTTP_FAKE_HOST}/v1/sessions`); const status = res.status; diff --git a/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts b/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts index 6ddb6e5cf..52c18cd10 100644 --- a/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts +++ b/packages/playwright-cloudflare/tests/src/browser-rendering/guardrails.spec.ts @@ -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/); +}); diff --git a/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts b/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts index a260401eb..1326d40cd 100644 --- a/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts +++ b/packages/playwright-cloudflare/tests/src/browser-rendering/session-management.spec.ts @@ -1,4 +1,4 @@ -import { launch, connect, sessions, history, acquire, limits, endpointURLString, BrowserEndpoint, BrowserWorker, ActiveSession } from '@cloudflare/playwright'; +import { launch, connect, sessions, history, acquire, limits, endpointURLString, BrowserWorker, ActiveSession } from '@cloudflare/playwright'; import playwright from '@cloudflare/playwright'; import { test, expect } from '../server/workerFixtures'; @@ -14,7 +14,7 @@ async function fetchSingleSession(endpoint: BrowserWorker, sessionId: string) { test(`should list sessions @smoke`, async ({ binding }) => { const before = await sessions(binding); const browser = await launch(binding); - + expect(before.map(a => a.sessionId)).not.toContain(browser.sessionId()); // fails if session doesn't exist await fetchSingleSession(binding, browser.sessionId()); @@ -22,6 +22,71 @@ test(`should list sessions @smoke`, async ({ binding }) => { browser.close(); }); +test(`should launch a lab browser`, async ({ binding }) => { + const browser = await launch(binding, { lab: true }); + expect(browser.sessionId()).toBeTruthy(); + await browser.close(); +}); + +test(`should pass lab and outbound workers to the RPC acquire method`, async () => { + let received: unknown; + const outboundWorker = { fetch: async () => new Response('ok') } as BrowserWorker; + const rpcBinding = { + fetch: async () => new Response('ok'), + acquire: async (options: unknown) => { + received = options; + return { sessionId: 'session' }; + }, + } as BrowserWorker; + + await acquire(rpcBinding, { lab: true, outboundByHost: { 'app.example.com': outboundWorker } }); + expect(received).toEqual({ lab: true, outboundByHost: { 'app.example.com': outboundWorker } }); +}); + +test(`should translate keep_alive for RPC acquire`, async () => { + let received: unknown; + const rpcBinding = { + fetch: async () => new Response('ok'), + acquire: async (options: unknown) => { + received = options; + return { sessionId: 'session' }; + }, + } as BrowserWorker; + + await acquire(rpcBinding, { lab: true, keep_alive: 30000 }); + expect(received).toEqual({ lab: true, keepAlive: 30000 }); +}); + +test(`should pass translated options to RPC launch`, async () => { + let received: unknown; + const rpcBinding = { + fetch: async () => new Response('ok'), + launch: async (options: unknown) => { + received = options; + return { + sessionId: 'session', + webSocket: { fetch: async () => new Response('not a websocket') } as BrowserWorker, + }; + }, + } as BrowserWorker; + + await expect(launch(rpcBinding, { lab: true, keep_alive: 30000 })).rejects.toThrow(); + expect(received).toEqual({ lab: true, keepAlive: 30000 }); +}); + +test(`should preserve lab for a fetch-only acquire binding`, async () => { + let request: Request | undefined; + const legacyBinding = { + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = new Request(input, init); + return Response.json({ sessionId: 'session' }); + }, + } as BrowserWorker; + + await acquire(legacyBinding, { lab: true }); + expect(new URL(request!.url).searchParams.get('lab')).toBe('true'); +}); + test(`should keep session open when closing browser created with connect`, async ({ binding }) => { const { sessionId } = await acquire(binding); const before = await sessions(binding); @@ -58,7 +123,7 @@ test(`should close session after keep_alive`, async ({ binding }) => { test(`should add new session to history when launching browser`, async ({ binding }) => { const before = await history(binding); - const launchedBrowser = await launch (binding); + const launchedBrowser = await launch(binding); const after = await history(binding); expect(before.map(a => a.sessionId)).not.toContain(launchedBrowser.sessionId()); diff --git a/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts b/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts index 54262ea78..58750376f 100644 --- a/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts +++ b/packages/playwright-cloudflare/tests/test-d/guardrails.test-d.ts @@ -15,6 +15,7 @@ import { expectAssignable, expectNotAssignable, expectType } from 'tsd'; import type { BrowserWorker, SessionGuardrails, WorkersLaunchOptions } from '@cloudflare/playwright'; declare const binding: BrowserWorker; +declare const outboundWorker: BrowserWorker; const sessionPolicy: SessionGuardrails = { allowedDomains: ['example.com', '*.example.com', 'api.*.example.com'], @@ -31,6 +32,9 @@ expectAssignable({ allowedDomains: [] }); // launch and acquire take the session scope. expectAssignable({ guardrails: sessionPolicy }); expectAssignable({ guardrails: { allowedDomains: ['*.example.com'] }, keep_alive: 30000 }); +expectAssignable({ outboundByHost: { 'app.example.com': outboundWorker } }); +expectAssignable({ lab: true, outboundByHost: { 'app.example.com': outboundWorker } }); +expectNotAssignable({ outboundByHost: { 'app.example.com': {} } }); await launch(binding, { guardrails: { allowedDomains: ['*.example.com'] } }); await acquire(binding, { guardrails: { allowedDomainSets: ['common-cdns'] } }); @@ -45,6 +49,10 @@ expectNotAssignable({ guardrails: { allowedHosts: [] } }); // @ts-expect-error endpointURLString(binding, { guardrails: sessionPolicy }); +// Worker Fetchers are live capabilities, so an endpoint URL cannot carry them. +// @ts-expect-error +endpointURLString(binding, { outboundByHost: { 'app.example.com': outboundWorker } }); + // Guardrails stay optional: existing calls keep compiling. await launch(binding); await acquire(binding); From b39a8ebee6b89b79e5f380e69e37028288b30143 Mon Sep 17 00:00:00 2001 From: Niklas Huthmann Date: Tue, 22 Sep 2026 11:27:08 +0100 Subject: [PATCH 2/2] chore: bump version to 1.3.7-next --- packages/playwright-cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-cloudflare/package.json b/packages/playwright-cloudflare/package.json index db0295b8e..7a0a82946 100644 --- a/packages/playwright-cloudflare/package.json +++ b/packages/playwright-cloudflare/package.json @@ -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",