From 3858951e95a404b7b563cf6c77856c0c57115f2b Mon Sep 17 00:00:00 2001 From: Lord Coughmann Date: Mon, 27 Jul 2026 19:14:38 +0800 Subject: [PATCH 1/3] fix(cloudflare-playwright): use ESM specifiers for NodeNext types --- packages/cloudflare-playwright/README.md | 2 +- packages/cloudflare-playwright/index.d.ts | 32 ++++----- packages/cloudflare-playwright/internal.d.ts | 6 +- packages/cloudflare-playwright/package.json | 3 - .../patches/CHECKLIST.md | 68 +++++++++++++------ packages/cloudflare-playwright/test.d.ts | 4 +- .../cloudflare-playwright/types/structs.d.ts | 2 +- .../cloudflare-playwright/types/test.d.ts | 4 +- .../cloudflare-playwright/types/types.d.ts | 4 +- 9 files changed, 74 insertions(+), 51 deletions(-) diff --git a/packages/cloudflare-playwright/README.md b/packages/cloudflare-playwright/README.md index 31cbf07..8961ce4 100644 --- a/packages/cloudflare-playwright/README.md +++ b/packages/cloudflare-playwright/README.md @@ -25,7 +25,7 @@ Forked from `@cloudflare/playwright@1.3.0`. Resynced on each upstream release vi Two of the four patches have corresponding upstream PRs: - [PR #193 — lazy-load `cloudflare:workers`](https://github.com/cloudflare/playwright/pull/193) -- [PR #194 — `.d.ts` extensions for NodeNext](https://github.com/cloudflare/playwright/pull/194) +- [PR #194 — ESM specifiers for NodeNext](https://github.com/cloudflare/playwright/pull/194) ## Used by diff --git a/packages/cloudflare-playwright/index.d.ts b/packages/cloudflare-playwright/index.d.ts index 9e0afc0..fd8a23c 100644 --- a/packages/cloudflare-playwright/index.d.ts +++ b/packages/cloudflare-playwright/index.d.ts @@ -1,11 +1,11 @@ import * as FS from 'fs'; -import type { Browser } from './types/types.d.ts'; -import { chromium, request, selectors, devices } from './types/types.d.ts'; +import type { Browser } from './types/types.js'; +import { chromium, request, selectors, devices } from './types/types.js'; import { env } from 'cloudflare:workers'; -export * from './types/types.d.ts'; +export * from './types/types.js'; -declare module './types/types.d.ts' { +declare module './types/types.js' { interface Browser { /** * Get the Browser Rendering session ID associated with this browser @@ -140,18 +140,18 @@ export function history(endpoint: BrowserEndpoint): Promise; */ export function limits(endpoint: BrowserEndpoint): Promise; -const playwright = { - chromium, - selectors, - request, - devices, - endpointURLString, - connect, - launch, - limits, - sessions, - history, - acquire, +declare const playwright: { + chromium: typeof chromium; + selectors: typeof selectors; + request: typeof request; + devices: typeof devices; + endpointURLString: typeof endpointURLString; + connect: typeof connect; + launch: typeof launch; + limits: typeof limits; + sessions: typeof sessions; + history: typeof history; + acquire: typeof acquire; }; export type Playwright = typeof playwright; diff --git a/packages/cloudflare-playwright/internal.d.ts b/packages/cloudflare-playwright/internal.d.ts index 14fd0f8..cc49f8a 100644 --- a/packages/cloudflare-playwright/internal.d.ts +++ b/packages/cloudflare-playwright/internal.d.ts @@ -1,8 +1,8 @@ import { isUnderTest } from 'playwright-core/lib/utils'; -import { BrowserBindingName } from './tests/src/utils.d.ts'; +import { BrowserBindingName } from './tests/src/utils.js'; -export * from './tests.d.ts'; -export { expect, _baseTest, Fixtures, mergeTests } from './types/test.d.ts'; +export * from './tests.js'; +export { expect, _baseTest, Fixtures, mergeTests } from './types/test.js'; export type TestStatus = 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'; diff --git a/packages/cloudflare-playwright/package.json b/packages/cloudflare-playwright/package.json index 0ab8208..f760539 100644 --- a/packages/cloudflare-playwright/package.json +++ b/packages/cloudflare-playwright/package.json @@ -26,9 +26,6 @@ "./internal": { "types": "./internal.d.ts", "default": "./lib/internal.js" - }, - "./types/types": { - "types": "./types/types.d.ts" } }, "files": [ diff --git a/packages/cloudflare-playwright/patches/CHECKLIST.md b/packages/cloudflare-playwright/patches/CHECKLIST.md index 6634457..84bd75d 100644 --- a/packages/cloudflare-playwright/patches/CHECKLIST.md +++ b/packages/cloudflare-playwright/patches/CHECKLIST.md @@ -155,61 +155,87 @@ longer needed — drop it. ## 3. ESM type resolution -**Files:** `index.d.ts`, `internal.d.ts`, `test.d.ts` +**Files:** `index.d.ts`, `internal.d.ts`, `test.d.ts`, `types/*.d.ts`, `package.json` **Why:** Upstream's type files use extension-less imports (`from './types/types'`). With `moduleResolution: NodeNext` / `moduleResolution: Node16` these fail to resolve, breaking type-checking -for downstream consumers. Add `.d.ts` extensions to every relative -import / export / module declaration. +for downstream consumers. Use `.js` specifiers for relative ESM imports; +TypeScript resolves them to the corresponding declaration files while +`.d.ts` specifiers are rejected for value imports and exports. **What:** -In `index.d.ts`: +In the hand-written declaration files, use `.js` for every relative +specifier: ```diff -import type { Browser } from './types/types'; -import { chromium, request, selectors, devices } from './types/types'; -+import type { Browser } from './types/types.d.ts'; -+import { chromium, request, selectors, devices } from './types/types.d.ts'; ++import type { Browser } from './types/types.js'; ++import { chromium, request, selectors, devices } from './types/types.js'; -export * from './types/types'; -+export * from './types/types.d.ts'; ++export * from './types/types.js'; -declare module './types/types' { -+declare module './types/types.d.ts' { ++declare module './types/types.js' { ``` -In `internal.d.ts`: +Apply the same conversion in `internal.d.ts` and `test.d.ts`: ```diff -import { BrowserBindingName } from './tests/src/utils'; -+import { BrowserBindingName } from './tests/src/utils.d.ts'; ++import { BrowserBindingName } from './tests/src/utils.js'; -export * from './tests'; -export { expect, _baseTest, Fixtures, mergeTests } from './types/test'; -+export * from './tests.d.ts'; -+export { expect, _baseTest, Fixtures, mergeTests } from './types/test.d.ts'; ++export * from './tests.js'; ++export { expect, _baseTest, Fixtures, mergeTests } from './types/test.js'; + +-export * from './index'; +-export { expect, mergeExpects } from './types/test'; ++export * from './index.js'; ++export { expect, mergeExpects } from './types/test.js'; ``` -In `test.d.ts`: +Update generated declarations to use `.js` for their relative imports and +exports as well, including `types/structs.d.ts`, `types/types.d.ts`, and +`types/test.d.ts`. Do not rewrite relative paths in documentation examples. + +The hand-written default export must be declared as an ambient value rather +than initialized at the top level of a declaration file: ```diff --export * from './index'; --export { expect, mergeExpects } from './types/test'; -+export * from './index.d.ts'; -+export { expect, mergeExpects } from './types/test.d.ts'; +-const playwright = { ... }; ++declare const playwright: { ++ chromium: typeof chromium; ++ selectors: typeof selectors; ++ request: typeof request; ++ devices: typeof devices; ++ endpointURLString: typeof endpointURLString; ++ connect: typeof connect; ++ launch: typeof launch; ++ limits: typeof limits; ++ sessions: typeof sessions; ++ history: typeof history; ++ acquire: typeof acquire; ++}; ``` +Remove the unnecessary `./types/types` package export; package-root and +`@cloudflare/playwright/test` consumers resolve their declarations through +their existing public exports. + **Verify:** ```sh -# Should print 0 — every relative .d.ts import has its extension. -grep -E "from '\./[^']+'" index.d.ts internal.d.ts test.d.ts | grep -v '\.d\.ts' +# Should print no extension-less relative declarations in executable specifiers. +rg "(from|declare module) ['\"]\./" index.d.ts internal.d.ts test.d.ts types/structs.d.ts types/types.d.ts | grep -vE "\.(js|mjs|cjs|json)['\"]" ``` -If upstream added `.d.ts` extensions itself, this patch is no longer -needed — drop it. +If upstream adds equivalent ESM specifiers and declaration fixes itself, this +patch is no longer needed — drop it. --- diff --git a/packages/cloudflare-playwright/test.d.ts b/packages/cloudflare-playwright/test.d.ts index af8f706..6ba4b62 100644 --- a/packages/cloudflare-playwright/test.d.ts +++ b/packages/cloudflare-playwright/test.d.ts @@ -1,3 +1,3 @@ -export * from './index.d.ts'; -export { expect, mergeExpects } from './types/test.d.ts'; +export * from './index.js'; +export { expect, mergeExpects } from './types/test.js'; diff --git a/packages/cloudflare-playwright/types/structs.d.ts b/packages/cloudflare-playwright/types/structs.d.ts index 9c621cf..1cfca27 100644 --- a/packages/cloudflare-playwright/types/structs.d.ts +++ b/packages/cloudflare-playwright/types/structs.d.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JSHandle, ElementHandle, Frame, Page, BrowserContext } from './types'; +import { JSHandle, ElementHandle, Frame, Page, BrowserContext } from './types.js'; /** * Can be converted to JSON diff --git a/packages/cloudflare-playwright/types/test.d.ts b/packages/cloudflare-playwright/types/test.d.ts index 9ac8932..64d3bd9 100644 --- a/packages/cloudflare-playwright/types/test.d.ts +++ b/packages/cloudflare-playwright/types/test.d.ts @@ -15,8 +15,8 @@ * limitations under the License. */ -import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from './types'; -export * from './types'; +import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from './types.js'; +export * from './types.js'; export type BlobReporterOptions = { outputDir?: string, fileName?: string }; export type ListReporterOptions = { printSteps?: boolean }; diff --git a/packages/cloudflare-playwright/types/types.d.ts b/packages/cloudflare-playwright/types/types.d.ts index f6e6165..0317fc1 100644 --- a/packages/cloudflare-playwright/types/types.d.ts +++ b/packages/cloudflare-playwright/types/types.d.ts @@ -17,8 +17,8 @@ import { ChildProcess } from 'child_process'; import { Readable } from 'stream'; import { ReadStream } from 'fs'; -import { Protocol } from './protocol'; -import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from './structs'; +import { Protocol } from './protocol.js'; +import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from './structs.js'; type PageWaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & { state?: 'visible'|'attached'; From 9e462111ba65901479b5cb6e1fa1d67f9070bbd7 Mon Sep 17 00:00:00 2001 From: Lord Coughmann Date: Mon, 27 Jul 2026 20:10:04 +0800 Subject: [PATCH 2/3] fix(cloudflare-playwright): harden external CDP connections --- packages/cloudflare-playwright/lib/index.js | 58 +++++++++++++--- .../patches/CHECKLIST.md | 68 +++++++++---------- 2 files changed, 81 insertions(+), 45 deletions(-) diff --git a/packages/cloudflare-playwright/lib/index.js b/packages/cloudflare-playwright/lib/index.js index 024335c..8663c2d 100644 --- a/packages/cloudflare-playwright/lib/index.js +++ b/packages/cloudflare-playwright/lib/index.js @@ -33,7 +33,8 @@ wrapClientApis(); const HTTP_FAKE_HOST = "http://fake.host"; const WS_FAKE_HOST = "ws://fake.host"; const originalConnectOverCDP = playwright.chromium.connectOverCDP; -playwright.chromium.connectOverCDP = (endpointURLOrOptions) => { +playwright.chromium.connectOverCDP = (endpointURLOrOptions, options) => { + const connectOptions = typeof endpointURLOrOptions === "string" ? options : endpointURLOrOptions; const wsEndpoint = typeof endpointURLOrOptions === "string" ? endpointURLOrOptions : endpointURLOrOptions.wsEndpoint ?? endpointURLOrOptions.endpointURL; if (!wsEndpoint) throw new Error("No wsEndpoint provided"); @@ -41,7 +42,7 @@ playwright.chromium.connectOverCDP = (endpointURLOrOptions) => { // Support external WebSocket endpoints (Steel, Browserbase, local browser) // These bypass Cloudflare browser binding entirely if (wsEndpoint.startsWith("ws://") || wsEndpoint.startsWith("wss://")) { - return connectToExternalWebSocket(wsEndpoint); + return connectToExternalWebSocket(wsEndpoint, connectOptions); } const wsUrl = new URL(wsEndpoint); @@ -51,16 +52,53 @@ playwright.chromium.connectOverCDP = (endpointURLOrOptions) => { }; // Connect to external CDP endpoint via standard WebSocket (no browser binding needed) -async function connectToExternalWebSocket(wsEndpoint) { +async function connectToExternalWebSocket(wsEndpoint, options) { resetMonotonicTime(); const webSocket = new WebSocket(wsEndpoint); - await new Promise((resolve, reject) => { - webSocket.addEventListener("open", () => resolve()); - webSocket.addEventListener("error", (error) => reject(error)); - }); + await waitForExternalWebSocketOpen(webSocket, options?.timeout ?? 30_000); const sessionId = new URL(wsEndpoint).searchParams.get("browser_session") ?? ""; const transport = new WebSocketTransport(webSocket, sessionId); - return await createBrowser(transport, { persistent: true }); + const browserOptions = options && { + isLocal: options.isLocal, + logger: options.logger, + slowMo: options.slowMo, + timeout: options.timeout + }; + return await createBrowser(transport, { persistent: true }, browserOptions); +} +function waitForExternalWebSocketOpen(webSocket, timeout) { + return new Promise((resolve, reject) => { + let timeoutId; + const cleanup = () => { + if (timeoutId) + clearTimeout(timeoutId); + webSocket.removeEventListener("open", onOpen); + webSocket.removeEventListener("error", onError); + webSocket.removeEventListener("close", onClose); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error("External CDP WebSocket connection failed")); + }; + const onClose = () => { + cleanup(); + reject(new Error("External CDP WebSocket closed before opening")); + }; + webSocket.addEventListener("open", onOpen); + webSocket.addEventListener("error", onError); + webSocket.addEventListener("close", onClose); + if (timeout > 0) { + timeoutId = setTimeout(() => { + cleanup(); + webSocket.close(); + reject(new Error(`Timed out after ${timeout}ms while connecting to external CDP endpoint`)); + }, timeout); + } + }); } async function connectDevtools(endpoint, options) { resetMonotonicTime(); @@ -102,12 +140,12 @@ function endpointURLString(binding, options) { url.searchParams.set("keep_alive", options.keepAlive.toString()); return url.toString(); } -async function createBrowser(transport, options) { +async function createBrowser(transport, options, connectOptions) { return await transportZone.run(transport, async () => { const url = new URL(WS_FAKE_HOST); if (options?.persistent) url.searchParams.set("persistent", "true"); - const browser = await originalConnectOverCDP.call(playwright.chromium, url.toString(), {}); + const browser = await originalConnectOverCDP.call(playwright.chromium, url.toString(), connectOptions ?? {}); browser.sessionId = () => transport.sessionId; return browser; }); diff --git a/packages/cloudflare-playwright/patches/CHECKLIST.md b/packages/cloudflare-playwright/patches/CHECKLIST.md index 84bd75d..02ae9bf 100644 --- a/packages/cloudflare-playwright/patches/CHECKLIST.md +++ b/packages/cloudflare-playwright/patches/CHECKLIST.md @@ -95,60 +95,58 @@ needed — drop it from this checklist. **Why:** Upstream's `connectOverCDP()` only handles Cloudflare's internal browser-binding endpoints. Detect `ws://` / `wss://` URLs (Steel, -Browserbase, local Chrome, anything) and connect via a standard -WebSocket instead, bypassing the browser-binding machinery. +Browserbase, local Chrome, anything) and connect via a standard WebSocket +instead, bypassing the browser-binding machinery. **What:** -Find this line (inside the body of -`playwright.chromium.connectOverCDP = (endpointURLOrOptions) => { ... }`): +In `playwright.chromium.connectOverCDP`, preserve the optional connection +options and route external WebSocket URLs before the existing Browser Run +URL handling: ```js - const wsUrl = new URL(wsEndpoint); - if (!wsUrl.searchParams.has("persistent")) - wsUrl.searchParams.set("persistent", "true"); +playwright.chromium.connectOverCDP = (endpointURLOrOptions, options) => { + const connectOptions = typeof endpointURLOrOptions === 'string' ? options : endpointURLOrOptions; + const wsEndpoint = typeof endpointURLOrOptions === 'string' + ? endpointURLOrOptions + : endpointURLOrOptions.wsEndpoint ?? endpointURLOrOptions.endpointURL; + if (!wsEndpoint) + throw new Error('No wsEndpoint provided'); + + if (wsEndpoint.startsWith('ws://') || wsEndpoint.startsWith('wss://')) + return connectToExternalWebSocket(wsEndpoint, connectOptions); + + // Existing Cloudflare Browser Run handling remains unchanged. +}; ``` -Insert this block **immediately before** it: - -```js - // Support external WebSocket endpoints (Steel, Browserbase, local browser) - // These bypass Cloudflare browser binding entirely - if (wsEndpoint.startsWith("ws://") || wsEndpoint.startsWith("wss://")) { - return connectToExternalWebSocket(wsEndpoint); - } -``` +The external helper should: -Then add this helper function near the other top-level helpers in the -same file (a good spot is right after the existing `connectDevtools` -function): +- Open the endpoint with the standard WebSocket API. +- Use a 30-second opening timeout by default; `timeout: 0` disables it. +- Reject and clean up on socket error, early close, or timeout. +- Preserve `browser_session` as the browser session ID. +- Forward `slowMo`, `isLocal`, `logger`, and `timeout` into browser creation. +- Use the existing raw-JSON `WebSocketTransport`; do not reintroduce the + obsolete chunking toggle from PR `#59`. -```js -// Connect to external CDP endpoint via standard WebSocket (no browser binding needed) -async function connectToExternalWebSocket(wsEndpoint) { - resetMonotonicTime(); - const webSocket = new WebSocket(wsEndpoint); - await new Promise((resolve, reject) => { - webSocket.addEventListener("open", () => resolve()); - webSocket.addEventListener("error", (error) => reject(error)); - }); - const sessionId = new URL(wsEndpoint).searchParams.get("browser_session") ?? ""; - const transport = new WebSocketTransport(webSocket, sessionId); - return await createBrowser(transport, { persistent: true }); -} -``` +The standard Worker WebSocket constructor does not support arbitrary request +headers. `ConnectOverCDPOptions.headers` is therefore not applied on this +external path; providers should use credentials in the connection URL. **Verify:** ```sh -# Should print 2 (the if-check + the helper definition). +# Should print 2 (the route call + helper definition). grep -c 'connectToExternalWebSocket' lib/index.js +# Should print 2 (the helper call + definition). +grep -c 'waitForExternalWebSocketOpen' lib/index.js # Smoke-test that the package loads in Node.js: node -e "import('./lib/index.js').then(m => console.log(typeof m.chromium))" # Should print: function ``` -If upstream merged equivalent external-CDP support, this patch is no +If upstream merges equivalent external-CDP support, this patch is no longer needed — drop it. --- From bc493d26b9b0fded92685d95e75fe4ef83aaef78 Mon Sep 17 00:00:00 2001 From: Lord Coughmann Date: Mon, 27 Jul 2026 21:50:03 +0800 Subject: [PATCH 3/3] docs(cloudflare-playwright): track external CDP upstream PR --- packages/cloudflare-playwright/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare-playwright/README.md b/packages/cloudflare-playwright/README.md index 8961ce4..c48bb26 100644 --- a/packages/cloudflare-playwright/README.md +++ b/packages/cloudflare-playwright/README.md @@ -22,10 +22,11 @@ The full list of patches and why each is needed lives in [`patches/CHECKLIST.md` Forked from `@cloudflare/playwright@1.3.0`. Resynced on each upstream release via [`scripts/sync-upstream.sh`](./scripts/sync-upstream.sh). Versioned on its own `0.x` series; the upstream version it tracks is documented in this README and the changelog, not encoded in the package version. -Two of the four patches have corresponding upstream PRs: +Three of the four patches have corresponding upstream PRs: - [PR #193 — lazy-load `cloudflare:workers`](https://github.com/cloudflare/playwright/pull/193) - [PR #194 — ESM specifiers for NodeNext](https://github.com/cloudflare/playwright/pull/194) +- [PR #221 — external WebSocket CDP endpoints](https://github.com/cloudflare/playwright/pull/221) ## Used by