From 2172ee5313c05b534492d19b32fbeb5ab10ce8ea Mon Sep 17 00:00:00 2001 From: rcorreia Date: Mon, 26 Oct 2026 11:15:52 +0000 Subject: [PATCH] Setup brapi wrapped binding with websocket upgrade --- src/cloudflare/internal/br-api.ts | 145 ++- src/cloudflare/internal/browser-run.d.ts | 916 ++++++++++++++++++ .../internal/test/br/br-api-test.js | 165 ++++ .../internal/test/br/br-api-test.wd-test | 2 +- src/cloudflare/internal/test/br/br-mock.js | 100 +- 5 files changed, 1318 insertions(+), 10 deletions(-) create mode 100644 src/cloudflare/internal/browser-run.d.ts diff --git a/src/cloudflare/internal/br-api.ts b/src/cloudflare/internal/br-api.ts index 67619ca8329..41c7a469655 100644 --- a/src/cloudflare/internal/br-api.ts +++ b/src/cloudflare/internal/br-api.ts @@ -4,23 +4,158 @@ import wrappedBinding from 'cloudflare-internal:wrapped-binding'; +// Pairs each `BrowserRun.quickAction()` overload's action with its options. +type QuickActionOptions = { + screenshot: BrowserRunScreenshotOptions; + pdf: BrowserRunPDFOptions; + content: BrowserRunContentOptions; + scrape: BrowserRunScrapeOptions; + links: BrowserRunLinksOptions; + snapshot: BrowserRunSnapshotOptions; + json: BrowserRunJsonOptions; + markdown: BrowserRunMarkdownOptions; + accessibilityTree: BrowserRunAccessibilityTreeOptions; +}; + interface Fetcher { fetch: typeof fetch; + quickAction( + action: Action, + options: QuickActionOptions[Action] + ): Promise; + acquire(options?: BrowserRunAcquireOptions): Promise; + launch(options?: BrowserRunAcquireOptions): Promise; + connectSession( + sessionId: string, + options?: BrowserRunConnectOptions + ): Promise; + getLiveView( + sessionId: string, + options?: BrowserRunLiveViewOptions + ): Promise; + listSessions( + options?: BrowserRunListSessionsOptions + ): Promise; + history(options?: BrowserRunHistoryOptions): Promise; + limits(): Promise; + getSession(sessionId: string): Promise; + closeSession(sessionId: string): Promise; + readonly devtools: BrowserRunDevtools; } +type WebSocketOption = { websocket?: boolean }; + +type LaunchOptions = BrowserRunAcquireOptions & WebSocketOption; + +type ConnectSessionOptions = BrowserRunConnectOptions & WebSocketOption; + +type WebSocketConnection = Omit & { + webSocket: WebSocket; +}; + +type UpgradeResponse = Response & { webSocket: WebSocket | null }; + export class BrowserRendering extends wrappedBinding.WrappedBinding { - // TODO(soon): Can we use the # syntax here? - // eslint-disable-next-line no-restricted-syntax - private readonly fetcher: Fetcher; + readonly #fetcher: Fetcher; constructor(fetcher: Fetcher) { super(fetcher); - this.fetcher = fetcher; + this.#fetcher = fetcher; } async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - return this.fetcher.fetch(input, init); + return this.#fetcher.fetch(input, init); + } + + quickAction( + action: Action, + options: QuickActionOptions[Action] + ): Promise { + return this.#fetcher.quickAction(action, options); + } + + acquire( + options?: BrowserRunAcquireOptions + ): Promise { + return this.#fetcher.acquire(options); + } + + launch({ websocket, ...options }: LaunchOptions = {}): Promise< + BrowserRunConnection | WebSocketConnection + > { + const connection = this.#fetcher.launch(options); + if (!websocket) { + return connection; + } + return connection.then(async (con) => { + try { + return await upgrade(con); + } catch (err) { + await this.#fetcher.closeSession(con.sessionId).catch(() => undefined); + throw err; + } + }); + } + + connectSession( + sessionId: string, + { websocket, ...options }: ConnectSessionOptions = {} + ): Promise { + const connection = this.#fetcher.connectSession(sessionId, options); + return websocket ? connection.then(upgrade) : connection; + } + + getLiveView( + sessionId: string, + options?: BrowserRunLiveViewOptions + ): Promise { + return this.#fetcher.getLiveView(sessionId, options); + } + + listSessions( + options?: BrowserRunListSessionsOptions + ): Promise { + return this.#fetcher.listSessions(options); + } + + history(options?: BrowserRunHistoryOptions): Promise { + return this.#fetcher.history(options); + } + + limits(): Promise { + return this.#fetcher.limits(); + } + + getSession(sessionId: string): Promise { + return this.#fetcher.getSession(sessionId); + } + + closeSession(sessionId: string): Promise { + return this.#fetcher.closeSession(sessionId); + } + + get devtools(): BrowserRunDevtools { + return this.#fetcher.devtools; + } +} + +async function upgrade( + connection: BrowserRunConnection +): Promise { + const response = await connection.webSocket.fetch( + 'https://browser-binding.invalid/', + { + headers: { Upgrade: 'websocket' }, + } + ); + const webSocket = (response as UpgradeResponse).webSocket; + if (response.status !== 101 || !webSocket) { + const body = await response.text(); + throw new Error( + `Browser Run WebSocket upgrade failed with HTTP ${response.status}: ${body}` + ); } + return Object.assign(connection, { webSocket }); } export default function makeBinding(env: { diff --git a/src/cloudflare/internal/browser-run.d.ts b/src/cloudflare/internal/browser-run.d.ts new file mode 100644 index 00000000000..6e3bee6c4e9 --- /dev/null +++ b/src/cloudflare/internal/browser-run.d.ts @@ -0,0 +1,916 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +/***************************** + * + * !!! WARNING !!! + * Changes should be made in `types/defines/browser-run.d.ts` + * and then synced back here. + * + * This file is copy & pasted from the types/ folder + * because when bazel runs it doesn't have access to that directly (and thusly is sad). + * Only the eslint directive and the `Fetcher` declaration below are not part of the copy. + * + ******************************/ + +// The copied DevTools protocol types use `any`, and this copy must match its source. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Stands in for the generated `Fetcher` type, which this build does not have. +interface Fetcher { + fetch: typeof fetch; +} + +type BrowserRunLifecycleEvent = + 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; + +type BrowserRunResourceType = + | 'document' + | 'stylesheet' + | 'image' + | 'media' + | 'font' + | 'script' + | 'texttrack' + | 'xhr' + | 'fetch' + | 'prefetch' + | 'eventsource' + | 'websocket' + | 'manifest' + | 'signedexchange' + | 'ping' + | 'cspviolationreport' + | 'preflight' + | 'other'; + +/** Options fields shared by all quick actions. */ +interface BrowserRunBaseOptions { + /** Adds `