lilcdp
v0.1.0
Published
Playwright-free Chrome DevTools Protocol client for Cloudflare Workers (workerd) and Node 22+ — drive Browser Run with zero dependencies
Maintainers
Readme
lilcdp
Playwright-free Chrome DevTools Protocol client that runs inside Cloudflare Workers (workerd) and Node 22+. Zero runtime dependencies.
Driving Cloudflare Browser Run from a Worker, playwright-core is a dead end —
it does a dynamic require of its own package.json that workerd cannot
resolve. lilcdp speaks the ~dozen CDP commands an agent actually needs over a
raw WebSocket, so the whole client is a few hundred lines you can read.
Quickstart (Worker + Browser Run)
import { connect, reconnect } from "lilcdp";
const endpoint = `wss://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/browser-rendering/devtools/browser`;
// keep_alive: the browser session survives disconnect() for 10 minutes
const browser = await connect(endpoint, { token: env.BROWSER_TOKEN, keepAliveMs: 600_000 });
const page = await browser.newPage("https://example.com");
const title = await page.evaluate<string>("document.title");
const png = await page.screenshot(); // base64
await page.click(100, 200);
await page.type("hello");
await page.press("Enter");
const axTree = await page.getFullAXTree(); // raw Accessibility domain passthrough
// Park the browser — the session (and page state) stays alive remotely:
await browser.disconnect(); // NOT close() — close() kills the session
const sessionId = browser.sessionId; // cf-browser-session-id upgrade header
// …later, from another step / another invocation:
const again = await reconnect(endpoint, sessionId!, { token: env.BROWSER_TOKEN });
const targets = await again.listTargets();
const page2 = await again.attach(targets.find((t) => t.type === "page")!.targetId);
// localStorage, window state, cookies — all still thereThe workerd gotcha (why transport exists)
new WebSocket(url, { headers }) silently drops headers in Workers — so
auth never reaches the endpoint. The default transport in workerd is the
documented fetch-Upgrade pattern instead:
const resp = await fetch(httpsUrl, { headers: { Upgrade: "websocket", Authorization: `Bearer ${token}` } });
const ws = resp.webSocket; // status 101
ws.accept();In Node the plain WebSocket constructor carries headers fine, so the default
there is websocket. defaultTransportKind() exposes the pick. Tests (or
exotic runtimes) can inject their own:
await connect(endpoint, { token, transport: async (url, headers) => ({ transport: mySocket }) });The fetch-Upgrade handshake itself can't be exercised from Node (undici can't
complete a WS upgrade via fetch), so it's covered by the live smoke script,
not the unit tests.
API
connect(endpoint, { token, keepAliveMs?, requestTimeoutMs?, transport? })→CdpBrowserreconnect(endpoint, sessionId, opts)→CdpBrowser(path-segment reconnect, Browser Run verified)- browser:
newPage(url?),attach(targetId),listTargets(),send(method, params, sessionId?)(raw escape hatch),onEvent(cb),disconnect(),close() - page:
navigate,evaluate,screenshot("png"|"jpeg"),getFullAXTree(),click(x,y),type(text),press(key)(Enter/Tab/Backspace/Escape mapped; any key string works),close()
Development
pnpm test # node:test, fake-socket transport — no network
pnpm smoke # live Browser Run smoke (needs CLOUDFLARE_ACCOUNT_ID + token env)MIT.
