Skip to content
Closed
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
5 changes: 3 additions & 2 deletions packages/cloudflare-playwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `.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)
- [PR #221 — external WebSocket CDP endpoints](https://github.com/cloudflare/playwright/pull/221)

## Used by

Expand Down
32 changes: 16 additions & 16 deletions packages/cloudflare-playwright/index.d.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -140,18 +140,18 @@ export function history(endpoint: BrowserEndpoint): Promise<ClosedSession[]>;
*/
export function limits(endpoint: BrowserEndpoint): Promise<LimitsResponse>;

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;
Expand Down
6 changes: 3 additions & 3 deletions packages/cloudflare-playwright/internal.d.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
58 changes: 48 additions & 10 deletions packages/cloudflare-playwright/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ 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");

// 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);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
});
Expand Down
3 changes: 0 additions & 3 deletions packages/cloudflare-playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@
"./internal": {
"types": "./internal.d.ts",
"default": "./lib/internal.js"
},
"./types/types": {
"types": "./types/types.d.ts"
}
},
"files": [
Expand Down
136 changes: 80 additions & 56 deletions packages/cloudflare-playwright/patches/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,121 +95,145 @@ 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.

---

## 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.

---

Expand Down
4 changes: 2 additions & 2 deletions packages/cloudflare-playwright/test.d.ts
Original file line number Diff line number Diff line change
@@ -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';

2 changes: 1 addition & 1 deletion packages/cloudflare-playwright/types/structs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading