Skip to content

Commit 5aee5b0

Browse files
authored
Merge pull request #1 from lightpanda-io/cdp-server
feat: add CDPServer to drive lightpanda with Playwright/Puppeteer
2 parents dadfa31 + ea99ceb commit 5aee5b0

11 files changed

Lines changed: 540 additions & 79 deletions

File tree

.github/workflows/wheels.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,9 @@ jobs:
183183
- name: Install the wheel into a clean venv
184184
run: |
185185
uv venv wheel-env
186-
uv pip install --python wheel-env dist/*.whl pytest pytest-asyncio
186+
# playwright is a CDP client here (connect_over_cdp), so no browser
187+
# download (`playwright install`) is needed.
188+
uv pip install --python wheel-env dist/*.whl pytest pytest-asyncio playwright
187189
188190
- name: Run the test suite against the installed wheel
189191
run: |

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,34 @@ The package also puts the full `lightpanda` CLI on PATH — agent REPL, fetch,
5959
serve, and the rest: see the
6060
[command reference](https://lightpanda.io/docs/run-locally/commands).
6161

62+
## Drive it with Playwright or Puppeteer
63+
64+
Lightpanda has its own Chrome DevTools Protocol server, so existing
65+
Playwright/Puppeteer code works against it without Chromium. `CDPServer`
66+
starts `lightpanda serve` on a free localhost port and hands you the
67+
endpoint:
68+
69+
```python
70+
from lightpanda import CDPServer
71+
from playwright.sync_api import sync_playwright
72+
73+
with CDPServer() as server, sync_playwright() as p:
74+
browser = p.chromium.connect_over_cdp(server.ws_endpoint)
75+
page = browser.new_context().new_page()
76+
page.goto("https://example.com")
77+
print(page.title())
78+
```
79+
80+
`AsyncCDPServer` is the asyncio twin (`async with AsyncCDPServer() as server`).
81+
`server.ws_endpoint` is a plain CDP WebSocket, so Node clients connect to it
82+
too: Puppeteer with `puppeteer.connect({ browserWSEndpoint })` and Playwright
83+
with `chromium.connectOverCDP(...)`. Pass `port=` to pin the port, and
84+
`args=` for `lightpanda serve` flags such as `--cdp-max-connections` or
85+
`--http-proxy`. This is a separate process from `Browser` (the binary
86+
cannot serve MCP and CDP from the same one). The package itself needs only
87+
Python's standard library; Playwright is a dev-only test dependency and
88+
`connect_over_cdp` never downloads a browser.
89+
6290
## How the bindings work
6391

6492
Every browser tool is a `Session` method, typed and documented in your IDE.
@@ -97,6 +125,9 @@ lightpanda binary. Then:
97125
uv run --group dev pytest tests
98126
```
99127

128+
The `dev` group includes `playwright` for the CDP tests (its pip package
129+
only; no `playwright install`). Those tests skip when it is absent.
130+
100131
Regenerate the tool methods (`lightpanda/_methods.py`) and the API docs:
101132

102133
```bash

lightpanda/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@
1919
await page.goto(url="https://example.com")
2020
data = await page.extract(schema={"title": "h1"})
2121
```
22+
23+
For Playwright or Puppeteer code, ``CDPServer`` runs the browser's own
24+
Chrome DevTools Protocol server and hands you the endpoint to connect to
25+
(see its docs for an example).
2226
"""
2327

2428
from .async_browser import AsyncBrowser, AsyncSession, run_script_async
2529
from .browser import Browser, Session, run_script
30+
from .cdp import AsyncCDPServer, CDPServer
2631
from .errors import LightpandaError, ProtocolError, ScriptError, ToolError
2732

2833
__all__ = [
@@ -32,6 +37,8 @@
3237
"AsyncBrowser",
3338
"AsyncSession",
3439
"run_script_async",
40+
"CDPServer",
41+
"AsyncCDPServer",
3542
"LightpandaError",
3643
"ProtocolError",
3744
"ScriptError",

lightpanda/cdp.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Chrome DevTools Protocol: run ``lightpanda serve`` and hand out its endpoint.
2+
3+
The browser has a native CDP server (``lightpanda serve``): a WebSocket on
4+
``/`` plus the ``/json/version`` discovery endpoint Chrome exposes. Anything
5+
that speaks CDP connects to it directly — Playwright (``connect_over_cdp``),
6+
Puppeteer (``connect({browserWSEndpoint})``), chromedp, cdp-use — with no
7+
Chromium involved. :class:`CDPServer` spawns that server on a free localhost
8+
port and owns the process; :class:`AsyncCDPServer` is the asyncio twin.
9+
10+
This is a separate process from :class:`lightpanda.Browser`: the binary
11+
cannot serve MCP and CDP from one process, and MCP sessions and CDP
12+
browser contexts are unrelated anyway.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import asyncio
18+
import json
19+
import os
20+
import urllib.request
21+
22+
from .client import _HOST, _spawn, _terminate, find_binary
23+
from .errors import LightpandaError
24+
25+
_VERSION_TIMEOUT = 5.0
26+
27+
28+
def _get_version(port: int) -> dict:
29+
"""GET ``/json/version``. urllib sends an IP-literal ``Host`` and no
30+
``Origin``, which is what the browser's handshake accepts."""
31+
url = f"http://{_HOST}:{port}/json/version"
32+
try:
33+
with urllib.request.urlopen(url, timeout=_VERSION_TIMEOUT) as resp:
34+
return json.loads(resp.read())
35+
except (OSError, ValueError) as err: # URLError is an OSError
36+
raise LightpandaError(f"GET {url} failed: {err}") from err
37+
38+
39+
class CDPServer:
40+
"""A lightpanda process serving the Chrome DevTools Protocol on 127.0.0.1.
41+
42+
```python
43+
from lightpanda import CDPServer
44+
from playwright.sync_api import sync_playwright
45+
46+
with CDPServer() as server, sync_playwright() as p:
47+
browser = p.chromium.connect_over_cdp(server.ws_endpoint)
48+
page = browser.new_context().new_page()
49+
page.goto("https://example.com")
50+
```
51+
52+
Every connected client gets its own browser; up to 16 connect at once
53+
by default (``args=["--cdp-max-connections", "N"]`` to change). The
54+
process is stopped by :meth:`close` / leaving the ``with`` block, and on
55+
Linux also when the interpreter dies.
56+
"""
57+
58+
def __init__(
59+
self,
60+
binary: str | os.PathLike | None = None,
61+
env: dict[str, str] | None = None,
62+
verbose: bool = False,
63+
args: tuple[str, ...] | list[str] = (),
64+
port: int | None = None,
65+
):
66+
"""``port`` pins the listening port (default: a free one). ``args``
67+
are extra ``lightpanda serve`` flags (``--http-proxy``, ``--cookie``,
68+
``--cdp-max-connections``, ...); pass ``port=`` rather than
69+
``--port``. ``verbose`` lets the browser log through to stderr."""
70+
self._proc, self._port = _spawn(find_binary(binary), "serve", args, env, verbose, port=port)
71+
72+
@property
73+
def port(self) -> int:
74+
return self._port
75+
76+
@property
77+
def ws_endpoint(self) -> str:
78+
"""The CDP WebSocket URL, ``ws://127.0.0.1:<port>/``.
79+
80+
Keep it as is: the server only upgrades on path ``/`` and only
81+
accepts an IP-literal or ``localhost`` host."""
82+
return f"ws://{_HOST}:{self._port}/"
83+
84+
@property
85+
def http_endpoint(self) -> str:
86+
"""``http://127.0.0.1:<port>``, for clients that discover the
87+
WebSocket through ``/json/version`` (Puppeteer's ``browserURL``,
88+
Playwright's ``connect_over_cdp`` with an http URL)."""
89+
return f"http://{_HOST}:{self._port}"
90+
91+
def version(self) -> dict:
92+
"""The ``/json/version`` document (browser, protocol version,
93+
``webSocketDebuggerUrl``)."""
94+
if self._proc is None:
95+
raise LightpandaError("server closed")
96+
return _get_version(self._port)
97+
98+
def close(self) -> None:
99+
if self._proc is not None:
100+
_terminate(self._proc)
101+
self._proc = None
102+
103+
def __enter__(self):
104+
return self
105+
106+
def __exit__(self, *exc):
107+
self.close()
108+
109+
def __del__(self):
110+
try:
111+
self.close()
112+
except Exception:
113+
pass
114+
115+
116+
class AsyncCDPServer:
117+
""":class:`CDPServer` for asyncio: the process is spawned by
118+
:meth:`start`, called automatically on ``async with`` entry.
119+
120+
```python
121+
async with AsyncCDPServer() as server, async_playwright() as p:
122+
browser = await p.chromium.connect_over_cdp(server.ws_endpoint)
123+
```
124+
"""
125+
126+
def __init__(
127+
self,
128+
binary: str | os.PathLike | None = None,
129+
env: dict[str, str] | None = None,
130+
verbose: bool = False,
131+
args: tuple[str, ...] | list[str] = (),
132+
port: int | None = None,
133+
):
134+
"""Arguments are forwarded to :class:`CDPServer`."""
135+
self._kwargs = dict(binary=binary, env=env, verbose=verbose, args=args, port=port)
136+
self._server: CDPServer | None = None
137+
self._start_lock = asyncio.Lock()
138+
139+
async def start(self) -> AsyncCDPServer:
140+
"""Spawn the server process. Idempotent."""
141+
if self._server is None:
142+
async with self._start_lock:
143+
if self._server is None:
144+
self._server = await asyncio.to_thread(CDPServer, **self._kwargs)
145+
return self
146+
147+
def _started(self) -> CDPServer:
148+
if self._server is None:
149+
raise LightpandaError("server not started; use `async with` or `await start()`")
150+
return self._server
151+
152+
@property
153+
def port(self) -> int:
154+
return self._started().port
155+
156+
@property
157+
def ws_endpoint(self) -> str:
158+
"""See :attr:`CDPServer.ws_endpoint`."""
159+
return self._started().ws_endpoint
160+
161+
@property
162+
def http_endpoint(self) -> str:
163+
"""See :attr:`CDPServer.http_endpoint`."""
164+
return self._started().http_endpoint
165+
166+
async def version(self) -> dict:
167+
"""See :meth:`CDPServer.version`."""
168+
return await asyncio.to_thread(self._started().version)
169+
170+
async def close(self) -> None:
171+
if self._server is not None:
172+
server, self._server = self._server, None
173+
await asyncio.to_thread(server.close)
174+
175+
async def __aenter__(self):
176+
return await self.start()
177+
178+
async def __aexit__(self, *exc):
179+
await self.close()
180+
181+
182+
__all__ = ["CDPServer", "AsyncCDPServer"]

0 commit comments

Comments
 (0)