Files
2026-09-09 10:09:14 +04:00

286 lines
11 KiB
Markdown

# Donut Browser SDKs
Two thin clients for the REST API that Donut Browser serves on this machine:
[`python/`](python) (`donutbrowser`) and [`node/`](node) (`@donutbrowser/sdk`).
They are deliberately thin. Every method is one request to one path that the
app publishes in its own `/openapi.json`, with the request and response shapes
taken from the Rust handlers in `src-tauri/src/api_server.rs`. Nothing is
cached, nothing is retried, and no endpoint is invented. What the two add on top
of a bare HTTP call is the part that is tedious to redo in every script:
- the bearer token and the port, read from arguments or the environment,
- one exception class per documented status, with `Retry-After` parsed and the
app's `{"code": ...}` error bodies decoded,
- a launch-and-stop helper, so a script cannot leave a browser running,
- a drift check that fails the tests when the app grows an endpoint the SDK
does not cover.
Neither package is part of the pnpm workspace. They build, test and publish on
their own, so they never slow the desktop app's own checks down.
## Switch the API on first
**The local REST API is off by default. It must be enabled in the app under
Settings → Integrations → Local API → "Enable Local API Server".**
That screen also shows the two things a client needs:
- the **port**, `10108` unless it was already taken or you changed it, and
- the **authentication token**, sent as `Authorization: Bearer <token>`.
The server binds `127.0.0.1` only, so it is never reachable from another
machine. Requests are also refused with `403` until the Wayfern terms have been
accepted in the app.
Both SDKs read arguments first, then the environment:
| Setting | Argument | Environment | Default |
| --- | --- | --- | --- |
| Token | `token` | `DONUT_API_TOKEN` | none; required |
| Port | `port` | `DONUT_API_PORT` | `10108` |
| Host | `host` | — | `127.0.0.1` |
`base_url` / `baseUrl` overrides host and port entirely, for the rare case of a
tunnel or a path prefix in front of the app.
## Python
Requires Python 3.10 or newer. **No runtime dependencies:** the client talks to
a loopback server on the same machine, so `http.client` from the standard
library is enough. That keeps `pip install donutbrowser` from dragging anything
into an automation environment, and it sidesteps a real trap — `urllib.request`
honours `http_proxy` from the environment, which would send calls meant for the
local app through whatever proxy the shell happens to have set.
```bash
cd sdk/python
pip install -e .
```
A worked example: launch a profile, drive the page through the agent endpoints,
and stop the browser.
```python
from donutbrowser import Conflict, DonutClient, NotFound, RateLimited
PROFILE_ID = "your-profile-id"
with DonutClient(token="...") as client:
# `run` starts the browser on entry and stops it on exit, even if the body
# raises. `session.cdp_url` is the DevTools endpoint the launch returned.
with client.run(PROFILE_ID, url="https://example.com", headless=True) as session:
print("CDP:", session.cdp_url)
# Read the page the way the agent sees it: roles, names, text, bounds.
page = client.agent_perceive(PROFILE_ID, viewport_only=True)
print(page["stats"]["returnedNodes"], "nodes,", len(page["text"]), "characters")
# Name an element without a selector, and check it is unambiguous.
search = {"role": "textbox", "nameContains": "Search"}
resolved = client.agent_resolve_locator(PROFILE_ID, locator=search)
assert resolved["matchCount"] == 1
client.agent_type(PROFILE_ID, locator=search, text="donut browser")
client.agent_click(PROFILE_ID, locator={"role": "button", "name": "Search"})
# Pull a table out of whatever came back.
rows = client.agent_extract(
PROFILE_ID,
container={"role": "listitem"},
field_map=[
{"key": "title", "locator": {"role": "heading"}, "source": "text"},
{"key": "link", "locator": {"role": "link"}, "source": "link"},
],
max_pages=3,
)
for row in rows["rows"]:
print(row["values"])
# The browser is stopped here.
```
Errors are classes, not status codes:
```python
try:
client.run_profile(PROFILE_ID)
except Conflict as busy:
print("someone else has it:", busy.code) # PROFILE_LOCKED_BY_MEMBER, ...
except RateLimited as limited:
print("wait", limited.retry_after, "seconds")
except NotFound:
print("no such profile")
```
### Tests
```bash
cd sdk/python
pip install -e ".[dev]"
pytest
```
## Node
Requires Node 22 or newer, for the built-in `fetch`. **No runtime
dependencies**; `typescript` is a development dependency and is needed only to
build `dist/` for publishing. The tests run straight from the TypeScript
sources through Node's own type stripping, so `npm test` works with nothing
installed at all.
```bash
cd sdk/node
npm install # only needed for `npm run build`
npm run build
```
The convenience helper is `withProfile(profileId, options, work)`, a callback
rather than `await using`. `await using` is not yet syntax any released V8
understands, so TypeScript has to down-level it — which would stop the sources
running under Node's type stripping, and with it `npm test` on a clean
checkout. The callback form works on every Node 22. A `RunSession` does also
implement `Symbol.asyncDispose`, so `await using` is there for anyone whose
toolchain already handles it.
```ts
import { Conflict, DonutClient, NotFound, RateLimited } from "@donutbrowser/sdk";
const PROFILE_ID = "your-profile-id";
const client = new DonutClient({ token: "..." });
// The browser starts before `work` runs and is stopped after it, even when it
// throws. `session.cdpUrl` is the DevTools endpoint the launch returned.
const titles = await client.withProfile(
PROFILE_ID,
{ url: "https://example.com", headless: true },
async (session) => {
console.log("CDP:", session.cdpUrl);
const page = await client.agentPerceive(PROFILE_ID, { viewport_only: true });
console.log(page.stats.returnedNodes, "nodes,", page.text.length, "characters");
const search = { role: "textbox", nameContains: "Search" };
const resolved = await client.agentResolveLocator(PROFILE_ID, { locator: search });
if (resolved.matchCount !== 1) {
throw new Error("the search box is ambiguous");
}
await client.agentType(PROFILE_ID, { locator: search, text: "donut browser" });
await client.agentClick(PROFILE_ID, {
locator: { role: "button", name: "Search" },
});
const extraction = await client.agentExtract(PROFILE_ID, {
container: { role: "listitem" },
field_map: [
{ key: "title", locator: { role: "heading" }, source: "text" },
{ key: "link", locator: { role: "link" }, source: "link" },
],
max_pages: 3,
});
return extraction.rows.map((row) => row.values.title);
},
);
// The browser is stopped here.
try {
await client.runProfile(PROFILE_ID);
} catch (error) {
if (error instanceof Conflict) {
console.log("someone else has it:", error.code);
} else if (error instanceof RateLimited) {
console.log("wait", error.retryAfter, "seconds");
} else if (error instanceof NotFound) {
console.log("no such profile");
} else {
throw error;
}
}
```
### Tests
```bash
cd sdk/node
npm test
```
`npm test` runs the TypeScript sources directly, which needs Node 22.18 or
newer (type stripping is unflagged from that release). The published package
ships compiled `.mjs`, so consumers only need Node 22.
## Errors
Both packages map the app's documented statuses onto the same set of classes.
The 5xx classes share one base, so a single `ServerError` branch catches every
server-side failure.
| Status | Python | Node | Meaning |
| ---: | --- | --- | --- |
| 400 | `ValidationError` | `ValidationError` | Malformed request, duplicate name, unsupported input |
| 401 | `Unauthorized` | `Unauthorized` | Missing or wrong bearer token |
| 402 | `PaymentRequired` | `PaymentRequired` | Automation needs an active paid plan |
| 403 | `Forbidden` | `Forbidden` | Wayfern terms not accepted, or not signed in |
| 404 | `NotFound` | `NotFound` | No entity with that id |
| 408 | `RequestTimeout` | `RequestTimeout` | `agent/pick` waited and nothing was picked |
| 409 | `Conflict` | `Conflict` | A browser, a teammate or a remote session holds the profile |
| 429 | `RateLimited` | `RateLimited` | Automation quota spent; `retry_after` / `retryAfter` |
| 500 | `ServerError` | `ServerError` | Internal failure |
| 502 | `BadGateway` | `BadGateway` | The browser or the relay answered wrongly |
| 503 | `ServiceUnavailable` | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
Anything else becomes `DonutAPIError` / `DonutApiError` (a `ServerError` for an
unrecognised 5xx), so a status added to the app later still arrives as
something a caller can catch. A transport failure — the app not running, the
API switched off, the wrong port — is `DonutConnectionError`, never an API
error, so "Donut is not there" is never confused with "Donut said no".
Every error carries `status`, `body`, `method` and `path`. When the body is one
of the app's structured `{"code": ..., "params": {...}}` strings, `code` and
`params` are filled in too.
A `503` from stopping something means the fleet could not be reached and the
remote browser is **still running**, not that it stopped.
## Staying in step with the app
`api-paths.json` in this directory lists every operation the app publishes. It
is generated from the `#[utoipa::path]` annotations and the `ApiDoc` `paths(...)`
list in `src-tauri/src/api_server.rs` — the two things the served
`/openapi.json` is actually built from — and the generator fails if a handler is
annotated but missing from `ApiDoc`, which is exactly how an endpoint silently
disappears from the spec.
```bash
python3 sdk/tools/extract-api-paths.py
```
Each SDK keeps its own table of operation to method (`donutbrowser.coverage` and
`OPERATIONS` in the Node package), and both test suites hold that table against
the snapshot in **both** directions:
- an operation in the snapshot that the SDK neither wraps nor lists as omitted
fails the suite, so a new endpoint cannot slip past unnoticed;
- an entry the app no longer publishes fails too, so a removed endpoint cannot
linger as a dead method;
- every wrapped operation must name a method that really exists, no two
operations may claim the same method, and every omission must carry a reason.
On top of that, one parameterised test per method drives it against a fake
server and asserts the exact verb, path, query string and JSON body it sends.
That is what ties the table to reality rather than to a comment.
Of the 71 published operations, 70 are wrapped. The one omission:
- `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an
HTTP client can make, and bundling a websocket implementation would end the
zero-dependency promise for one endpoint. `remote_session_cdp_url()` /
`remoteSessionCdpUrl()` builds the `ws://` address instead, so a websocket
library of your choosing can connect — send the same `Authorization: Bearer`
header on the handshake.
## Tests
Both suites run offline against a fake HTTP server on an ephemeral loopback
port. Neither needs the desktop app, a browser, a network, or credentials.