Commit Graph
87 Commits
Author SHA1 Message Date
SGavrl 68f20cf525 Improve render lifecycle, geometry, and PDF parity 2026-08-04 00:32:43 +00:00
SGavrl ede14b7740 Speed up retained rendering and honor PDF backgrounds 2026-08-03 22:08:44 +00:00
SGavrl 222b50e480 Reduce render latency and improve headless input 2026-08-03 21:17:17 +00:00
SGavrl c8a5477296 Improve modern rendering and output parity 2026-08-03 19:46:24 +00:00
SGavrl f428aa37fa Add bounded CDP screencast frames 2026-08-03 16:33:52 +00:00
SGavrl cb6fa31d81 Honor CDP screenshot capture options 2026-08-03 14:35:13 +00:00
SGavrl 10b8492116 Improve modern layout and viewport rendering 2026-07-30 20:24:57 +00:00
SGavrl 3618549842 render: unify responsive geometry and inline gradients 2026-07-26 11:51:44 +00:00
SGavrl 2652ee64a4 render: real-world CSS rendering parity
Colors: oklch()/oklab() and color-mix(); linear-gradient backgrounds.
Grid: named-line placement, repeat() expansion, drop whitespace-only
grid items; upgrade taffy 0.7 -> 0.12 so aspect-ratio images in
auto-height rows no longer collapse to slivers.
CSS: @layer, nesting, @property, :is()/:where(), clamp(), @media not all;
source/track/param/area and svg desc/metadata as display:none.
Images: webp + intrinsic sizing, srcset/picture, browser UA + Accept for
CDN fetches, grey placeholder on failure; width/height HTML attributes
rank below author CSS with aspect-ratio preserved for auto axes.
2026-07-11 19:52:36 +00:00
SGavrl cdd52ade43 render: PNG screenshots via CLI and CDP
End-to-end rasterization behind the render feature (which now enables
obscura-render/paint):

- obscura-render: screenshot_png(dom, viewport) paints and encodes to PNG
  via tiny-skia's encode_png (no extra dep).
- obscura-browser: Page::screenshot renders the live DOM at a viewport.
- CLI: obscura fetch --screenshot out.png writes the settled page to a PNG.
- CDP: Page.captureScreenshot returns the PNG base64-encoded, so
  Puppeteer/Playwright page.screenshot() works. captureSnapshot still
  errors (it is a DOM/layer snapshot, not an image).

Verified: both paths produce correct 1280x720 RGBA PNGs with elements
laid out and colored as the CSS specifies (block stacking, backgrounds,
white page). Default build unchanged; obstacle course 33/33.

Text, borders, and images are not yet painted; the box layout and
background colors are rendered.
2026-07-01 21:34:15 +00:00
lisa0314 84513d5bcc feat(cdp): support DOM.scrollIntoViewIfNeeded 2026-08-03 14:42:03 +08:00
SGavrl 409ac781d2 Isolate CDP browser contexts 2026-07-22 10:45:36 +00:00
sycatle 581942567b fix(cdp): drain live connections before persisting cookies on shutdown
Connection threads are detached and never joined, so `save_cookies()` ran on
the main thread while they were still serving: a connection writing a
Set-Cookie after that point lost it, and the process then exited and killed the
thread mid-flight. Before the per-connection move this could not happen -- the
single processor saved on its own way out, ordered against all connection work
on one LocalSet. Draining on the live-connection counter restores that ordering.

Scope, honestly: this bounds the loss window rather than closing it. The drain
does not finish early today because a connection's frame reader is parked in a
socket read and never observes `notify_waiters`, so a client that holds a
connection open costs the full 3s deadline and then gets the warning. Making
readers wake on shutdown is a separate change and not #430's business.

Measured: 2ms to shut down with no connections (unchanged), 3005ms with one
held open, against the 10s `docker stop` allows before SIGKILL. Cookies are
persisted in both cases.

Refs #430, #333.
2026-07-20 18:46:34 +02:00
sycatle 3015f7d8ad feat(cdp): bound live CDP connections with --max-connections
Each connection runs on its own OS thread with its own V8 isolates, so
connection count is what drives the server's thread footprint.
MAX_PENDING_WS_HANDOFFS bounds only the handoff queue: once a stream is handed
off, nothing limited how many could be live at once, and a client could grow
the server without limit.

Reserve a slot in the accept loop and release it from a guard that runs when
the connection thread unwinds, on every path including panics and the early
returns before the runtime is built -- a plain decrement at the end of the
closure would leak slots until the cap wedged the server shut. Connections
past the limit get an explicit 503 with X-Obscura-Reason: max-connections
rather than a bare reset, so a client can tell a refusal from a crash. The
refusal happens in the WS dispatch loop, so the HTTP control plane (#62) stays
reachable at the cap.

Default 128, matching the handoff bound and well above any real client fan-out
(Playwright/Puppeteer use one connection per browser). At the cap, with arenas
capped by the previous commit: 146 threads, 33.2 GiB of reserved address space
and 51 MiB resident -- nearly all of that reservation being V8's process-wide
sandbox, which is present at zero connections.

Threads are the honest cost here, not memory: at 100 connections each running
JS this server reserves 83.5 GiB against `main`'s 83.6 GiB, because `main`
pays the same V8 sandbox as soon as it runs any JS. What it does not pay is
118 threads against 17.

The test covers all three properties, and specifically that a closed
connection frees its slot: a leak there would only surface as a server that
silently stops accepting after N lifetime connections.

Refs #430.
2026-07-20 17:31:01 +02:00
sycatle dc79fde1a0 fix(cdp): cap glibc malloc arenas so memory does not track peak concurrency
glibc hands each new thread its own 64 MiB arena (up to 8x cores). With one
thread per connection that arena is the dominant per-connection memory term,
and freed chunks stay reserved in it rather than returning to the OS: resident
memory tracks the *peak* number of concurrent connections and never comes back
down, which reads as a leak although nothing is leaked and no thread is stuck.

Measured with reliability/conn-scale.py in obscura-benchmark. 100 connections
each running JS reserve 90.0 GiB of address space uncapped versus 83.5 GiB
capped; per idle connection, 65 MiB of reserved address space drops to 2.0 MiB.
`main` at that same 100-connection JS workload reserves 83.6 GiB, so with the
cap this server is level with it -- most of the total is V8's process-wide
sandbox, which `main` pays too the moment it runs any JS.

The resident-set effect is the one that bites in production. Measured in the
container image against Google Maps, four concurrent connections per round:
350 / 619 / 826 MiB over three rounds uncapped and still climbing linearly,
versus 166 / 235 / 269 MiB capped, on a decelerating curve.

Two arenas cost no measurable throughput here -- 8 concurrent connections x 12
navigations, 1.53s uncapped against 1.50s capped over three runs. V8 allocates
the JS heap through its own allocator and the Rust side is dominated by network
I/O rather than malloc traffic. Applied only from `serve`, which owns the
process, and skipped when the caller has already set MALLOC_ARENA_MAX.

Refs #430.
2026-07-20 17:30:34 +02:00
sycatle 1f5bffc4de refactor: drop dead code and refresh comments after the per-connection move
- Delete the now-unused CdpContext::new_full (its only caller switched to
  new_with_shared_context); have _new_inner build the shared BrowserContext and
  delegate, so the struct's field list lives in one place.
- Extract drive_module_eval, shared verbatim by load_module and
  load_inline_module.
- Update comments that still described the deleted process-wide V8 lock and the
  single-LocalSet model to the per-connection reality, drop the dangling
  reference to the removed obscura-js/src/v8_lock.rs, and soften the
  ISOLATE_CREATE_LOCK note now that the main-thread warmup is the real guard
  against the InitializeBuiltinJSDispatchTable segfault.

Refs #430.
2026-07-20 16:37:51 +02:00
sycatle 9065f383de perf(cdp): scope the V8 lock per connection to run connections in parallel
With every connection on its own thread (previous commit), the process-wide V8
lock served no safety purpose across connections yet forced all of them to
serialize on one mutex, so throughput did not scale with concurrency. Move the
lock into CdpContext so each connection has its own: a connection's nav task and
command dispatch still coordinate on one page's isolate at a time on its thread,
but different connections no longer block each other. Delete the now-unused
process-wide obscura_js::v8_lock.

Measured on a local CPU-bound fixture, page throughput stays flat (~75/s from
concurrency 1 to 8) with the global lock and scales to about 2.5x at concurrency
8 with the per-connection lock, with no crash at any concurrency. Obstacle
course 33/33, obscura-cdp 76/76.

Refs #430.
2026-07-20 16:37:50 +02:00
sycatle 76fc3b9cae fix(cdp): run each connection on its own thread to confine V8 isolates
The CDP server ran every connection's pages on one LocalSet thread, so two
pages' V8 isolates shared an OS thread. Under concurrent navigation a cancelled
event-loop future could leave one isolate entered while another page's
execute_script ran, tripping V8's heap->isolate() == Isolate::TryGetCurrent()
check and aborting the process (#430). No single-thread serialization fixes
this reliably: the cancellation-leak pattern is pervasive and partly inside
deno_core.

Give each WebSocket connection its own OS thread with its own current_thread
runtime, LocalSet, and CdpContext, so every page's isolate is confined to one
thread. V8's invariant is per-thread, so two connections' isolates can never
collide. This is the "Option 2" fix named in obscura-js/src/v8_lock.rs, and it
also lets connections run in parallel instead of serializing on one thread.

- CdpContext::new_with_shared_context reuses one Arc<BrowserContext> across all
  connections, so they share one cookie jar and one HTTP client.
- A single signal watcher flips the accept flag and wakes every connection's
  processor via notify_waiters(); the accept side persists cookies once on
  shutdown.
- Serialize isolate construction with a process-wide lock: the leaptiering
  JSDispatchTable setup is not safe to run from several threads at once.
- Warm up V8 once on the main thread before any connection thread creates an
  isolate; creating the first isolate off the main thread segfaults in
  InitializeBuiltinJSDispatchTable.

obstacle course 33/33, obscura-cdp 75/75 including the concurrent-navigation and
control-plane tests. Concurrency 2 and 4 against heavy pages no longer abort.

Fixes #430.
2026-07-20 16:37:50 +02:00
Andrey Mnatsakanov 57949a0efc fix(cdp): backslash-escape key/code in Input.dispatchKeyEvent (#433) (#437)
The keyDown/keyUp paths interpolated `key`/`code` into the generated
KeyboardEvent snippet with single-quote escaping only. Chrome sends
`key: "\"` (U+005C) for the backslash key, which became `key:'\'` — the
backslash escaped the closing quote, ran the string literal on, and made
the whole page.evaluate a syntax error, so the keydown was silently
dropped. Any key/code carrying a backslash broke dispatch.

Escape backslash before single-quote (backslash first, so the quote-escape
backslashes are not themselves doubled), matching the sibling text path in
the same function. Adds a regression test asserting the backslash key
arrives at a keydown listener alongside an ordinary key.
2026-07-19 19:11:17 +00:00
SGavrl d0b6fc4ffd fix(cdp): emit Network events for script-initiated requests (#415)
Requests made by page JS (fetch/XHR/dynamic script and stylesheet loads)
run in the V8 op layer and never produced a NetworkEvent, so CDP emitted
Network.requestWillBeSent / responseReceived only for the static
navigation subresources. Puppeteer/Playwright page.on('request') and
on('response') therefore missed every XHR/JSON response, which breaks
response capture (and is the root cause of the Aviasales half of #394).

op_fetch_url now records a JsNetworkEvent keyed by the same fetch-{N} id
under which it already stores the response body. The Page drains these
into its network_events before the CDP layer emits, so they flow through
emit_navigation_events as requestWillBeSent / responseReceived /
loadingFinished alongside the document subresources, and
Network.getResponseBody resolves for them via the shared id. Applied to
both navigation paths (server process_navigation and in-process
do_navigate). The queue is capped at 4096 events per page.

Closes #406.
2026-07-14 13:23:55 +00:00
SGavrl 358100aa63 fix(cdp): prune valid_context_ids on executionContextsCleared (#413)
Runtime.executionContextsCleared on navigation told clients every prior
context was gone, but valid_context_ids was insert-only, so a
Runtime.callFunctionOn targeting a pre-navigation context id still ran
instead of being rejected with Chrome's 'Cannot find context with
specified id'. On a long-lived CDP connection the set also grew by one
id per navigation and was never pruned.

Clear valid_context_ids when executionContextsCleared is emitted, then
re-register the default world (id 2) and the isolated worlds as their
executionContextCreated events are emitted. Closes #407.
2026-07-14 12:23:29 +00:00
Andrey MnatsakanovandSGavrl 5ada3b404b fix(net): default cookie Path to the request directory (RFC 6265 5.1.4) (#403)
* fix(net): default cookie Path to the request directory (RFC 6265 5.1.4)

When a Set-Cookie carried no Path attribute, the jar stored the full request
path as the cookie's path. RFC 6265 5.1.4 default-path is the request URI's
directory — the path up to but not including the right-most '/'. Storing the
full path scoped a session cookie to the exact URL that set it: a cookie set on
GET /app/login was stored with path=/app/login and then, because path-match
(5.1.4, fixed earlier) requires a '/' boundary, was not sent to /app/dashboard.
The user appeared logged out on the very next navigation.

Add default_cookie_path() implementing the RFC algorithm and use it as the
initial path in both set_cookie (HTTP) and set_cookie_from_js (document.cookie).
An explicit Path attribute still overrides it. Tests cover the algorithm and the
end-to-end 'cookie set on /app/login is sent to /app/dashboard' path for both
the HTTP and JS entry points; both failed before this change.

* fix(cdp): apply RFC 6265 default cookie Path to CDP-injected cookies

Network.setCookie/setCookies (the Puppeteer page.setCookie and
Playwright addCookies surface) derived the cookie Path from the full
request URL when the caller omitted path, the same bug the parent
commit fixed for Set-Cookie responses. Reuse default_cookie_path (now
pub) so a cookie set under /v1/things scopes to /v1 and is resent on
sibling paths. Fixes the CDP half of #401.

Correct the test that locked in the old full-path behavior and add a
case confirming an explicit Path still wins.

---------

Co-authored-by: SGavrl <[email protected]>
2026-07-14 12:13:49 +00:00
sickoandSGavrl 7e2cabf411 fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit() (#395)
* fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit()

Per the HTML standard, the submit() method submits without firing a submit
event and cannot be canceled by a page submit listener; only requestSubmit()
and user-initiated submits fire the cancelable event. bootstrap.js dispatched a
cancelable submit event from submit() and aborted navigation when canceled,
which breaks the common pattern where a submit listener preventDefault()s the
native submit and a callback later calls form.submit() to actually send it
(e.g. an invisible-reCAPTCHA data-callback): the listener also canceled the
programmatic submit, so no navigation was ever issued.

- submit(): navigate unconditionally, no submit event
- requestSubmit(): fire the cancelable submit event, navigate only if not canceled
- submit-button clicks route through requestSubmit() (a real click fires the event)

Adds a regression test; full obscura-cdp suite (56 tests) passes.

* fix(cdp): route synthetic submit-button clicks and Enter through requestSubmit

A CDP Input.dispatchMouseEvent on a submit button and an Enter key in a
form field are user-initiated submits, so per the HTML spec they must
fire the cancelable submit event. input.rs called form.submit() (the
method) directly, bypassing any preventDefault() submit listener, which
mirrors the bootstrap.js bug fixed in the parent commit. Use
requestSubmit() with a submit() fallback so the listener can veto.

---------

Co-authored-by: SGavrl <[email protected]>
2026-07-13 23:21:15 +00:00
Andrey Mnatsakanov c915a11f59 fix(cdp): truncate client-controlled log previews on char boundaries (#397)
server.rs logged previews of attacker-controlled strings with raw byte
slicing: `&text[..text.len().min(200)]` for a malformed CDP frame (inside a
`warn!`, which is active at the worker's default log level) and the same for
an intercepted event JSON. `&s[..n]` panics when byte `n` falls inside a
multi-byte UTF-8 character, so a single WebSocket frame that is invalid JSON,
>=201 bytes, with a multi-byte char straddling byte 200 panics the CDP
processor task — an unauthenticated one-frame crash. page.rs had the same
pattern (behind --verbose debug logging) for evaluated JS expressions.

Add truncate_on_char_boundary (str::floor_char_boundary is still unstable) and
route all four sites through it. Regression test constructs the exact 'a'*199 +
'€' input, asserts the old raw slice panics (via catch_unwind) and the helper
truncates on the boundary instead.
2026-07-13 22:45:57 +00:00
Andrey Mnatsakanov d8e9fc2196 fix(cdp): bound DOM.getDocument depth so deep DOMs cannot crash the worker (#396)
DOM.getDocument{depth:-1} (the standard full-tree call from Puppeteer/
Playwright/browser-use) arrived as u32::MAX and drove serialize_node to
recurse once per DOM nesting level. A page can nest nodes tens of thousands
deep (trivially scriptable; html5ever caps no generic nesting), so the
recursion overflowed the stack — a SIGABRT that catch_unwind cannot recover,
crashing the whole worker (AGENTS.md: "one page must never crash a worker").

Deriving the builder alone is not enough: the response is inherently nested
JSON, and serde_json's own serialization and the Value's Drop recurse over
that nesting too, overflowing the ~2 MiB tokio worker stack. So this both
rewrites serialize_node as an explicit heap worklist and clamps the effective
depth to MAX_SERIALIZE_DEPTH (256), mirroring DOMSnapshot's MAX_NODES guard.
Real DOMs are far shallower; clients wanting a deeper subtree re-request it
with DOM.requestChildNodes / describeNode.

Adds a regression test that builds a 50k-deep tree and serializes with
depth:-1; it SIGABRTs before this change and passes after (run on nextest's
2 MiB test thread, mirroring production).
2026-07-13 22:42:30 +00:00
Flo 82d20bcb7c fix(cdp): allow large WebSocket response frames (<64Mb) (#405) 2026-07-13 22:41:20 +00:00
Andrey Mnatsakanov 50e66320b0 fix(cdp): bound IO stream buffers so downloads cannot leak (#387)
* fix(js): fire trusted input/change from setFileInputFiles

DOM.setFileInputFiles dispatched plain input/change events, so
event.isTrusted was false. Upload flows and anti-bot code that gate their
change handler on isTrusted ignored them, which silently broke the exact
case the feature targets. The Input domain already marks its synthesized
clicks/keys trusted via __obscura_markTrusted; do the same here.

Fixes #384.

* fix(cdp): bound IO stream buffers so downloads cannot leak

Fetch.takeResponseBodyAsStream moved a response body out of the page's
LRU-bounded cache into CdpContext.io_streams, a server-lifetime map freed only
by an explicit IO.close. A client that opens a stream and disconnects, errors
mid-download, or never closes it leaked the full buffer forever; no teardown
path (WS close, Browser.close, Target.closeTarget) touched io_streams. That
reintroduced the unbounded memory growth streaming was meant to avoid (#360).

Replace the raw map + counter with an IoStreamStore that caps the number of
open streams and their total bytes (OBSCURA_IO_STREAM_MAX_ENTRIES / _BYTES),
evicting the oldest first and always keeping the just-opened stream. Reading an
evicted handle fails cleanly, which is the right trade against an OOM. Read
chunking is unchanged.

Fixes #386.
2026-07-11 16:42:57 +02:00
SGavrl 1ca6047351 feat(cdp): stream large response bodies via takeResponseBodyAsStream + IO (#381)
Network.getResponseBody returns the whole body in one base64 blob and the
cache drops anything over 2 MiB, so large downloads (images, PDFs,
archives) either failed or forced the long-running server to hold the
full body in memory (issue #360).

Add Fetch.takeResponseBodyAsStream + IO.read + IO.close. The body is moved
out of the page cache into a stream handle and delivered in bounded chunks
via IO.read, then freed on IO.close, so it is held once and released after
the download. Binary bodies are byte-exact (stored base64). To stream a
large body, raise OBSCURA_NETWORK_BODY_BUFFER_BYTES so it is cached.

This is entirely opt-in: nothing runs unless a client calls the new
methods, so the default fetch/render/scrape path is unchanged (verified:
obstacle course 33/33, obscura-vs-Chrome head-to-head within noise of the
pre-change baseline).

Fixes #360.
2026-07-10 20:38:28 +00:00
Andrey Mnatsakanov 68234970b1 fix(cdp): apply Fetch.continueRequest overrides (#373)
handle_fetch_resolution built InterceptResolution::Continue with url, method,
headers, and body hardcoded to None, so Fetch.continueRequest silently sent the
request unmodified. Playwright route.continue({ headers, url, postData }) and
Puppeteer request.continue reported success while their overrides were dropped.

op_fetch_url already applies all four (and re-validates a rewritten URL through
the SSRF gate); only the CDP layer was discarding them. Read url, method,
headers, and postData from the continueRequest params and pass them through.

Fixes #365.
2026-07-10 20:35:36 +00:00
Andrey Mnatsakanov daa0d62fbd fix(cdp): route navigation on exact method, not substring (#372)
The cdp_processor decided whether to take the spawn-and-defer navigation path
with `text.contains("Page.navigate")`, which also matches
`Page.navigateToHistoryEntry`. goBack / goForward (both Puppeteer and
Playwright drive Page.navigateToHistoryEntry) were misrouted into the raw
navigate path, which reads an absent `url` param and fails with
"Invalid URL: relative URL without a base", so history navigation never
reached its real handler. Any frame merely embedding the literal
"Page.navigate" (e.g. a Runtime.evaluate expression) was misrouted the same
way.

Match on the parsed CDP method instead, so only an exact Page.navigate takes
the navigation path.

Fixes #363.
2026-07-10 20:23:53 +00:00
SGavrl 6788996b0b feat(cdp): implement DOM.setFileInputFiles for file uploads (#369)
Puppeteer's ElementHandle.uploadFile and Playwright's setInputFiles drive
an <input type=file> through CDP DOM.setFileInputFiles, which obscura
rejected as an unknown method, so any upload flow failed (issue #359).

Implement it: resolve the node, read each local file, and hand the bytes
(base64) to the JS layer, which builds real File objects (backed by the
bytes, so page code can read them via FileReader or upload them via
fetch/FormData) and fires input+change like a genuine selection. The file
input exposes a FileList via .files, and .value reads C:\fakepath\<name>
to match Chrome.

Fixes #359.
2026-07-09 10:51:18 +02:00
SGavrl 08c1f0d721 fix(cdp): fire onChange for React/Vue controlled inputs (#353)
Typing over CDP into a React controlled input never fired onChange, so
the value never reached component state (issue #324). Two root causes,
both needed:

1. Value tracker. React/Preact/Vue install a value tracker by redefining
   `value` on the element instance to record the last value they wrote.
   obscura's CDP typing assigned `el.value = ...` directly, which runs
   that wrapper and keeps the tracker in sync, so React saw no change on
   the input event. Synthetic value/backspace writes now go through a new
   __obscura_setFieldValue helper that writes via the element's prototype
   setter, bypassing the tracker. The tracker stays stale, so the input
   event reads as a real edit.

2. Event handler feature detection. React decides whether to use the
   modern `input` event via `("oninput" in document)`. obscura exposed the
   GlobalEventHandlers on* attributes only on window, so that check failed
   and React fell back to a legacy change-detection path that ignores
   input events. The on* attributes are now also present on Document and
   Element, matching the HTML spec (GlobalEventHandlers is mixed into
   Window, Document, and HTMLElement).

Verified end to end over CDP against the real React 18 UMD build: typing
into controlled inputs updates state, the component re-renders, and a
submit handler reads the captured values.

Closes #324.
2026-07-03 14:10:03 +02:00
SGavrl f72234d99e fix: harden comment serialization and DOMSnapshot recursion depth
fix: harden comment serialization and DOMSnapshot recursion depth
2026-07-01 16:02:55 +00:00
SGavrl e7300c5df7 fix(cdp): make DOMSnapshot walk iterative to bound recursion depth
The DOMSnapshot walk recursed one frame per nesting level. The MAX_NODES
cap bounds the node count but not the depth, so a deeply nested linear
chain (script can build thousands of nested elements) could overflow the
stack before the count guard triggered. Rewrite it with an explicit stack
so depth lives on the heap. Pre-order traversal and the MAX_NODES cap are
preserved.

Fixes #341.
2026-07-01 15:58:49 +00:00
SGavrl 7440b534f3 fix(cdp): stub setDownloadBehavior, detachFromTarget, activateTarget
These Chrome CDP methods logged "Unknown method" warnings. obscura runs
one page per session and does not download to disk, so there is nothing
to do, but clients that call them should get a success ack instead of a
warning. Added as no-op stubs.

Refs #340.
2026-07-01 06:55:37 +00:00
SGavrl 630f9894f6 fix(cdp): expose main-resource response body and real mime on navigation
Navigating straight to an image, PDF, or other resource could not read the
main response body over CDP, and Page.frameNavigated always reported
text/html.

Two problems: the main resource's body was stored under its internal
request id, but the client sees it as the navigation loaderId (Chrome's
requestId === loaderId convention), so Network.getResponseBody(loaderId)
missed. And binary bodies were stored as UTF-8-lossy text, corrupting the
bytes. Alias the stored body to the loaderId, store non-text-like content
types (images, PDF, octet-stream) base64, and report the response's real
Content-Type in frameNavigated. HTML pages are unchanged.

Fixes #340.
2026-07-01 06:55:37 +00:00
SGavrl b63125faed cdp: flush cookies on SIGTERM in serve (#333)
serve --storage-dir saved cookies only on Ctrl-C (SIGINT). docker stop
and kill send SIGTERM, which was not caught, so the save_cookies() call
at shutdown never ran and cookies were lost on restart. Watch SIGTERM as
well as Ctrl-C on Unix.
2026-06-29 10:48:20 +00:00
Gabriel 0913be86dc cdp: make Backspace and typing honor text selection (#316) (#321)
Backspace always trimmed the last character and typing always appended,
ignoring any selection. So "select-all, then Backspace to clear" left
all but one character, and typing over a selection accumulated text
instead of replacing it.

Backspace now deletes the selected range when one is set, and typed
text replaces it, matching the browser. selectionStart is null during
ordinary typing, so the previous append/trim behavior is preserved when
no selection is tracked.

Pairs with the triple-click select-all in #320 to make the common
"triple-click, Backspace, retype" clear pattern work end to end. Also
fixes the same pattern when a page sets the selection via select() or
setSelectionRange().
2026-06-23 13:28:35 +02:00
zhangyang-crazy-one 5d01b5eb6a fix(cdp): implement triple-click select-all for text inputs (#316 follow-up) (#320)
Passing detail:clickCount to MouseEvent is necessary but not sufficient
for triple-click to select all text. The select-all-on-triple-click is a
browser native behavior that synthetic MouseEvent dispatch doesn't trigger.

Add manual selection after click dispatch: when clickCount >= 3 and the
target is an INPUT or TEXTAREA, set selectionStart=0 and
selectionEnd=value.length.
2026-06-23 13:26:18 +02:00
RohitMadhu 3bf0da2fae Add Network.setBlockedURLs support (#315)
Added Network.setBlockedURLs dispatch handling.
Stores blocked URL patterns on each page.
Applies blocking to page subresources and JS fetch / XHR requests.
Supports wildcard CDP-style patterns like *://*.gstatic.com/*.woff2.
2026-06-22 11:02:49 +00:00
zhangyang-crazy-one cd0fbc02c2 fix(cdp): pass clickCount to MouseEvent.detail + fix char handler event loop (#316) (#317)
Bug 1: clickCount parameter was read with underscore prefix and silently
discarded. MouseEvent constructor never received the detail field, so
triple-click (clickCount:3) was always treated as a single click by the
DOM — inputs never selected their full text.

Fix: rename _click_count to click_count, pass it as detail in both
mousedown and click MouseEvent constructors.

Bug 2: page.type() intermittently dropped the last character because
the char handler's quote-escaping order was wrong (single-quote before
backslash), and the V8 event loop wasnt pumped after dispatching the
input event.

Fix: fix escaping order (backslash first, then single-quote), and add
page.settle(50) after the char handler's evaluate to pump the event
loop.
2026-06-22 11:01:40 +00:00
Gabriel d526a4da7d js: make event.isTrusted false for page-constructed events
isTrusted was hardcoded to true for every Event, so new Event('x').isTrusted returned true. Real browsers return false for script-constructed events and true only for user-agent dispatched ones, so this was a trivial bot-detection tell (Closes #303).

Track trusted events in a closure-private WeakSet that page JS cannot read or forge; default isTrusted to false. The CDP input pipeline marks its synthesized mouse/key events trusted via a non-enumerable __obscura_markTrusted helper, so automation clicks stay trusted like real Chrome over CDP while page-built events report false.
2026-06-17 15:15:25 +00:00
SGavrl 6264e6bfad js: make event.isTrusted false for page-constructed events
isTrusted was hardcoded to true for every Event, so new Event('x').isTrusted
returned true. Real browsers return false for script-constructed events and
true only for user-agent dispatched ones, so this was a trivial bot-detection
tell (issue #303).

Track trusted events in a closure-private WeakSet that page JS cannot read or
forge; default isTrusted to false. The CDP input pipeline marks its synthesized
mouse/key events trusted via a non-enumerable __obscura_markTrusted helper, so
automation clicks stay trusted like real Chrome over CDP while page-built
events report false.
2026-06-17 15:12:04 +00:00
Rohit Madhu 570ca1544c add Network.getResponseBody support 2026-06-13 20:26:26 -04:00
SGavrl 4a00a6ac2c Implement DOM.focus for keyboard input (#283)
CDP clients (browser-use and similar) focus an input via DOM.focus before
typing; Input.dispatchKeyEvent then targets document.activeElement. Without a
DOM.focus handler the focus call errored, keystrokes landed on nothing, and the
field stayed empty. Resolve the node and call its JS focus(), which sets
document.activeElement. Completes the type path for DOM-agent frameworks.
2026-06-09 21:21:51 +00:00
SGavrl f4cbb78f0b Add Target.targetInfoChanged and DOMSnapshot.captureSnapshot for DOM agents (#282)
CDP DOM-agent frameworks (browser-use and similar) could connect but saw a
blank page: navigation never refreshed their cached target url/title, and their
DOM build aborted with "Unknown domain: DOMSnapshot".

- Emit Target.targetInfoChanged after navigation (both CDP Page.navigate and
  JS-initiated) carrying the post-nav url/title. Strict clients refresh a target
  only on this event; it also fixes Puppeteer/Playwright page.url() tracking.
- Implement DOMSnapshot.captureSnapshot, built from the DOM with synthetic
  geometry (no paint engine) and plausible computed styles, keyed by the same
  backendNodeId as DOM.getDocument. Enough for tag/ARIA/accessibility element
  detection; it runs only when a client requests it, so the fetch path is
  unaffected. Coordinate clicking still requires a real layout engine.
2026-06-09 21:02:04 +00:00
SGavrl 1a0644f7bc feat(cdp): bound every CDP command with a shared V8 watchdog (#250)
A hung page (a runaway Runtime.evaluate, a synchronous DOM op) holds the
process-wide V8 lock and wedges every other session forever. The one-shot CLI has
a process-level hard deadline for this; the long-running server cannot force-exit,
so terminate just the offending isolate instead.

dispatch() arms a watchdog on the session page's isolate after taking the V8 lock
and disarms it after the handler; on overrun it terminates the isolate and clears
the termination flag so the session keeps working. OBSCURA_CDP_COMMAND_TIMEOUT_MS
tunes the bound (default 60s, 0 disables); navigation already self-bounds well
under it.

The watchdog is a single long-lived shared thread (cdp_watchdog), not a thread
per command: the V8 lock serializes commands so at most one is ever armed, and
arm/disarm are a mutex plus notify with no measurable per-command cost. Also
fixes a lost-wakeup in the per-call watchdog (spawn_watchdog) where disarming a
fast call before the watchdog thread started blocked for the full budget.

Verified: a hung Runtime.evaluate is terminated at the budget and the session
recovers; CDP integration 37/37, obstacle course 33/33; per-command latency
unchanged (watchdog on equals off within noise).
2026-06-05 21:28:21 +00:00
2e731a2ab9 feat(api): add obscura-api crate with Browser + Page + Element + CookieStore (#191)
* feat(api): add obscura-api crate with Browser + Page API

Adds a Rust library crate exposing Obscura as a programmable browser:
- Browser: build with config (proxy, stealth, UA, storage_dir)
- Page: goto, url, evaluate, content
- BrowserBuilder with fluent API
- examples/basic.rs

Part of #169

* feat(api): add Element API + CookieStore + wait_for_selector

- Element: text(), attribute(), click() via JS evaluation
- CookieStore: set/get_all/get_for_url/save_to_file/load_from_file
- Page::wait_for_selector with 100ms polling
- Page::query_selector for single element lookup
- Updated examples/basic.rs with full interaction demo

Part of #169

* feat(api): add Browser::cookies() method

* refactor(api): move to obscura crate with feature flag

- Renamed obscura-api → obscura (per maintainer feedback #169)
- API enabled by default via 'api' feature flag
- Usage: cargo add obscura (default=api) or cargo add obscura --features api
- Removed separate obscura-api crate

* chore: exclude docker workflow from fork

* fix(api): make the obscura crate build, run, and document install correctly

- examples/basic.rs and the lib.rs doctest imported `obscura_api`, but the crate
  is named `obscura`, so both failed to compile. Use the real crate name.
- wait_for_selector / query_selector read the node id with `Value::as_u64`, but
  `evaluate` returns JS numbers as f64, so it was always None and every
  wait_for_selector timed out. Accept an integer or a non-negative finite float.
- restore .github/workflows/docker.yml, which the PR removed (a fork-specific
  change that should not land upstream).
- add crates/obscura/README.md with honest install instructions: the crate is not
  on crates.io, so depend on it via git (and it builds the embedded V8 from
  source). The `cargo add obscura` form does not work yet.

Verified: workspace + example build, the lib doctest passes, the example runs end
to end (goto, wait_for_selector, text, attribute), and the integration suite is
28/28 with no regressions.

---------

Co-authored-by: zhangyang-crazy-one <[email protected]>
Co-authored-by: SGavrl <[email protected]>
2026-06-04 18:20:51 +00:00
Sander Spilleman 9ae36764da feat(stealth): many stealth improvements 2026-06-03 08:21:14 +00:00
SGavrl 1b9be08767 fix(cdp): exit on Ctrl-C while serving (#225) (#226)
The LocalSet accept loop blocked on ws_rx.recv() forever because the
detached accept thread kept a ws_tx clone alive, so the channel never
closed and start_with_full_serve_options never returned. cdp_processor
caught Ctrl-C and saved cookies, but that only ended its own task.

Add a shared shutdown flag and Notify. On Ctrl-C cdp_processor sets the
flag and notifies; the accept loop selects on the notify and breaks, so
run_until returns and the process exits. The accept thread checks the
flag between connections so it stops and drops ws_tx on the slow path.
Closes #225
2026-06-02 20:21:08 +00:00