Atrium is an open-source toolkit for remote Chromium sessions where a host application can hand control to a human (OAuth, captchas, MFA), then resume automation and capture cookies or Playwright storage_state — without shipping raw CDP plumbing to your UI layer.
This repository is a pnpm monorepo (packages/*) implementing the architecture described in docs/remote-browser-design.md.
| Resource | What it is |
|---|---|
| npm quick start | Install from npm, start the worker, mount the server API, render <RemoteBrowser />, snapshot sessions. |
| User guide | How to ship: install, demo, embed @atriumjs/express + @atriumjs/react, multi-tab behavior, HTTP routes, snapshots, security. |
| Documentation hub | Index linking every package README, examples, design doc, and sprint artifacts. |
| Technical design | Architecture, wire protocol, deployment narrative. |
| npm publishing | Package split, prepublish checks, tarball inspection, and publish order for @atriumjs/*. |
| Public sites deploy | atriumjs.dev (static landing) + demo.atriumjs.dev (full demo), nginx, systemd, GitHub Actions SSH. |
| Sprint artifacts | Spec, progress, sprint-bundle.json, per-sprint contracts. |
- Human-in-the-loop — transfer control to a real user for OAuth / MFA / captchas, then export cookies and Playwright
storageState. - Multi-tab —
target="_blank"opens a managed tab; the worker emitstabsover the viewer WebSocket. - Optional viewer chrome —
@atriumjs/reactsupports presetsnone,minimal,full, or custom{ showTabStrip?, showToolbar?, showUrlBar? }around the live canvas. - TLS client certificates (mTLS) — upload a PEM or PFX bundle on
POST /sessions(clientCertificates) and Chromium presents it to the matching origin. - Passkey-aware viewer — passkeys are not supported in remote browsers (WebAuthn is unrelayable by design). The worker disguises Chromium as a device without a platform authenticator so most sites never offer the passkey button; if a site insists, the call rejects with
NotAllowedError(falls back to password / OTP) and<RemoteBrowser />shows a 6s toast. Details: user guide §7.
Install the core packages into your app:
npm install express @atriumjs/express @atriumjs/react @atriumjs/worker
npm install react react-dom
npx playwright install chromiumStart the Chromium worker:
ATRIUM_WORKER_SECRET=replace-me npx atrium-workerThen mount @atriumjs/express in your Express app and render @atriumjs/react's <RemoteBrowser /> with the session payload returned by POST /atrium/sessions.
Full walkthrough: npm quick start.
The @atriumjs/demo package runs the worker and a Vite + React UI on http://127.0.0.1:3333 using the same defaults as production-style configs in code (ATRIUM_WORKER_SECRET, ATRIUM_WORKER_DIAL_BASE). See packages/demo/README.md.
pnpm install
pnpm build
pnpm exec playwright install chromium # once per machine
pnpm demoOpen the app, edit the tweet if you want, then click Login and post. The demo opens X in a fullscreen remote browser, hands control to you for login, then resumes automation in the same session.
The demo is private, but the core pieces publish as public npm packages:
npm install express @atriumjs/express @atriumjs/react @atriumjs/worker@atriumjs/protocol is a shared dependency for schemas and wire types. @atriumjs/worker also exposes the atrium-worker binary for running the Chromium worker from an installed package.
Release checklist: docs/npm-publishing.md.
Step-by-step context: User guide — Session snapshots.
Use a single JSON blob to move state between machines or to seed a new session.
Export (after the viewer has connected at least once so the worker has an active context):
curl -sS -H "Authorization: Bearer <host-token>" \
"https://api.example/atrium/sessions/<id>/session-snapshot" | jq . > snapshot.jsonThe response is { "cookies": [...], "storageState": { "cookies": [...], "origins": [...] } } — the same shape Playwright uses for storageState.
Create a new session from a snapshot (bootstrap runs on the worker before the first WebSocket connects):
curl -sS -X POST -H "Authorization: Bearer <host-token>" -H "Content-Type: application/json" \
"https://api.example/atrium/sessions" \
-d @- <<'JSON' | jq .
{
"storageState": { "cookies": [], "origins": [] },
"initialUrl": "https://example.com/"
}
JSONApply to an already-running session (replaces the browser context; viewer stays connected):
curl -sS -X POST -H "Authorization: Bearer <host-token>" -H "Content-Type: application/json" \
"https://api.example/atrium/sessions/<id>/session-snapshot" \
-d '{"storageState":{"cookies":[],"origins":[]}}'Granular reads remain on GET .../cookies and GET .../storage-state. Worker dry-run accepts bootstrap as a no-op but cannot apply snapshots to a real browser.
Shared ESLint (flat config) and Prettier live at the repository root and apply to every package and example.
pnpm lint # eslint + prettier --check + recursive tsc (typecheck)
pnpm lint:fix # eslint --fix + prettier --write
pnpm format # prettier --write .
pnpm test # vitest (unit + integration, see packages/*/src/*.test.*)Each workspace package also exposes pnpm run typecheck (and lint aliases it) so pnpm -r run typecheck can be run alone if needed.
| Topic | Choice | Why |
|---|---|---|
| API ↔ worker transport | Dial (API opens WebSocket to worker) | Stateless API tier; any node can serve a session after reading workerDialBase from config. |
| Browser automation | Playwright | Context isolation, storage_state, and a supported path to CDP features (for example Page.startScreencast via newCDPSession) without maintaining a bespoke raw CDP client. |
| Frame transport | CDP screencast JPEG over WebSocket | Two-frame pattern: JSON metadata, then binary JPEG (see @atriumjs/protocol). |
| Package | Role |
|---|---|
@atriumjs/demo |
Full-stack demo — server + React + worker; pnpm demo. |
@atriumjs/protocol |
Zod + TypeScript for WebSocket and bootstrap payloads. |
@atriumjs/express |
Express atrium() mount; viewer relay; dials worker with Authorization: Bearer …. |
@atriumjs/worker |
Chromium + Playwright; screencast JPEG; multi-tab context. |
@atriumjs/react |
<RemoteBrowser /> canvas viewer + optional embedded chrome. |
@atriumjs/cli |
atrium doctor CLI. |
For a smaller host without a bundled UI, see examples/express-host/README.md (also linked from the user guide).
| Variable | Typical value |
|---|---|
ATRIUM_WORKER_SECRET |
dev-secret-change-me |
ATRIUM_WORKER_DIAL_BASE |
ws://127.0.0.1:7070 |
| Demo web port | 3333 (PORT) |
| Worker port | 7070 when run alone (ATRIUM_WORKER_PORT); pnpm demo picks a free port if unset and sets ATRIUM_WORKER_DIAL_BASE to match (see packages/demo/README.md). |
The worker uses playwright-extra + puppeteer-extra-plugin-stealth (unless disabled), plus ordinary BrowserContext hints: desktop viewport (default 1366×768), user agent, locale, timezone, color scheme, and Accept-Language. Chromium launches headed by default (not headless), with --window-size=… aligned to that viewport and common anti-automation flags.
On Linux servers or CI without a real display, run the worker under Xvfb (X Virtual Framebuffer), e.g. xvfb-run -a node packages/worker/dist/run.js, or use the Dockerfile below (it installs xvfb and wraps the process with xvfb-run).
| Variable | Purpose |
|---|---|
ATRIUM_STEALTH |
Set to 0 to use stock Playwright (no stealth plugin). |
ATRIUM_VIEWPORT_W / ATRIUM_VIEWPORT_H |
Context + initial window size (integers). |
ATRIUM_USER_AGENT |
Override default Chrome-on-Windows UA string. |
ATRIUM_LOCALE |
BCP-47 locale (default en-US). |
ATRIUM_TIMEZONE |
IANA zone (default America/Los_Angeles). |
ATRIUM_COLOR_SCHEME |
light (default), dark, or no-preference. |
ATRIUM_CHROMIUM_CHANNEL |
Optional Playwright channel: chrome, chrome-beta, chrome-dev, or msedge if installed. |
ATRIUM_WORKER_HEADLESS |
Set to 1 for headless Chromium (no X11). Default is headed (needs a display or Xvfb). |
Worker dry-run (no Chromium):
ATRIUM_WORKER_DRY=1 pnpm --filter @atriumjs/worker startFrom the repository root:
docker build -f docker/worker/Dockerfile -t atrium-worker:local .
docker run --rm -p 7070:7070 \
-e ATRIUM_WORKER_SECRET=replace-me \
atrium-worker:localThe image extends mcr.microsoft.com/playwright so Chromium is available in-container. It installs Xvfb; docker/worker/entrypoint.sh starts Xvfb :99 and sets DISPLAY=:99 before node, giving headed Chromium a virtual X11 display (no physical monitor). Use ATRIUM_WORKER_HEADLESS=1 in the container only if you intentionally want headless mode instead.
- Rotate
ATRIUM_WORKER_SECRETand protect the worker network so only your API can dialws://…/internal/stream/:sessionId. - Viewer tokens are short-lived; treat them like capability URLs.
- Cookies and
storage_statemust stay on host-authenticated HTTP endpoints (/session-snapshot,/cookies,/storage-state); never expose worker shared secrets to browsers.
MIT — see LICENSE.
Issues and PRs are welcome. Read the user guide for how pieces fit together, and docs/remote-browser-design.md before proposing protocol or security changes.