Architecture overview
Obscura is a workspace of nine crates.
obscura-cli CLI entry point. fetch, serve, scrape, mcp.
obscura-cdp Chrome DevTools Protocol server. WebSocket, dispatch, domain handlers.
obscura-browser Page type, navigation, lifecycle events.
obscura-js V8 runtime via deno_core. bootstrap.js + Rust ops.
obscura-dom DOM tree implementation.
obscura-net HTTP client, stealth client, cookie jar, robots cache, tracker blocklist.
obscura-mcp Model Context Protocol server.
obscura-render CSS cascade, retained layout, text shaping, and CPU paint.
obscura Embeddable Rust library API (Browser, Page, Element, CookieStore).Request flow
A Page.navigate from a CDP client:
CDP client (Puppeteer)
│ WebSocket frame
▼
obscura-cdp/server.rs accept, route by sessionId
│
▼
obscura-cdp/dispatch.rs method router, acquires v8_lock
│
▼
obscura-cdp/domains/page.rs Page.navigate handler
│
▼
obscura-browser/page.rs navigate_with_wait
│
├──► obscura-net/client.rs HTTP fetch
│
├──► obscura-dom/tree.rs parse HTML into the tree
│
└──► obscura-js/runtime.rs run inline scripts
│
└──► bootstrap.js + ops.rs DOM bindingsThe dispatcher emits CDP events (Network.requestWillBeSent, Page.frameNavigated, Page.lifecycleEvent) back to the client through the same WebSocket.
Rendering flow
obscura-render consumes the shared DOM and computed style state. Taffy provides the flex/grid foundation; Obscura adds browser formatting behavior, text shaping, intrinsic replaced-element sizing, retained geometry, scrolling, and CPU-backed paint. obscura-js exposes renderer-owned geometry to DOM APIs, obscura-browser prepares resources and owns capture, and obscura-cdp maps screenshots, screencast frames, and raster PDF output onto CDP.
Layout is retained between captures and invalidated by relevant DOM, style, viewport, scroll, animation, font, and resource changes. The same geometry therefore drives browser APIs and paint instead of maintaining separate measurement and screenshot models.
Single V8 isolate
All pages in a process share one V8 isolate. The isolate is single-threaded by design.
obscura_js::v8_lock::global() is a tokio::sync::Mutex that serializes V8 work. A handler that wants to run JS must acquire the lock first:
The dispatcher routes long-running operations (navigation, eval) through process_with_interception in server.rs, which spawns the work onto the tokio LocalSet and releases the dispatcher to keep handling other CDP messages.
This is why Target.createTarget from many concurrent clients works: each newPage returns immediately while the actual navigation runs in a spawned task.
Robustness
One page cannot hang or crash the process. obscura-js/runtime.rs provides a V8 termination watchdog (arm_watchdog, run_event_loop_bounded) that terminates the isolate from a separate thread when synchronous work overruns a budget, because tokio::time::timeout cannot preempt synchronous V8. It bounds the post-load settle, the navigation event-loop pumps, and --eval. The complete script phase is bounded by OBSCURA_SCRIPT_DEADLINE_MS; enhancement modules have a shorter per-module graph-loading/evaluation budget controlled by OBSCURA_MODULE_BUDGET_MS, while modules mounting an empty SPA shell receive the full script deadline. obscura-js/cdp_watchdog.rs is a single shared watchdog the dispatcher arms around every CDP command, so a runaway page cannot hold the V8 lock and wedge other sessions (tunable via OBSCURA_CDP_COMMAND_TIMEOUT_MS). op_dom is wrapped in catch_unwind so a DOM-op panic degrades to a null result instead of aborting the process through V8's FFI frame, and obscura-dom/tree.rs rejects cyclic reparenting that would make tree walks loop forever. Scripted fetch()/XHR and module network requests are timeout-bounded (OBSCURA_FETCH_TIMEOUT_MS), and the one-shot fetch CLI has a process-level hard deadline as a final backstop.
JS bridge
obscura-js/js/bootstrap.js provides the browser globals: document, window, navigator, location, observers, fetch, indexedDB, etc.
obscura-js/src/ops.rs registers Rust ops that the bootstrap calls into:
Adding a Web API usually means:
JS shim in
bootstrap.jsthat exposes the API surface.Rust op in
ops.rsthat performs the side effect (DOM mutation, fetch, crypto).Register the op in
build_extension().
Worked example: Adding a CDP method or Web API.
Classic Web Workers
The JavaScript shim executes each classic Worker source once and retains its message handlers and lexical state. Bare onmessage assignments target the worker scope, and messages posted before the source loads are queued until initialization finishes. Terminating a worker discards pending messages.
Workers remain emulated within the page runtime, not separate V8 isolates or OS threads. This is not a complete WorkerGlobalScope implementation.
CDP session model
Each CDP client connection gets attached to one or more targets. Session IDs are "{targetId}-session". The dispatcher routes by sessionId in the incoming frame to the right Page.
Targets are created by Target.createTarget. Closing the WebSocket detaches all sessions but leaves the pages running.
Lifecycle
Lifecycle events are emitted by obscura-browser/lifecycle.rs as the page transitions:
waitUntil on Page.navigate blocks until the requested level is reached. The Puppeteer / Playwright goto resolves on the matching Page.lifecycleEvent client-side.
Storage
--storage-dir persists cookies (cookies.json) and localStorage (localStorage/<origin>.json). Reads on process start, writes on every navigation and on graceful shutdown.
Stealth
--stealth swaps the default reqwest client for obscura-net/wreq_client.rs, which presents a real browser's TLS ClientHello, ALPN, and cipher order (a consistent Chrome fingerprint, not a randomized one) so the TLS layer matches the User-Agent and JS surfaces. It also applies the bundled tracker blocklist before any request leaves the process. Scripted fetch()/XHR go through the same stealth client, so subresource requests carry the same fingerprint as the navigation. --stealth is a global CLI flag that applies to fetch, serve, scrape, and mcp.
Workspace conventions
One crate per layer. Cross-crate calls go through the layer above, not sideways.
All async is
tokiowith aLocalSetbecause V8 is!Send.All DOM ops go through
op_domto keep the JS/Rust boundary narrow.
Last updated
Was this helpful?

