diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..8af59dd
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[env]
+RUST_TEST_THREADS = "1"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8812940
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,73 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ name: obscura-x86_64-linux
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ name: obscura-aarch64-linux
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ name: obscura-x86_64-macos
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ name: obscura-aarch64-macos
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ name: obscura-x86_64-windows
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install cross-compilation tools
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+
+ - name: Build
+ run: cargo build --release --target ${{ matrix.target }}
+
+ - name: Build stealth
+ run: cargo build --release --target ${{ matrix.target }} --features stealth
+ continue-on-error: true
+
+ - name: Package (Unix)
+ if: runner.os != 'Windows'
+ run: |
+ cd target/${{ matrix.target }}/release
+ tar czf ../../../${{ matrix.name }}.tar.gz obscura
+ cd ../../..
+
+ - name: Package (Windows)
+ if: runner.os == 'Windows'
+ run: |
+ cd target/${{ matrix.target }}/release
+ 7z a ../../../${{ matrix.name }}.zip obscura.exe
+ cd ../../..
+
+ - name: Upload
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ ${{ matrix.name }}.tar.gz
+ ${{ matrix.name }}.zip
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..6be156f
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,3531 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "async-compression"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "az"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.71.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "boring-sys2"
+version = "5.0.0-alpha.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "455d79965f5155dcc88a7abce112c3590883889131b799beda10bf9a813ed669"
+dependencies = [
+ "bindgen 0.72.1",
+ "cmake",
+ "fs_extra",
+ "fslock",
+]
+
+[[package]]
+name = "boring2"
+version = "5.0.0-alpha.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28"
+dependencies = [
+ "bitflags",
+ "boring-sys2",
+ "foreign-types 0.5.0",
+ "libc",
+ "openssl-macros",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "capacity_builder"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f2d24a6dcf0cd402a21b65d35340f3a49ff3475dc5fdac91d22d2733e6641c6"
+dependencies = [
+ "capacity_builder_macros",
+ "itoa",
+]
+
+[[package]]
+name = "capacity_builder_macros"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "compression-codecs"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
+dependencies = [
+ "brotli",
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
+
+[[package]]
+name = "cooked-waker"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f"
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "cookie_store"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
+dependencies = [
+ "cookie",
+ "document-features",
+ "idna",
+ "log",
+ "publicsuffix",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "time",
+ "url",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+
+[[package]]
+name = "debugid"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
+dependencies = [
+ "serde",
+ "uuid",
+]
+
+[[package]]
+name = "deno_core"
+version = "0.350.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e273b731fce500130790e777cb2631dc451db412975304d23816c1e444a10c5b"
+dependencies = [
+ "anyhow",
+ "az",
+ "bincode",
+ "bit-set",
+ "bit-vec",
+ "bytes",
+ "capacity_builder",
+ "cooked-waker",
+ "deno_core_icudata",
+ "deno_error",
+ "deno_ops",
+ "deno_path_util",
+ "deno_unsync",
+ "futures",
+ "indexmap",
+ "libc",
+ "parking_lot",
+ "percent-encoding",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "serde_v8",
+ "smallvec",
+ "sourcemap",
+ "static_assertions",
+ "thiserror",
+ "tokio",
+ "url",
+ "v8",
+ "wasm_dep_analyzer",
+]
+
+[[package]]
+name = "deno_core_icudata"
+version = "0.74.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe4dccb6147bb3f3ba0c7a48e993bfeb999d2c2e47a81badee80e2b370c8d695"
+
+[[package]]
+name = "deno_error"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612ec3fc481fea759141b0c57810889b0a4fb6fee8f10748677bfe492fd30486"
+dependencies = [
+ "deno_error_macro",
+ "libc",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "deno_error_macro"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8380a4224d5d2c3f84da4d764c4326cac62e9a1e3d4960442d29136fc07be863"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "deno_ops"
+version = "0.226.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28b12489187c71fa123731cc783d48beb17ae5df04da991909cc2ae5a3d0ef9"
+dependencies = [
+ "indexmap",
+ "proc-macro-rules",
+ "proc-macro2",
+ "quote",
+ "stringcase",
+ "strum",
+ "strum_macros",
+ "syn",
+ "thiserror",
+]
+
+[[package]]
+name = "deno_path_util"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "516f813389095889776b81cc9108ff6f336fd9409b4b12fc0138aea23d2708e1"
+dependencies = [
+ "deno_error",
+ "percent-encoding",
+ "sys_traits",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "deno_unsync"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6742a724e8becb372a74c650a1aefb8924a5b8107f7d75b3848763ea24b27a87"
+dependencies = [
+ "futures-util",
+ "parking_lot",
+ "tokio",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "derive_more"
+version = "0.99.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared 0.1.1",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared 0.3.1",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "fslock"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "gzip-header"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2"
+dependencies = [
+ "crc32fast",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "match_token",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http2"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7349aa548b6a413a7e7146d5d7e3db4565b3d0fbc8ff53c72e46b44b083cf9f"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "parking_lot",
+ "slab",
+ "smallvec",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "if_chain"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb"
+
+[[package]]
+name = "indexmap"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "iri-string"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.94"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.184"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "markup5ever"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
+dependencies = [
+ "log",
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "match_token"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "obscura-browser"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures",
+ "obscura-dom",
+ "obscura-js",
+ "obscura-net",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "obscura-cdp"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "futures-util",
+ "obscura-browser",
+ "obscura-dom",
+ "obscura-js",
+ "obscura-net",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tokio-tungstenite",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "obscura-cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "obscura-browser",
+ "obscura-cdp",
+ "obscura-dom",
+ "obscura-js",
+ "obscura-net",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+]
+
+[[package]]
+name = "obscura-dom"
+version = "0.1.0"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "markup5ever",
+ "precomputed-hash",
+ "selectors",
+ "servo_arc",
+ "thiserror",
+ "tracing",
+]
+
+[[package]]
+name = "obscura-js"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "deno_core",
+ "deno_error",
+ "html5ever",
+ "obscura-dom",
+ "obscura-net",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "obscura-net"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "url",
+ "wreq",
+ "wreq-util",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "openssl"
+version = "0.10.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types 0.3.2",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-src"
+version = "300.6.0+3.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.112"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
+dependencies = [
+ "cc",
+ "libc",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "outref"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro-rules"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f"
+dependencies = [
+ "proc-macro-rules-macros",
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro-rules-macros"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "207fffb0fe655d1d47f6af98cc2793405e85929bdbc420d685554ff07be27ac7"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psl-types"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
+
+[[package]]
+name = "publicsuffix"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
+dependencies = [
+ "idna",
+ "psl-types",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "cookie",
+ "cookie_store",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "schnellru"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649"
+dependencies = [
+ "ahash",
+ "cfg-if",
+ "hashbrown 0.13.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "selectors"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
+dependencies = [
+ "bitflags",
+ "cssparser",
+ "derive_more",
+ "fxhash",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_v8"
+version = "0.259.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e4c5f439cffc03021c8bfd380cd1ef6acb4788a72b7e54bf4a83c73f91f8a0"
+dependencies = [
+ "deno_error",
+ "num-bigint",
+ "serde",
+ "smallvec",
+ "thiserror",
+ "v8",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "sourcemap"
+version = "9.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "314d62a489431668f719ada776ca1d49b924db951b7450f8974c9ae51ab05ad7"
+dependencies = [
+ "base64-simd",
+ "bitvec",
+ "data-encoding",
+ "debugid",
+ "if_chain",
+ "rustc-hash",
+ "serde",
+ "serde_json",
+ "unicode-id-start",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "stringcase"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72abeda133c49d7bddece6c154728f83eec8172380c80ab7096da9487e20d27c"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "sys_traits"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a79feaa49de4a6c8191bdbd5fb3eada50671e9367d874d1c12e3d36db131414"
+dependencies = [
+ "sys_traits_macros",
+]
+
+[[package]]
+name = "sys_traits_macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.4",
+ "once_cell",
+ "rustix 1.1.4",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.51.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-boring2"
+version = "5.0.0-alpha.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f81df1210d791f31d72d840de8fbd80b9c3cb324956523048b1413e2bd55756"
+dependencies = [
+ "boring2",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "async-compression",
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "iri-string",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.2",
+ "sha1",
+ "thiserror",
+ "utf-8",
+]
+
+[[package]]
+name = "typed-builder"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
+dependencies = [
+ "typed-builder-macro",
+]
+
+[[package]]
+name = "typed-builder-macro"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "unicode-id-start"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "v8"
+version = "137.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595"
+dependencies = [
+ "bindgen 0.71.1",
+ "bitflags",
+ "fslock",
+ "gzip-header",
+ "home",
+ "miniz_oxide",
+ "paste",
+ "which",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm_dep_analyzer"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c"
+dependencies = [
+ "deno_error",
+ "thiserror",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.94"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "which"
+version = "6.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
+dependencies = [
+ "either",
+ "home",
+ "rustix 0.38.44",
+ "winsafe",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winsafe"
+version = "0.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "wreq"
+version = "6.0.0-rc.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f79937f6c4df65b3f6f78715b9de2977afe9ee3b3436483c7949a24511e25935"
+dependencies = [
+ "ahash",
+ "boring2",
+ "brotli",
+ "bytes",
+ "flate2",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http2",
+ "httparse",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "schnellru",
+ "smallvec",
+ "socket2",
+ "tokio",
+ "tokio-boring2",
+ "tower",
+ "url",
+ "want",
+ "webpki-root-certs",
+ "zstd",
+]
+
+[[package]]
+name = "wreq-util"
+version = "3.0.0-rc.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c6bbe24d28beb9ceb58b514bd6a613c759d3b706f768b9d2950d5d35b543c04"
+dependencies = [
+ "typed-builder",
+ "wreq",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..f9b0507
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,41 @@
+[workspace]
+resolver = "2"
+members = [
+ "crates/obscura-dom",
+ "crates/obscura-net",
+ "crates/obscura-browser",
+ "crates/obscura-cdp",
+ "crates/obscura-js",
+ "crates/obscura-cli",
+]
+
+[workspace.package]
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+repository = "https://github.com/h4ckf0r0day/obscura"
+
+[workspace.dependencies]
+obscura-dom = { path = "crates/obscura-dom" }
+obscura-net = { path = "crates/obscura-net" }
+obscura-browser = { path = "crates/obscura-browser" }
+obscura-cdp = { path = "crates/obscura-cdp" }
+obscura-js = { path = "crates/obscura-js" }
+
+html5ever = "0.29"
+markup5ever = "0.14"
+selectors = "0.26"
+servo_arc = "0.4"
+cssparser = "0.34"
+tokio = { version = "1", features = ["full"] }
+tokio-tungstenite = "0.26"
+reqwest = { version = "0.12", features = ["cookies", "gzip", "brotli", "deflate", "native-tls-vendored"], default-features = false }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+url = "2"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+clap = { version = "4", features = ["derive"] }
+uuid = { version = "1", features = ["v4"] }
+thiserror = "2"
+anyhow = "1"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5bb8751
--- /dev/null
+++ b/README.md
@@ -0,0 +1,248 @@
+
+
+
+
+Obscura
+
+
+ The open-source headless browser for AI agents and web scraping.
+ Lightweight, stealthy, and built in Rust.
+
+
+---
+
+Obscura is a headless browser engine written in Rust, built for web scraping and AI agent automation. It runs real JavaScript via V8, supports the Chrome DevTools Protocol, and acts as a drop-in replacement for headless Chrome with Puppeteer and Playwright.
+
+### Why Obscura over headless Chrome?
+
+Designed for automation at scale, not desktop browsing.
+
+| Metric | Obscura | Headless Chrome |
+|--------------|--------------|------------------|
+| Memory | **30 MB** | 200+ MB |
+| Binary size | **70 MB** | 300+ MB |
+| Anti-detect | **Built-in** | None |
+| Page load | **85 ms** | ~500 ms |
+| Startup | **Instant** | ~2s |
+| Puppeteer | **Yes** | Yes |
+| Playwright | **Yes** | Yes |
+
+## Install
+
+### Download
+
+Grab the latest binary from [Releases](https://github.com/h4ckf0r0day/obscura/releases):
+
+```bash
+# Linux x86_64
+curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-linux.tar.gz
+tar xzf obscura-x86_64-linux.tar.gz
+./obscura fetch https://example.com --eval "document.title"
+
+# macOS Apple Silicon
+curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-aarch64-macos.tar.gz
+tar xzf obscura-aarch64-macos.tar.gz
+
+# macOS Intel
+curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-macos.tar.gz
+tar xzf obscura-x86_64-macos.tar.gz
+
+# Windows
+Download the `.zip` from the releases page and extract it manually.
+```
+
+Single binary. No Chrome, no Node.js, no dependencies.
+
+### Build from source
+
+```bash
+git clone https://github.com/h4ckf0r0day/obscura.git
+cd obscura
+cargo build --release
+
+# With stealth mode (anti-detection + tracker blocking)
+cargo build --release --features stealth
+```
+
+Requires Rust 1.75+ ([rustup.rs](https://rustup.rs)). First build takes ~5 min (V8 compiles from source, cached after).
+
+## Quick Start
+
+### Fetch a page
+
+```bash
+# Get the page title
+obscura fetch https://example.com --eval "document.title"
+
+# Extract all links
+obscura fetch https://example.com --dump links
+
+# Render JavaScript and dump HTML
+obscura fetch https://news.ycombinator.com --dump html
+
+# Wait for dynamic content
+obscura fetch https://example.com --wait-until networkidle0
+```
+
+### Start the CDP server
+
+```bash
+obscura serve --port 9222
+
+# With stealth mode (anti-detection + tracker blocking)
+obscura serve --port 9222 --stealth
+```
+
+### Scrape in parallel
+
+```bash
+obscura scrape url1 url2 url3 ... \
+ --concurrency 25 \
+ --eval "document.querySelector('h1').textContent" \
+ --format json
+```
+
+## Puppeteer / Playwright
+
+### Puppeteer
+
+```bash
+npm install puppeteer-core
+```
+
+```javascript
+import puppeteer from 'puppeteer-core';
+
+const browser = await puppeteer.connect({
+ browserWSEndpoint: 'ws://127.0.0.1:9222/devtools/browser',
+});
+
+const page = await browser.newPage();
+await page.goto('https://news.ycombinator.com');
+
+const stories = await page.evaluate(() =>
+ Array.from(document.querySelectorAll('.titleline > a'))
+ .map(a => ({ title: a.textContent, url: a.href }))
+);
+console.log(stories);
+
+await browser.disconnect();
+```
+
+### Playwright
+
+```bash
+npm install playwright-core
+```
+
+```javascript
+import { chromium } from 'playwright-core';
+
+const browser = await chromium.connectOverCDP({
+ endpointURL: 'ws://127.0.0.1:9222',
+});
+
+const page = await browser.newContext().then(ctx => ctx.newPage());
+await page.goto('https://en.wikipedia.org/wiki/Web_scraping');
+console.log(await page.title());
+
+await browser.close();
+```
+
+### Form submission & login
+
+```javascript
+await page.goto('https://quotes.toscrape.com/login');
+await page.evaluate(() => {
+ document.querySelector('#username').value = 'admin';
+ document.querySelector('#password').value = 'admin';
+ document.querySelector('form').submit();
+});
+// Obscura handles the POST, follows the 302 redirect, maintains cookies
+```
+
+## Benchmarks
+
+Page load:
+
+| Page | Obscura | Chrome |
+|------|---------|--------|
+| Static HTML | **51 ms** | ~500 ms |
+| JS + XHR + fetch | **84 ms** | ~800 ms |
+| Dynamic scripts | **78 ms** | ~700 ms |
+
+## Stealth Mode
+
+Enable with `--features stealth`.
+
+### Anti-fingerprinting
+- Per-session fingerprint randomization (GPU, screen, canvas, audio, battery)
+- Realistic `navigator.userAgentData` (Chrome 145, high-entropy values)
+- `event.isTrusted = true` for dispatched events
+- Hidden internal properties (`Object.keys(window)` safe)
+- Native function masking (`Function.prototype.toString()` → `[native code]`)
+- `navigator.webdriver = undefined` (matches real Chrome)
+
+### Tracker Blocking
+- 3,520 domains blocked
+- Blocks analytics, ads, telemetry, and fingerprinting scripts
+- Prevents trackers from loading entirely
+- Enabled automatically with `--stealth`
+
+## CDP API
+
+Obscura implements the Chrome DevTools Protocol for Puppeteer/Playwright compatibility.
+
+| Domain | Methods |
+|--------|---------|
+| **Target** | createTarget, closeTarget, attachToTarget, createBrowserContext, disposeBrowserContext |
+| **Page** | navigate, getFrameTree, addScriptToEvaluateOnNewDocument, lifecycleEvents |
+| **Runtime** | evaluate, callFunctionOn, getProperties, addBinding |
+| **DOM** | getDocument, querySelector, querySelectorAll, getOuterHTML, resolveNode |
+| **Network** | enable, setCookies, getCookies, setExtraHTTPHeaders, setUserAgentOverride |
+| **Fetch** | enable, continueRequest, fulfillRequest, failRequest (live interception) |
+| **Storage** | getCookies, setCookies, deleteCookies |
+| **Input** | dispatchMouseEvent, dispatchKeyEvent |
+| **LP** | getMarkdown (DOM-to-Markdown conversion) |
+## CLI Reference
+
+### `obscura serve`
+
+Start a CDP WebSocket server.
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--port` | `9222` | WebSocket port |
+| `--proxy` | — | HTTP/SOCKS5 proxy URL |
+| `--stealth` | off | Enable anti-detection + tracker blocking |
+| `--workers` | `1` | Number of parallel worker processes |
+| `--obey-robots` | off | Respect robots.txt |
+
+### `obscura fetch `
+
+Fetch and render a single page.
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--dump` | `html` | Output: `html`, `text`, or `links` |
+| `--eval` | — | JavaScript expression to evaluate |
+| `--wait-until` | `load` | Wait: `load`, `domcontentloaded`, `networkidle0` |
+| `--selector` | — | Wait for CSS selector |
+| `--stealth` | off | Anti-detection mode |
+| `--quiet` | off | Suppress banner |
+
+### `obscura scrape `
+
+Scrape multiple URLs in parallel with worker processes.
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--concurrency` | `10` | Parallel workers |
+| `--eval` | — | JS expression per page |
+| `--format` | `json` | Output: `json` or `text` |
+
+## License
+
+MIT
+
+---
\ No newline at end of file
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..ce80df5
Binary files /dev/null and b/assets/icon.png differ
diff --git a/crates/obscura-browser/Cargo.toml b/crates/obscura-browser/Cargo.toml
new file mode 100644
index 0000000..bb88efe
--- /dev/null
+++ b/crates/obscura-browser/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "obscura-browser"
+version.workspace = true
+edition.workspace = true
+
+[features]
+default = []
+stealth = ["obscura-net/stealth"]
+
+[dependencies]
+obscura-dom = { workspace = true }
+obscura-net = { workspace = true }
+obscura-js = { workspace = true }
+tokio = { workspace = true }
+futures = "0.3"
+url = { workspace = true }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+serde_json = { workspace = true }
diff --git a/crates/obscura-browser/src/context.rs b/crates/obscura-browser/src/context.rs
new file mode 100644
index 0000000..dace8cb
--- /dev/null
+++ b/crates/obscura-browser/src/context.rs
@@ -0,0 +1,57 @@
+use std::sync::Arc;
+
+use obscura_net::{CookieJar, ObscuraHttpClient, RobotsCache};
+
+pub struct BrowserContext {
+ pub id: String,
+ pub cookie_jar: Arc,
+ pub http_client: Arc,
+ pub user_agent: String,
+ pub proxy_url: Option,
+ pub robots_cache: Arc,
+ pub obey_robots: bool,
+ pub stealth: bool,
+}
+
+impl BrowserContext {
+ pub fn new(id: String) -> Self {
+ let cookie_jar = Arc::new(CookieJar::new());
+ let http_client = Arc::new(ObscuraHttpClient::with_cookie_jar(cookie_jar.clone()));
+ BrowserContext {
+ id,
+ cookie_jar,
+ http_client,
+ user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string(),
+ proxy_url: None,
+ robots_cache: Arc::new(RobotsCache::new()),
+ obey_robots: false,
+ stealth: false,
+ }
+ }
+
+ pub fn with_options(id: String, proxy_url: Option, stealth: bool) -> Self {
+ let cookie_jar = Arc::new(CookieJar::new());
+ let mut client = ObscuraHttpClient::with_options(
+ cookie_jar.clone(),
+ proxy_url.as_deref(),
+ );
+ if stealth {
+ client.block_trackers = true;
+ }
+ let http_client = Arc::new(client);
+ BrowserContext {
+ id,
+ cookie_jar,
+ http_client,
+ user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36".to_string(),
+ proxy_url,
+ robots_cache: Arc::new(RobotsCache::new()),
+ obey_robots: false,
+ stealth,
+ }
+ }
+
+ pub fn with_proxy(id: String, proxy_url: Option) -> Self {
+ Self::with_options(id, proxy_url, false)
+ }
+}
diff --git a/crates/obscura-browser/src/lib.rs b/crates/obscura-browser/src/lib.rs
new file mode 100644
index 0000000..1ece192
--- /dev/null
+++ b/crates/obscura-browser/src/lib.rs
@@ -0,0 +1,7 @@
+pub mod page;
+pub mod context;
+pub mod lifecycle;
+
+pub use page::{Page, PageError};
+pub use context::BrowserContext;
+pub use lifecycle::{LifecycleState, WaitUntil};
diff --git a/crates/obscura-browser/src/lifecycle.rs b/crates/obscura-browser/src/lifecycle.rs
new file mode 100644
index 0000000..ca668f9
--- /dev/null
+++ b/crates/obscura-browser/src/lifecycle.rs
@@ -0,0 +1,42 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LifecycleState {
+ Idle,
+ Loading,
+ DomContentLoaded,
+ Loaded,
+ NetworkIdle,
+ Failed,
+}
+
+impl LifecycleState {
+ pub fn is_loading(&self) -> bool {
+ matches!(self, LifecycleState::Loading)
+ }
+
+ pub fn is_loaded(&self) -> bool {
+ matches!(self, LifecycleState::Loaded | LifecycleState::NetworkIdle)
+ }
+
+ pub fn is_network_idle(&self) -> bool {
+ matches!(self, LifecycleState::NetworkIdle)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WaitUntil {
+ Load,
+ DomContentLoaded,
+ NetworkIdle0,
+ NetworkIdle2,
+}
+
+impl WaitUntil {
+ pub fn from_str(s: &str) -> Self {
+ match s {
+ "domcontentloaded" => WaitUntil::DomContentLoaded,
+ "networkidle0" | "networkIdle" | "networkidle" => WaitUntil::NetworkIdle0,
+ "networkidle2" => WaitUntil::NetworkIdle2,
+ _ => WaitUntil::Load,
+ }
+ }
+}
diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
new file mode 100644
index 0000000..14557ce
--- /dev/null
+++ b/crates/obscura-browser/src/page.rs
@@ -0,0 +1,859 @@
+use std::sync::Arc;
+
+use obscura_dom::{parse_html, DomTree};
+use obscura_js::runtime::ObscuraJsRuntime;
+use obscura_net::{ObscuraHttpClient, ObscuraNetError, Response};
+use url::Url;
+
+use crate::context::BrowserContext;
+use crate::lifecycle::LifecycleState;
+
+#[cfg(feature = "stealth")]
+use obscura_net::StealthHttpClient;
+
+#[derive(Debug, Clone)]
+pub struct NetworkEvent {
+ pub request_id: String,
+ pub url: String,
+ pub method: String,
+ pub resource_type: String,
+ pub status: u16,
+ pub headers: std::collections::HashMap,
+ pub response_headers: Arc>,
+ pub body_size: usize,
+ pub timestamp: f64,
+}
+
+pub struct Page {
+ pub id: String,
+ pub frame_id: String,
+ pub url: Option,
+ pub dom: Option,
+ pub js: Option,
+ pub lifecycle: LifecycleState,
+ pub http_client: Arc,
+ pub context: Arc,
+ pub title: String,
+ pub network_events: Vec,
+ network_event_counter: u32,
+ pub intercept_enabled: bool,
+ pub intercept_block_patterns: Vec,
+ intercept_tx: Option>,
+ #[cfg(feature = "stealth")]
+ pub stealth_client: Option>,
+}
+
+impl Page {
+ pub fn new(id: String, context: Arc) -> Self {
+ let http_client = context.http_client.clone();
+ let frame_id = format!("{}.1", id);
+ #[cfg(feature = "stealth")]
+ let stealth_client = if context.stealth {
+ Some(Arc::new(StealthHttpClient::new(context.cookie_jar.clone())))
+ } else {
+ None
+ };
+
+ Page {
+ id,
+ frame_id,
+ url: None,
+ dom: None,
+ js: None,
+ lifecycle: LifecycleState::Idle,
+ http_client,
+ context,
+ title: String::new(),
+ network_events: Vec::new(),
+ network_event_counter: 0,
+ intercept_enabled: false,
+ intercept_block_patterns: Vec::new(),
+ intercept_tx: None,
+ #[cfg(feature = "stealth")]
+ stealth_client,
+ }
+ }
+
+ fn should_block_url(&self, url: &str) -> bool {
+ if !self.intercept_enabled || self.intercept_block_patterns.is_empty() {
+ return false;
+ }
+ for pattern in &self.intercept_block_patterns {
+ if pattern == "*" { return true; }
+ if pattern.starts_with('*') && pattern.ends_with('*') {
+ if url.contains(&pattern[1..pattern.len()-1]) { return true; }
+ } else if pattern.starts_with('*') {
+ if url.ends_with(&pattern[1..]) { return true; }
+ } else if pattern.ends_with('*') {
+ if url.starts_with(&pattern[..pattern.len()-1]) { return true; }
+ } else if url.contains(pattern) {
+ return true;
+ }
+ }
+ false
+ }
+
+ async fn do_fetch(&self, url: &Url) -> Result {
+ #[cfg(feature = "stealth")]
+ if let Some(ref stealth) = self.stealth_client {
+ return stealth.fetch(url).await;
+ }
+ self.http_client.fetch(url).await
+ }
+ fn init_js(&mut self) {
+ if self.js.is_some() {
+ let url_str = self.url_string();
+ let title = self.title.clone();
+ let dom = self.dom.take();
+
+ let js = self.js.as_mut().unwrap();
+ js.set_url(&url_str);
+ js.set_title(&title);
+
+ if let Some(d) = dom {
+ js.set_dom(d);
+ }
+
+ let _ = js.execute_script("",
+ "_cache.clear(); globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0; \
+ _iframeRegistry.length = 0; globalThis.length = 0; \
+ globalThis._formValues = {}; globalThis._formChecked = {}; \
+ globalThis._eventRegistry = {}; \
+ globalThis.document = new Document(+_dom('document_node_id'));");
+
+ return;
+ }
+
+ let mut rt = ObscuraJsRuntime::with_base_url(&self.url_string());
+ rt.set_url(&self.url_string());
+ rt.set_title(&self.title);
+
+ #[cfg(feature = "stealth")]
+ if self.stealth_client.is_some() {
+ rt.set_user_agent(obscura_net::STEALTH_USER_AGENT);
+ } else if let Ok(ua) = self.http_client.user_agent.try_read() {
+ rt.set_user_agent(&ua);
+ }
+ #[cfg(not(feature = "stealth"))]
+ if let Ok(ua) = self.http_client.user_agent.try_read() {
+ rt.set_user_agent(&ua);
+ }
+
+ rt.set_cookie_jar(self.context.cookie_jar.clone());
+ rt.set_http_client(self.http_client.clone());
+
+ if let Some(tx) = &self.intercept_tx {
+ rt.set_intercept_tx(tx.clone());
+ }
+
+ if let Some(dom) = self.dom.take() {
+ rt.set_dom(dom);
+ }
+
+ self.js = Some(rt);
+ }
+
+ async fn execute_scripts(&mut self) {
+ tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
+
+ #[derive(Debug)]
+ struct ScriptInfo {
+ src: Option,
+ inline: String,
+ is_defer: bool,
+ is_async: bool,
+ is_module: bool,
+ }
+
+ let all_scripts = match &self.js {
+ Some(js) => {
+ js.with_dom(|dom| {
+ let script_ids = dom.query_selector_all("script").unwrap_or_default();
+ let mut scripts = Vec::new();
+
+ for sid in script_ids {
+ if let Some(node) = dom.get_node(sid) {
+ let src = node.get_attribute("src").map(|s| s.to_string());
+ let script_type = node.get_attribute("type").unwrap_or("").to_string();
+ let is_defer = node.get_attribute("defer").is_some();
+ let is_async = node.get_attribute("async").is_some();
+ let is_module = script_type == "module";
+
+ if !script_type.is_empty()
+ && script_type != "text/javascript"
+ && script_type != "application/javascript"
+ && script_type != "module"
+ {
+ continue;
+ }
+
+ let inline_code = if src.is_none() {
+ dom.text_content(sid)
+ } else {
+ String::new()
+ };
+
+ if src.is_some() || !inline_code.trim().is_empty() {
+ scripts.push(ScriptInfo {
+ src,
+ inline: inline_code,
+ is_defer,
+ is_async,
+ is_module,
+ });
+ }
+ }
+ }
+ scripts
+ }).unwrap_or_default()
+ }
+ None => return,
+ };
+
+ let mut regular = Vec::new();
+ let mut deferred = Vec::new();
+ let mut async_scripts = Vec::new();
+
+ let mut module_scripts = Vec::new();
+
+ for script in all_scripts {
+ if script.is_module {
+ module_scripts.push(script);
+ continue;
+ }
+ if script.is_defer {
+ deferred.push(script);
+ } else if script.is_async {
+ async_scripts.push(script);
+ } else {
+ regular.push(script);
+ }
+ }
+
+ let scripts = regular;
+
+ tracing::info!("Found {} regular + {} deferred + {} async scripts", scripts.len(), deferred.len(), async_scripts.len());
+ let all_to_execute: Vec = scripts.into_iter()
+ .chain(deferred.into_iter())
+ .chain(async_scripts.into_iter())
+ .collect();
+
+ let mut resolved: Vec<(usize, String)> = Vec::new();
+ let mut fetch_tasks: Vec<(usize, String)> = Vec::new();
+
+ for (i, script) in all_to_execute.iter().enumerate() {
+ if let Some(src_url) = &script.src {
+ let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") {
+ src_url.clone()
+ } else if let Some(base) = &self.url {
+ base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone())
+ } else {
+ src_url.clone()
+ };
+
+ if self.should_block_url(&full_url) {
+ tracing::info!("Blocked script by interception: {}", full_url);
+ continue;
+ }
+ resolved.push((i, full_url.clone()));
+ fetch_tasks.push((i, full_url));
+ }
+ }
+
+ let client = self.http_client.clone();
+ let fetch_futures: Vec<_> = fetch_tasks.iter().map(|(idx, url)| {
+ let client = client.clone();
+ let url = url.clone();
+ let idx = *idx;
+ async move {
+ let parsed = Url::parse(&url).unwrap_or_else(|_| Url::parse("about:blank").unwrap());
+ match client.fetch(&parsed).await {
+ Ok(resp) => Some((idx, url, resp)),
+ Err(e) => {
+ tracing::warn!("Failed to fetch script {}: {}", url, e);
+ None
+ }
+ }
+ }
+ }).collect();
+
+ let fetch_results = futures::future::join_all(fetch_futures).await;
+
+ let mut fetched: std::collections::HashMap = std::collections::HashMap::new();
+ for result in fetch_results {
+ if let Some((idx, url, resp)) = result {
+ let code = String::from_utf8_lossy(&resp.body).to_string();
+ fetched.insert(idx, (url, code, resp));
+ }
+ }
+
+ for (i, script) in all_to_execute.iter().enumerate() {
+ if script.src.is_some() {
+ if let Some((url, code, resp)) = fetched.remove(&i) {
+ tracing::info!("Executing script ({} bytes): {}", code.len(), url);
+ self.record_network_event(&url, "GET", "Script", resp.status, &resp.headers, resp.body.len());
+ if let Some(js) = &mut self.js {
+ if let Err(e) = js.execute_script_guarded(&url, &code) {
+ tracing::warn!("Script error ({}): {}", url, e);
+ }
+ }
+ }
+ } else if !script.inline.is_empty() {
+ if let Some(js) = &mut self.js {
+ if let Err(e) = js.execute_script_guarded("", &script.inline) {
+ tracing::warn!("Inline script error: {}", e);
+ }
+ }
+ }
+ }
+
+ for module_script in &module_scripts {
+ if let Some(ref src) = module_script.src {
+ let full_url = if src.starts_with("http://") || src.starts_with("https://") {
+ src.clone()
+ } else if let Some(base) = &self.url {
+ base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone())
+ } else {
+ src.clone()
+ };
+
+ tracing::info!("Loading ES module: {}", full_url);
+ if let Some(js) = &mut self.js {
+ match js.load_module(&full_url).await {
+ Ok(()) => {
+ tracing::info!("ES module loaded: {}", full_url);
+ self.record_network_event(&full_url, "GET", "Script", 200, &std::collections::HashMap::new(), 0);
+ }
+ Err(e) => {
+ tracing::warn!("ES module error ({}): {}", full_url, e);
+ }
+ }
+ }
+ } else if !module_script.inline.is_empty() {
+ let base = self.url_string();
+ if let Some(js) = &mut self.js {
+ if let Err(e) = js.load_inline_module(&module_script.inline, &base).await {
+ tracing::warn!("Inline ES module error: {}", e);
+ }
+ }
+ }
+ }
+
+ if let Some(js) = &mut self.js {
+ let _ = js.execute_script("",
+ "if (typeof window.onload === 'function') { try { window.onload(); } catch(e) {} }\n\
+ try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch(e) {}\n\
+ try { window.dispatchEvent(new Event('load')); } catch(e) {}");
+ }
+
+ if let Some(js) = &mut self.js {
+ let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
+ let mut idle_count = 0u32;
+ loop {
+ let result = tokio::time::timeout(
+ tokio::time::Duration::from_millis(10),
+ js.run_event_loop(),
+ ).await;
+
+ match result {
+ Ok(Ok(())) => {
+ if self.http_client.active_requests() == 0 {
+ idle_count += 1;
+ if idle_count >= 2 {
+ break;
+ }
+ tokio::task::yield_now().await;
+ } else {
+ idle_count = 0;
+ tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
+ }
+ }
+ Ok(Err(_)) => break,
+ Err(_) => {
+ idle_count = 0;
+ if tokio::time::Instant::now() >= deadline {
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ pub async fn navigate(&mut self, url_str: &str) -> Result<(), PageError> {
+ self.navigate_with_wait(url_str, crate::lifecycle::WaitUntil::Load).await
+ }
+
+ pub async fn navigate_with_wait(
+ &mut self,
+ url_str: &str,
+ wait_until: crate::lifecycle::WaitUntil,
+ ) -> Result<(), PageError> {
+ self.navigate_with_wait_post(url_str, wait_until, "GET", "").await
+ }
+
+ pub async fn navigate_with_wait_post(
+ &mut self,
+ url_str: &str,
+ wait_until: crate::lifecycle::WaitUntil,
+ method: &str,
+ body: &str,
+ ) -> Result<(), PageError> {
+ let mut current_url = url_str.to_string();
+ let mut current_method = method.to_string();
+ let mut current_body = body.to_string();
+ for _chain in 0..10 {
+ self.navigate_single(¤t_url, wait_until, ¤t_method, ¤t_body).await?;
+ if let Some((next_url, next_method, next_body)) = self.take_pending_navigation() {
+ tracing::info!("JS-triggered navigation chain: {} {} -> {}", current_method, current_url, next_url);
+ current_url = next_url;
+ current_method = next_method;
+ current_body = next_body;
+ continue;
+ }
+ break;
+ }
+ Ok(())
+ }
+
+ async fn navigate_single(
+ &mut self,
+ url_str: &str,
+ wait_until: crate::lifecycle::WaitUntil,
+ method: &str,
+ body: &str,
+ ) -> Result<(), PageError> {
+ let url = Url::parse(url_str).map_err(|e| PageError::InvalidUrl(e.to_string()))?;
+
+ self.lifecycle = LifecycleState::Loading;
+ self.url = Some(url.clone());
+ self.network_events.clear();
+
+ if self.context.obey_robots {
+ if let Some(domain) = url.host_str() {
+ if self.context.robots_cache.is_allowed(domain, "/robots.txt") {
+ let robots_url = format!("{}://{}/robots.txt", url.scheme(), domain);
+ if let Ok(robots_url) = Url::parse(&robots_url) {
+ if let Ok(resp) = self.http_client.fetch(&robots_url).await {
+ if resp.status == 200 {
+ let body = String::from_utf8_lossy(&resp.body);
+ self.context.robots_cache.parse_and_store(
+ domain,
+ &body,
+ &self.context.user_agent,
+ );
+ }
+ }
+ }
+ }
+
+ if !self.context.robots_cache.is_allowed(domain, url.path()) {
+ self.lifecycle = LifecycleState::Failed;
+ return Err(PageError::NetworkError(format!(
+ "Blocked by robots.txt: {}",
+ url
+ )));
+ }
+ }
+ }
+
+ let response = if method == "POST" {
+ self.http_client.post_form(&url, body).await
+ } else {
+ self.do_fetch(&url).await
+ }.map_err(|e| {
+ self.lifecycle = LifecycleState::Failed;
+ PageError::NetworkError(e.to_string())
+ })?;
+
+ self.record_network_event(
+ url.as_str(),
+ "GET",
+ "Document",
+ response.status,
+ &response.headers,
+ response.body.len(),
+ );
+
+ if !response.redirected_from.is_empty() {
+ self.url = Some(response.url.clone());
+ }
+
+ let body_text = String::from_utf8_lossy(&response.body).to_string();
+ let dom = parse_html(&body_text);
+
+ self.title = dom
+ .query_selector("title")
+ .ok()
+ .flatten()
+ .map(|title_id| dom.text_content(title_id))
+ .unwrap_or_default();
+
+ let stylesheet_urls: Vec = dom
+ .query_selector_all("link")
+ .unwrap_or_default()
+ .iter()
+ .filter_map(|&nid| {
+ let node = dom.get_node(nid)?;
+ let rel = node.get_attribute("rel")?;
+ if rel.to_lowercase() != "stylesheet" {
+ return None;
+ }
+ node.get_attribute("href").map(|s| s.to_string())
+ })
+ .collect();
+
+ let mut css_fetch_urls: Vec = Vec::new();
+ for href in &stylesheet_urls {
+ let full_url = if href.starts_with("http://") || href.starts_with("https://") {
+ href.clone()
+ } else if let Some(base) = &self.url {
+ base.join(href).map(|u| u.to_string()).unwrap_or_else(|_| href.clone())
+ } else {
+ href.clone()
+ };
+ if self.should_block_url(&full_url) {
+ tracing::info!("Blocked stylesheet by interception: {}", full_url);
+ continue;
+ }
+ css_fetch_urls.push(full_url);
+ }
+
+ let client = self.http_client.clone();
+ let css_futures: Vec<_> = css_fetch_urls.iter().map(|full_url| {
+ let client = client.clone();
+ let url_str = full_url.clone();
+ async move {
+ let parsed = Url::parse(&url_str).unwrap_or_else(|_| Url::parse("about:blank").unwrap());
+ match client.fetch(&parsed).await {
+ Ok(resp) => Some((url_str, resp)),
+ Err(e) => {
+ tracing::debug!("Failed to fetch stylesheet {}: {}", url_str, e);
+ None
+ }
+ }
+ }
+ }).collect();
+
+ let css_results = futures::future::join_all(css_futures).await;
+ let mut css_sources = Vec::new();
+ for result in css_results {
+ if let Some((url_str, resp)) = result {
+ let css = String::from_utf8_lossy(&resp.body).to_string();
+ self.record_network_event(&url_str, "GET", "Stylesheet", resp.status, &resp.headers, resp.body.len());
+ css_sources.push(css);
+ }
+ }
+
+ self.dom = Some(dom);
+ self.lifecycle = LifecycleState::DomContentLoaded;
+
+ if wait_until == crate::lifecycle::WaitUntil::DomContentLoaded {
+ self.init_js();
+ return Ok(());
+ }
+
+ self.init_js();
+
+ if !css_sources.is_empty() {
+ if let Some(js) = &mut self.js {
+ let combined_css = css_sources.join("\n");
+ let escaped = combined_css
+ .replace('\\', "\\\\")
+ .replace('`', "\\`")
+ .replace("${", "\\${");
+ let code = format!("globalThis.__obscura_css = `{}`;", escaped);
+ let _ = js.execute_script("", &code);
+ }
+ }
+ if let Some(js) = &mut self.js {
+ let _ = js.execute_script("",
+ "(function() { var iframes = document.querySelectorAll('iframe[src]'); for (var i = 0; i < iframes.length; i++) { var src = iframes[i].getAttribute('src'); if (src && src !== 'about:blank') iframes[i]._loadIframeSrc(src); } })()");
+ }
+
+ self.execute_scripts().await;
+
+ if let Some(js) = &mut self.js {
+ if let Ok(new_title) = js.evaluate("document.title") {
+ if let Some(t) = new_title.as_str() {
+ self.title = t.to_string();
+ }
+ }
+ }
+
+ self.lifecycle = LifecycleState::Loaded;
+
+ if matches!(
+ wait_until,
+ crate::lifecycle::WaitUntil::NetworkIdle0 | crate::lifecycle::WaitUntil::NetworkIdle2
+ ) {
+ let threshold = match wait_until {
+ crate::lifecycle::WaitUntil::NetworkIdle0 => 0,
+ crate::lifecycle::WaitUntil::NetworkIdle2 => 2,
+ _ => 0,
+ };
+
+ let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
+ let mut idle_since: Option = None;
+
+ loop {
+ let active = self.http_client.active_requests();
+ let now = tokio::time::Instant::now();
+
+ if active <= threshold {
+ if idle_since.is_none() {
+ idle_since = Some(now);
+ }
+ if now.duration_since(idle_since.unwrap()) >= tokio::time::Duration::from_millis(500) {
+ break;
+ }
+ } else {
+ idle_since = None;
+ }
+
+ if now >= deadline {
+ tracing::debug!("Network idle timeout reached with {} active requests", active);
+ break;
+ }
+
+ if let Some(js) = &mut self.js {
+ let _ = tokio::time::timeout(
+ tokio::time::Duration::from_millis(50),
+ js.run_event_loop(),
+ ).await;
+ } else {
+ tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
+ }
+ }
+
+ self.lifecycle = LifecycleState::NetworkIdle;
+ }
+
+ Ok(())
+ }
+
+ pub fn navigate_blank(&mut self) {
+ self.js = None;
+ self.url = Some(Url::parse("about:blank").unwrap());
+ self.dom = Some(parse_html(""));
+ self.title = String::new();
+ self.lifecycle = LifecycleState::Loaded;
+ }
+
+ pub fn url_string(&self) -> String {
+ self.url
+ .as_ref()
+ .map(|u| u.to_string())
+ .unwrap_or_else(|| "about:blank".to_string())
+ }
+
+ pub fn with_dom(&self, f: impl FnOnce(&DomTree) -> R) -> Option {
+ if let Some(js) = &self.js {
+ return js.with_dom(f);
+ }
+ self.dom.as_ref().map(f)
+ }
+
+ pub fn dom(&self) -> Option<&DomTree> {
+ self.dom.as_ref()
+ }
+
+ pub fn evaluate(&mut self, expression: &str) -> serde_json::Value {
+ if let Some(js) = &mut self.js {
+ match js.evaluate(expression) {
+ Ok(val) => val,
+ Err(e) => {
+ tracing::debug!("JS eval error for '{}': {}", &expression[..expression.len().min(80)], e);
+ serde_json::Value::Null
+ }
+ }
+ } else {
+ match expression.trim() {
+ "document.title" => serde_json::Value::String(self.title.clone()),
+ "document.URL" | "document.location.href" | "window.location.href" => {
+ serde_json::Value::String(self.url_string())
+ }
+ _ => serde_json::Value::Null,
+ }
+ }
+ }
+
+ pub fn evaluate_for_cdp(
+ &mut self,
+ expression: &str,
+ return_by_value: bool,
+ ) -> obscura_js::runtime::RemoteObjectInfo {
+ if let Some(js) = &mut self.js {
+ match js.evaluate_for_cdp(expression, return_by_value) {
+ Ok(info) => info,
+ Err(e) => {
+ tracing::debug!("evaluate_for_cdp error: {}", e);
+ obscura_js::runtime::RemoteObjectInfo {
+ js_type: "undefined".into(),
+ subtype: None,
+ class_name: String::new(),
+ description: String::new(),
+ object_id: None,
+ value: None,
+ }
+ }
+ }
+ } else {
+ let val = self.evaluate(expression);
+ obscura_js::runtime::RemoteObjectInfo {
+ js_type: match &val {
+ serde_json::Value::String(_) => "string".into(),
+ serde_json::Value::Number(_) => "number".into(),
+ serde_json::Value::Bool(_) => "boolean".into(),
+ _ => "undefined".into(),
+ },
+ subtype: None,
+ class_name: String::new(),
+ description: String::new(),
+ object_id: None,
+ value: Some(val),
+ }
+ }
+ }
+
+ pub async fn call_function_on_for_cdp(
+ &mut self,
+ function_declaration: &str,
+ object_id: Option<&str>,
+ args: &[serde_json::Value],
+ return_by_value: bool,
+ await_promise: bool,
+ ) -> obscura_js::runtime::RemoteObjectInfo {
+ if let Some(js) = &mut self.js {
+ match js.call_function_on_for_cdp(function_declaration, object_id, args, return_by_value, await_promise).await {
+ Ok(info) => info,
+ Err(e) => {
+ tracing::debug!("callFunctionOn error: {}", e);
+ obscura_js::runtime::RemoteObjectInfo {
+ js_type: "undefined".into(),
+ subtype: None,
+ class_name: String::new(),
+ description: String::new(),
+ object_id: None,
+ value: None,
+ }
+ }
+ }
+ } else {
+ obscura_js::runtime::RemoteObjectInfo {
+ js_type: "undefined".into(),
+ subtype: None,
+ class_name: String::new(),
+ description: String::new(),
+ object_id: None,
+ value: None,
+ }
+ }
+ }
+
+ pub fn set_blocked_urls(&mut self, patterns: Vec) {
+ if let Some(js) = &self.js {
+ js.set_blocked_urls(patterns);
+ }
+ }
+
+ pub fn release_object(&mut self, object_id: &str) {
+ if let Some(js) = &mut self.js {
+ js.release_object(object_id);
+ }
+ }
+
+ fn record_network_event(
+ &mut self,
+ url: &str,
+ method: &str,
+ resource_type: &str,
+ status: u16,
+ response_headers: &std::collections::HashMap,
+ body_size: usize,
+ ) {
+ self.network_event_counter += 1;
+ let timestamp = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs_f64();
+ self.network_events.push(NetworkEvent {
+ request_id: format!("{}.{}", self.id, self.network_event_counter),
+ url: url.to_string(),
+ method: method.to_string(),
+ resource_type: resource_type.to_string(),
+ status,
+ headers: std::collections::HashMap::new(),
+ response_headers: Arc::new(response_headers.clone()),
+ body_size,
+ timestamp,
+ });
+ }
+
+ pub fn execute_preload_script(&mut self, source: &str) -> Result<(), String> {
+ if let Some(js) = &mut self.js {
+ js.execute_script("", source)
+ } else {
+ Err("No JS runtime".to_string())
+ }
+ }
+
+ pub fn suspend_js(&mut self) {
+ if let Some(js) = &self.js {
+ if let Some(dom) = js.take_dom() {
+ self.dom = Some(dom);
+ }
+ }
+ self.js = None;
+ }
+
+ pub fn resume_js(&mut self) {
+ if self.js.is_some() {
+ return;
+ }
+ self.init_js();
+ }
+
+ pub fn has_js(&self) -> bool {
+ self.js.is_some()
+ }
+
+ pub fn release_object_group(&mut self) {
+ if let Some(js) = &mut self.js {
+ js.release_object_group();
+ }
+ }
+
+ pub fn take_pending_navigation(&self) -> Option<(String, String, String)> {
+ if let Some(js) = &self.js {
+ js.take_pending_navigation()
+ } else {
+ None
+ }
+ }
+
+ pub fn set_intercept_tx(&mut self, tx: tokio::sync::mpsc::UnboundedSender) {
+ self.intercept_tx = Some(tx.clone());
+ if let Some(js) = &self.js {
+ js.set_intercept_tx(tx);
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum PageError {
+ #[error("Invalid URL: {0}")]
+ InvalidUrl(String),
+
+ #[error("Network error: {0}")]
+ NetworkError(String),
+
+ #[error("Parse error: {0}")]
+ ParseError(String),
+}
+
+impl From for PageError {
+ fn from(e: ObscuraNetError) -> Self {
+ PageError::NetworkError(e.to_string())
+ }
+}
diff --git a/crates/obscura-cdp/Cargo.toml b/crates/obscura-cdp/Cargo.toml
new file mode 100644
index 0000000..eec400d
--- /dev/null
+++ b/crates/obscura-cdp/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "obscura-cdp"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+obscura-dom = { workspace = true }
+obscura-browser = { workspace = true }
+obscura-js = { workspace = true }
+tokio = { workspace = true }
+tokio-tungstenite = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+url = { workspace = true }
+uuid = { workspace = true }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+futures-util = "0.3"
+obscura-net = { workspace = true }
diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs
new file mode 100644
index 0000000..c2cee0d
--- /dev/null
+++ b/crates/obscura-cdp/src/dispatch.rs
@@ -0,0 +1,136 @@
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use obscura_browser::{BrowserContext, Page};
+use obscura_js::ops::{InterceptResolution, InterceptedRequest};
+use serde_json::json;
+
+use crate::domains;
+use crate::domains::fetch::FetchInterceptState;
+use crate::types::{CdpEvent, CdpRequest, CdpResponse};
+
+pub struct CdpContext {
+ pub pages: Vec,
+ pub sessions: HashMap, // session_id -> page_id
+ pub pending_events: Vec,
+ pub default_context: Arc,
+ page_counter: u32,
+ pub preload_scripts: Vec<(String, String)>, // (identifier, source)
+ pub preload_counter: u32,
+ pub fetch_intercept: FetchInterceptState,
+ pub intercept_tx: Option>,
+}
+
+impl CdpContext {
+ pub fn new() -> Self {
+ Self::new_with_proxy(None)
+ }
+
+ pub fn new_with_proxy(proxy: Option) -> Self {
+ let default_context = Arc::new(BrowserContext::with_proxy("default".to_string(), proxy));
+ CdpContext {
+ pages: Vec::new(),
+ sessions: HashMap::new(),
+ pending_events: Vec::new(),
+ default_context,
+ page_counter: 0,
+ preload_scripts: Vec::new(),
+ preload_counter: 0,
+ fetch_intercept: FetchInterceptState::new(),
+ intercept_tx: None,
+ }
+ }
+
+ pub fn create_page(&mut self) -> String {
+ self.page_counter += 1;
+ let page_id = format!("page-{}", self.page_counter);
+ let mut page = Page::new(page_id.clone(), self.default_context.clone());
+ page.navigate_blank();
+ self.pages.push(page);
+ page_id
+ }
+
+ pub fn get_page(&self, id: &str) -> Option<&Page> {
+ self.pages.iter().find(|p| p.id == id)
+ }
+
+ pub fn get_page_mut(&mut self, id: &str) -> Option<&mut Page> {
+ self.pages.iter_mut().find(|p| p.id == id)
+ }
+
+ pub fn remove_page(&mut self, id: &str) {
+ self.pages.retain(|p| p.id != id);
+ self.sessions.retain(|_, v| v != id);
+ }
+
+ pub fn get_session_page(&self, session_id: &Option) -> Option<&Page> {
+ let page_id = session_id
+ .as_ref()
+ .and_then(|sid| self.sessions.get(sid))?;
+ self.get_page(page_id)
+ }
+
+ pub fn get_session_page_mut(&mut self, session_id: &Option) -> Option<&mut Page> {
+ let page_id = session_id
+ .as_ref()
+ .and_then(|sid| self.sessions.get(sid))
+ .cloned()?;
+
+ let target_has_js = self.pages.iter().any(|p| p.id == page_id && p.has_js());
+
+ if !target_has_js {
+ for page in &mut self.pages {
+ if page.id != page_id && page.has_js() {
+ page.suspend_js();
+ break;
+ }
+ }
+ if let Some(target) = self.pages.iter_mut().find(|p| p.id == page_id) {
+ target.resume_js();
+ }
+ }
+
+ self.get_page_mut(&page_id)
+ }
+}
+
+pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
+ let (domain, method) = match req.method.split_once('.') {
+ Some((d, m)) => (d, m),
+ None => {
+ return CdpResponse::error(
+ req.id,
+ -32601,
+ format!("Invalid method format: {}", req.method),
+ req.session_id.clone(),
+ );
+ }
+ };
+
+ let result = match domain {
+ "Target" => domains::target::handle(method, &req.params, ctx).await,
+ "Browser" => domains::browser::handle(method, &req.params).await,
+ "Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await,
+ "DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await,
+ "Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await,
+ "Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await,
+ "Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
+ "Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await,
+ "Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
+ "LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await,
+ "Emulation" | "Log" | "Performance" | "Security" | "CSS"
+ | "Accessibility" | "ServiceWorker" | "Inspector"
+ | "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" => {
+ Ok(json!({}))
+ }
+ _ => Err(format!("Unknown domain: {}", domain)),
+ };
+
+ match result {
+ Ok(value) => CdpResponse::success(req.id, value, req.session_id.clone()),
+ Err(msg) => {
+ tracing::warn!("CDP error for {}: {}", req.method, msg);
+ CdpResponse::error(req.id, -32601, msg, req.session_id.clone())
+ }
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/browser.rs b/crates/obscura-cdp/src/domains/browser.rs
new file mode 100644
index 0000000..c96d5cf
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/browser.rs
@@ -0,0 +1,31 @@
+use serde_json::{json, Value};
+
+pub async fn handle(method: &str, _params: &Value) -> Result {
+ match method {
+ "getVersion" => Ok(json!({
+ "protocolVersion": "1.3",
+ "product": "Obscura/0.1.0",
+ "revision": "0",
+ "userAgent": "Obscura/0.1.0 (Headless Browser)",
+ "jsVersion": "N/A",
+ })),
+ "close" => {
+ Ok(json!({}))
+ }
+ "getWindowForTarget" => Ok(json!({
+ "windowId": 1,
+ "bounds": {
+ "left": 0,
+ "top": 0,
+ "width": 1280,
+ "height": 720,
+ "windowState": "normal",
+ }
+ })),
+ "setDownloadBehavior" => Ok(json!({})),
+ "getWindowBounds" => Ok(json!({
+ "bounds": { "left": 0, "top": 0, "width": 1280, "height": 720, "windowState": "normal" }
+ })),
+ _ => Err(format!("Unknown Browser method: {}", method)),
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/dom.rs b/crates/obscura-cdp/src/domains/dom.rs
new file mode 100644
index 0000000..18e08dc
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/dom.rs
@@ -0,0 +1,204 @@
+use obscura_dom::{DomTree, NodeData, NodeId};
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "enable" => Ok(json!({})),
+ "getDocument" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(2);
+ page.with_dom(|dom| {
+ let node = serialize_node(dom, dom.document(), depth as u32, 0);
+ json!({ "root": node })
+ }).ok_or_else(|| "No DOM loaded".to_string())
+ }
+ "querySelector" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?;
+ let result = page.with_dom(|dom| {
+ dom.query_selector(selector).ok().flatten().map(|id| id.index()).unwrap_or(0)
+ }).unwrap_or(0);
+ Ok(json!({ "nodeId": result }))
+ }
+ "querySelectorAll" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?;
+ let ids = page.with_dom(|dom| {
+ dom.query_selector_all(selector).ok()
+ .map(|ids| ids.iter().map(|id| id.index() as u64).collect::>())
+ .unwrap_or_default()
+ }).unwrap_or_default();
+ Ok(json!({ "nodeIds": ids }))
+ }
+ "getOuterHTML" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ let node_id = params.get("nodeId").and_then(|v| v.as_u64())
+ .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64()))
+ .ok_or("nodeId required")?;
+ let html = page.with_dom(|dom| {
+ dom.outer_html(NodeId::new(node_id as u32))
+ }).unwrap_or_default();
+ Ok(json!({ "outerHTML": html }))
+ }
+ "describeNode" => {
+ let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
+ let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(0);
+
+ let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64())
+ .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64()))
+ {
+ nid
+ } else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) {
+ let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'");
+ let code = format!(
+ "(function() {{ var o = globalThis.__obscura_objects['{}']; if (!o) return -1; return (typeof o._nid === 'number') ? o._nid : -1; }})()",
+ escaped_oid
+ );
+ let result = page.evaluate(&code);
+ result.as_f64().map(|n| n as u64).unwrap_or(0)
+ } else {
+ return Err("nodeId or objectId required".to_string());
+ };
+
+ let node = page.with_dom(|dom| {
+ serialize_node(dom, NodeId::new(node_id as u32), depth as u32, 0)
+ }).unwrap_or(json!(null));
+ Ok(json!({ "node": node }))
+ }
+ "resolveNode" => {
+ let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
+ let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64())
+ .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64()))
+ {
+ nid
+ } else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) {
+ let code = format!(
+ "(function() {{ var o = globalThis.__obscura_objects['{}']; return (o && typeof o._nid === 'number') ? o._nid : -1; }})()",
+ oid
+ );
+ let result = page.evaluate(&code);
+ result.as_f64().map(|n| n as u64).unwrap_or(0)
+ } else {
+ return Err("nodeId or objectId required".to_string());
+ };
+
+ let js_code = format!(
+ "(function() {{\
+ var nid = {};\
+ var node = null;\
+ if (globalThis._cache && globalThis._cache.has(nid)) {{\
+ node = globalThis._cache.get(nid);\
+ }} else {{\
+ var t = +Deno.core.ops.op_dom('node_type', String(nid), '');\
+ if (t === 1) node = new Element(nid);\
+ else if (t === 9) node = globalThis.document;\
+ else node = new Node(nid);\
+ if (globalThis._cache) globalThis._cache.set(nid, node);\
+ }}\
+ return node;\
+ }})()",
+ node_id,
+ );
+
+ let info = if let Some(js) = &mut page.js {
+ match js.store_object_with_meta(&js_code) {
+ Ok(info) => info,
+ Err(_) => {
+ return Ok(json!({
+ "object": {
+ "type": "object",
+ "subtype": "node",
+ "className": "HTMLElement",
+ "objectId": format!("node-{}", node_id),
+ }
+ }));
+ }
+ }
+ } else {
+ return Err("No JS runtime".to_string());
+ };
+
+ Ok(json!({
+ "object": {
+ "type": "object",
+ "subtype": "node",
+ "className": if info.class_name.is_empty() { "HTMLElement".to_string() } else { info.class_name },
+ "description": info.description,
+ "objectId": info.object_id.unwrap_or_else(|| format!("node-{}", node_id)),
+ }
+ }))
+ }
+ "setAttributeValue" => Ok(json!({})),
+ "removeNode" => Ok(json!({})),
+ "getBoxModel" => {
+ Ok(json!({
+ "model": {
+ "content": [8,8, 108,8, 108,28, 8,28],
+ "padding": [8,8, 108,8, 108,28, 8,28],
+ "border": [8,8, 108,8, 108,28, 8,28],
+ "margin": [0,0, 116,0, 116,36, 0,36],
+ "width": 100, "height": 20,
+ }
+ }))
+ }
+ _ => Err(format!("Unknown DOM method: {}", method)),
+ }
+}
+
+fn serialize_node(dom: &DomTree, node_id: NodeId, max_depth: u32, current_depth: u32) -> Value {
+ let node = match dom.get_node(node_id) {
+ Some(n) => n,
+ None => return json!(null),
+ };
+ let children_ids = dom.children(node_id);
+ let child_count = children_ids.len();
+ let mut result = json!({ "nodeId": node_id.index(), "backendNodeId": node_id.index(), "childNodeCount": child_count });
+
+ match &node.data {
+ NodeData::Document => {
+ result["nodeType"] = json!(9); result["nodeName"] = json!("#document");
+ result["localName"] = json!(""); result["nodeValue"] = json!("");
+ result["documentURL"] = json!(""); result["baseURL"] = json!(""); result["xmlVersion"] = json!("");
+ }
+ NodeData::Doctype { name, public_id, system_id } => {
+ result["nodeType"] = json!(10); result["nodeName"] = json!(name);
+ result["localName"] = json!(""); result["nodeValue"] = json!("");
+ result["publicId"] = json!(public_id); result["systemId"] = json!(system_id);
+ }
+ NodeData::Element { name, attrs, .. } => {
+ result["nodeType"] = json!(1);
+ result["nodeName"] = json!(name.local.as_ref().to_ascii_uppercase());
+ result["localName"] = json!(name.local.as_ref());
+ result["nodeValue"] = json!("");
+ let cdp_attrs: Vec = attrs.iter()
+ .flat_map(|a| vec![a.name.local.to_string(), a.value.clone()]).collect();
+ result["attributes"] = json!(cdp_attrs);
+ }
+ NodeData::Text { contents } => {
+ result["nodeType"] = json!(3); result["nodeName"] = json!("#text");
+ result["localName"] = json!(""); result["nodeValue"] = json!(contents);
+ }
+ NodeData::Comment { contents } => {
+ result["nodeType"] = json!(8); result["nodeName"] = json!("#comment");
+ result["localName"] = json!(""); result["nodeValue"] = json!(contents);
+ }
+ NodeData::ProcessingInstruction { target, data } => {
+ result["nodeType"] = json!(7); result["nodeName"] = json!(target);
+ result["localName"] = json!(""); result["nodeValue"] = json!(data);
+ }
+ }
+
+ if current_depth < max_depth && !children_ids.is_empty() {
+ let children: Vec = children_ids.iter()
+ .map(|&cid| serialize_node(dom, cid, max_depth, current_depth + 1)).collect();
+ result["children"] = json!(children);
+ }
+ result
+}
diff --git a/crates/obscura-cdp/src/domains/fetch.rs b/crates/obscura-cdp/src/domains/fetch.rs
new file mode 100644
index 0000000..4096856
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/fetch.rs
@@ -0,0 +1,189 @@
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use serde_json::{json, Value};
+use tokio::sync::Mutex;
+
+use crate::dispatch::CdpContext;
+use crate::types::CdpEvent;
+
+pub struct PausedRequest {
+ pub request_id: String,
+ pub url: String,
+ pub method: String,
+ pub headers: HashMap,
+ pub resource_type: String,
+ pub resolver: tokio::sync::oneshot::Sender,
+}
+
+pub enum FetchResolution {
+ Continue {
+ url: Option,
+ method: Option,
+ headers: Option>,
+ post_data: Option,
+ },
+ Fulfill {
+ status: u16,
+ headers: Vec<(String, String)>,
+ body: String,
+ },
+ Fail {
+ reason: String,
+ },
+}
+
+pub struct FetchInterceptState {
+ pub enabled: bool,
+ pub patterns: Vec,
+ pub paused: HashMap,
+ request_counter: u64,
+}
+
+impl FetchInterceptState {
+ pub fn new() -> Self {
+ FetchInterceptState {
+ enabled: false,
+ patterns: Vec::new(),
+ paused: HashMap::new(),
+ request_counter: 0,
+ }
+ }
+
+ pub fn next_request_id(&mut self) -> String {
+ self.request_counter += 1;
+ format!("interception-{}", self.request_counter)
+ }
+}
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "enable" => {
+ let patterns = params
+ .get("patterns")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|p| {
+ p.get("urlPattern")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ })
+ .collect::>()
+ })
+ .unwrap_or_else(|| vec!["*".to_string()]);
+
+ ctx.fetch_intercept.enabled = true;
+ ctx.fetch_intercept.patterns = patterns.clone();
+ let tx_clone = ctx.intercept_tx.clone();
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ page.intercept_enabled = false;
+ page.intercept_block_patterns.clear();
+ if let Some(tx) = tx_clone {
+ page.set_intercept_tx(tx);
+ }
+ }
+
+ tracing::info!("Fetch interception enabled");
+ Ok(json!({}))
+ }
+ "disable" => {
+ ctx.fetch_intercept.enabled = false;
+ ctx.fetch_intercept.patterns.clear();
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ page.intercept_enabled = false;
+ page.intercept_block_patterns.clear();
+ }
+ let paused: Vec<_> = ctx.fetch_intercept.paused.drain().collect();
+ for (_, req) in paused {
+ let _ = req.resolver.send(FetchResolution::Continue {
+ url: None,
+ method: None,
+ headers: None,
+ post_data: None,
+ });
+ }
+ Ok(json!({}))
+ }
+ "continueRequest" => {
+ let request_id = params
+ .get("requestId")
+ .and_then(|v| v.as_str())
+ .ok_or("requestId required")?;
+
+ if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
+ let _ = paused.resolver.send(FetchResolution::Continue {
+ url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ headers: None,
+ post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ });
+ }
+ Ok(json!({}))
+ }
+ "fulfillRequest" => {
+ let request_id = params
+ .get("requestId")
+ .and_then(|v| v.as_str())
+ .ok_or("requestId required")?;
+
+ let status = params
+ .get("responseCode")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(200) as u16;
+ let headers: HashMap = params
+ .get("responseHeaders")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|h| {
+ let name = h.get("name")?.as_str()?.to_string();
+ let value = h.get("value")?.as_str()?.to_string();
+ Some((name, value))
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ let body = params
+ .get("body")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
+ let _ = paused.resolver.send(FetchResolution::Fulfill {
+ status,
+ headers: headers.into_iter().collect(),
+ body,
+ });
+ }
+ Ok(json!({}))
+ }
+ "failRequest" => {
+ let request_id = params
+ .get("requestId")
+ .and_then(|v| v.as_str())
+ .ok_or("requestId required")?;
+
+ let reason = params
+ .get("errorReason")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Failed")
+ .to_string();
+
+ if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
+ let _ = paused.resolver.send(FetchResolution::Fail { reason });
+ }
+ Ok(json!({}))
+ }
+ "getResponseBody" => {
+ Ok(json!({ "body": "", "base64Encoded": false }))
+ }
+ _ => Err(format!("Unknown Fetch method: {}", method)),
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/input.rs b/crates/obscura-cdp/src/domains/input.rs
new file mode 100644
index 0000000..ace79f6
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/input.rs
@@ -0,0 +1,155 @@
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "dispatchMouseEvent" => {
+ let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
+ let x = params.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
+ let y = params.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
+ let button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left");
+ let click_count = params.get("clickCount").and_then(|v| v.as_u64()).unwrap_or(1);
+
+ if event_type == "mousePressed" {
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ let code = format!(
+ "(function() {{\
+ var target = globalThis.__obscura_click_target || document.activeElement || document.body;\
+ if (!target) return;\
+ var evt = new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
+ target.dispatchEvent(evt);\
+ var click = new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
+ var cancelled = !target.dispatchEvent(click);\
+ if (!cancelled) {{\
+ var link = target.closest ? target.closest('a[href]') : null;\
+ if (!link && target.tagName === 'A' && target.getAttribute('href')) link = target;\
+ if (link) {{\
+ var href = link.getAttribute('href');\
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\
+ location.assign(href);\
+ }}\
+ }}\
+ }}\
+ }})()",
+ x = x, y = y,
+ );
+ page.evaluate(&code);
+ }
+ } else if event_type == "mouseReleased" {
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ let code = format!(
+ "(function() {{\
+ var target = globalThis.__obscura_click_target || document.activeElement || document.body;\
+ if (!target) return;\
+ var evt = new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}});\
+ target.dispatchEvent(evt);\
+ }})()",
+ x = x, y = y,
+ );
+ page.evaluate(&code);
+ }
+ }
+
+ Ok(json!({}))
+ }
+ "dispatchKeyEvent" => {
+ let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
+ let key = params.get("key").and_then(|v| v.as_str()).unwrap_or("");
+ let code = params.get("code").and_then(|v| v.as_str()).unwrap_or("");
+ let text = params.get("text").and_then(|v| v.as_str()).unwrap_or("");
+
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ match event_type {
+ "keyDown" | "rawKeyDown" => {
+ let js = format!(
+ "(function() {{\
+ var target = document.activeElement || document.body;\
+ var evt = new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:'{key}',code:'{code}'}});\
+ target.dispatchEvent(evt);\
+ }})()",
+ key = key.replace('\'', "\\'"),
+ code = code.replace('\'', "\\'"),
+ );
+ page.evaluate(&js);
+
+ if !text.is_empty() && text != "\r" && text != "\n" {
+ let js = format!(
+ "(function() {{\
+ var target = document.activeElement;\
+ if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\
+ target.value = (target.value || '') + '{text}';\
+ target.dispatchEvent(new Event('input', {{bubbles:true}}));\
+ }}\
+ }})()",
+ text = text.replace('\'', "\\'").replace('\\', "\\\\"),
+ );
+ page.evaluate(&js);
+ }
+
+ if key == "Enter" {
+ let js = "(function() {\
+ var target = document.activeElement;\
+ if (target) {\
+ target.dispatchEvent(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'}));\
+ var form = target.form || target.closest && target.closest('form');\
+ if (form && typeof form.submit === 'function') form.submit();\
+ }\
+ })()";
+ page.evaluate(js);
+ }
+
+ if key == "Backspace" {
+ let js = "(function() {\
+ var target = document.activeElement;\
+ if (target && (target.localName === 'input' || target.localName === 'textarea')) {\
+ target.value = target.value.slice(0, -1);\
+ target.dispatchEvent(new Event('input', {bubbles:true}));\
+ }\
+ })()";
+ page.evaluate(js);
+ }
+ }
+ "keyUp" => {
+ let js = format!(
+ "(function() {{\
+ var target = document.activeElement || document.body;\
+ var evt = new KeyboardEvent('keyup', {{bubbles:true,key:'{key}',code:'{code}'}});\
+ target.dispatchEvent(evt);\
+ }})()",
+ key = key.replace('\'', "\\'"),
+ code = code.replace('\'', "\\'"),
+ );
+ page.evaluate(&js);
+ }
+ "char" => {
+ if !text.is_empty() {
+ let js = format!(
+ "(function() {{\
+ var target = document.activeElement;\
+ if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\
+ target.value = (target.value || '') + '{text}';\
+ target.dispatchEvent(new Event('input', {{bubbles:true}}));\
+ }}\
+ }})()",
+ text = text.replace('\'', "\\'").replace('\\', "\\\\"),
+ );
+ page.evaluate(&js);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(json!({}))
+ }
+ "dispatchTouchEvent" => Ok(json!({})),
+ "setIgnoreInputEvents" => Ok(json!({})),
+ _ => Err(format!("Unknown Input method: {}", method)),
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/lp.rs b/crates/obscura-cdp/src/domains/lp.rs
new file mode 100644
index 0000000..518df67
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/lp.rs
@@ -0,0 +1,85 @@
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+
+pub async fn handle(
+ method: &str,
+ _params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "getMarkdown" => {
+ let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
+ let code = r#"
+(function() {
+ function toMd(el, depth) {
+ if (!el) return '';
+ var out = '';
+ if (el.nodeType === 3) return el.textContent || '';
+ if (el.nodeType !== 1) return '';
+ var tag = (el.tagName || '').toLowerCase();
+ var children = '';
+ var cn = el.childNodes || [];
+ for (var i = 0; i < cn.length; i++) children += toMd(cn[i], depth);
+ children = children.replace(/\n{3,}/g, '\n\n');
+ switch(tag) {
+ case 'h1': return '\n# ' + children.trim() + '\n\n';
+ case 'h2': return '\n## ' + children.trim() + '\n\n';
+ case 'h3': return '\n### ' + children.trim() + '\n\n';
+ case 'h4': return '\n#### ' + children.trim() + '\n\n';
+ case 'h5': return '\n##### ' + children.trim() + '\n\n';
+ case 'h6': return '\n###### ' + children.trim() + '\n\n';
+ case 'p': return '\n' + children.trim() + '\n\n';
+ case 'br': return '\n';
+ case 'hr': return '\n---\n\n';
+ case 'strong': case 'b': return '**' + children + '**';
+ case 'em': case 'i': return '*' + children + '*';
+ case 'code': return '`' + children + '`';
+ case 'pre': return '\n```\n' + children + '\n```\n\n';
+ case 'blockquote': return '\n> ' + children.trim().replace(/\n/g, '\n> ') + '\n\n';
+ case 'a':
+ var href = el.getAttribute('href') || '';
+ if (href && children.trim()) return '[' + children.trim() + '](' + href + ')';
+ return children;
+ case 'img':
+ var src = el.getAttribute('src') || '';
+ var alt = el.getAttribute('alt') || '';
+ return '';
+ case 'ul': case 'ol':
+ return '\n' + children + '\n';
+ case 'li':
+ var parent = el.parentNode;
+ var isOrdered = parent && parent.tagName && parent.tagName.toLowerCase() === 'ol';
+ var bullet = isOrdered ? '1. ' : '- ';
+ return bullet + children.trim() + '\n';
+ case 'table': return '\n' + children + '\n';
+ case 'thead': case 'tbody': case 'tfoot': return children;
+ case 'tr':
+ var cells = [];
+ var tds = el.childNodes || [];
+ for (var j = 0; j < tds.length; j++) {
+ if (tds[j].nodeType === 1) cells.push(toMd(tds[j], depth).trim());
+ }
+ return '| ' + cells.join(' | ') + ' |\n';
+ case 'th': case 'td': return children;
+ case 'script': case 'style': case 'noscript': case 'link': case 'meta': return '';
+ case 'div': case 'section': case 'article': case 'main': case 'aside': case 'nav': case 'header': case 'footer':
+ return '\n' + children;
+ case 'span': return children;
+ default: return children;
+ }
+ }
+ var body = document.body || document.documentElement;
+ var md = toMd(body, 0);
+ md = md.replace(/\n{3,}/g, '\n\n').trim();
+ return md;
+})()
+"#;
+ let result = page.evaluate(code);
+ let markdown = result.as_str().unwrap_or("").to_string();
+ Ok(json!({ "markdown": markdown }))
+ }
+ _ => Err(format!("Unknown LP method: {}", method)),
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/mod.rs b/crates/obscura-cdp/src/domains/mod.rs
new file mode 100644
index 0000000..4ed300d
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/mod.rs
@@ -0,0 +1,10 @@
+pub mod target;
+pub mod browser;
+pub mod page;
+pub mod dom;
+pub mod runtime;
+pub mod network;
+pub mod fetch;
+pub mod input;
+pub mod storage;
+pub mod lp;
diff --git a/crates/obscura-cdp/src/domains/network.rs b/crates/obscura-cdp/src/domains/network.rs
new file mode 100644
index 0000000..e3b6cd0
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/network.rs
@@ -0,0 +1,81 @@
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "enable" => Ok(json!({})),
+ "setExtraHTTPHeaders" => {
+ let headers = params.get("headers").and_then(|v| v.as_object());
+ if let Some(page) = ctx.get_session_page(session_id) {
+ if let Some(headers) = headers {
+ let header_map: std::collections::HashMap = headers
+ .iter()
+ .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
+ .collect();
+ page.http_client.set_extra_headers(header_map).await;
+ }
+ }
+ Ok(json!({}))
+ }
+ "setUserAgentOverride" => {
+ let ua = params.get("userAgent").and_then(|v| v.as_str()).unwrap_or("");
+ if let Some(page) = ctx.get_session_page(session_id) {
+ page.http_client.set_user_agent(ua).await;
+ }
+ Ok(json!({}))
+ }
+ "getCookies" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ let cookies = page.context.cookie_jar.get_all_cookies();
+ let cdp_cookies: Vec = cookies.iter().map(|c| {
+ json!({
+ "name": c.name,
+ "value": c.value,
+ "domain": c.domain,
+ "path": c.path,
+ "expires": -1,
+ "size": c.name.len() + c.value.len(),
+ "httpOnly": c.http_only,
+ "secure": c.secure,
+ "session": true,
+ "sameParty": false,
+ "sourceScheme": "Secure",
+ "sourcePort": 443,
+ })
+ }).collect();
+ Ok(json!({ "cookies": cdp_cookies }))
+ }
+ "setCookies" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) {
+ let cookie_infos: Vec = cookies.iter().filter_map(|c| {
+ Some(obscura_net::CookieInfo {
+ name: c.get("name")?.as_str()?.to_string(),
+ value: c.get("value")?.as_str()?.to_string(),
+ domain: c.get("domain").and_then(|v| v.as_str()).unwrap_or("").to_string(),
+ path: c.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string(),
+ secure: c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false),
+ http_only: c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false),
+ })
+ }).collect();
+ page.context.cookie_jar.set_cookies_from_cdp(cookie_infos);
+ }
+ Ok(json!({}))
+ }
+ "clearBrowserCookies" => {
+ if let Some(page) = ctx.get_session_page(session_id) {
+ page.context.cookie_jar.clear();
+ }
+ Ok(json!({}))
+ }
+ "setCacheDisabled" => Ok(json!({})),
+ "setRequestInterception" => Ok(json!({})),
+ _ => Err(format!("Unknown Network method: {}", method)),
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs
new file mode 100644
index 0000000..e33bb05
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/page.rs
@@ -0,0 +1,221 @@
+use obscura_browser::lifecycle::WaitUntil;
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+use crate::types::CdpEvent;
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "enable" => Ok(json!({})),
+ "navigate" => {
+ let url = params.get("url").and_then(|v| v.as_str())
+ .ok_or("url required")?;
+
+ let wait_until = params.get("waitUntil")
+ .and_then(|v| {
+ if let Some(s) = v.as_str() {
+ Some(WaitUntil::from_str(s))
+ } else if let Some(arr) = v.as_array() {
+ arr.iter()
+ .filter_map(|item| item.as_str())
+ .map(WaitUntil::from_str)
+ .max_by_key(|w| match w {
+ WaitUntil::DomContentLoaded => 0,
+ WaitUntil::Load => 1,
+ WaitUntil::NetworkIdle2 => 2,
+ WaitUntil::NetworkIdle0 => 3,
+ })
+ } else {
+ None
+ }
+ })
+ .unwrap_or(WaitUntil::Load);
+
+ let preload_scripts: Vec = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect();
+
+ let (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) = {
+ let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
+ let frame_id = page.frame_id.clone();
+ let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
+
+ let nav_method = params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET");
+ let nav_body = params.get("__body").and_then(|v| v.as_str()).unwrap_or("");
+ if nav_method == "POST" && !nav_body.is_empty() {
+ page.navigate_with_wait_post(url, wait_until, nav_method, nav_body).await.map_err(|e| e.to_string())?;
+ } else {
+ page.navigate_with_wait(url, wait_until).await.map_err(|e| e.to_string())?;
+ }
+
+ for source in &preload_scripts {
+ if let Err(e) = page.execute_preload_script(source) {
+ tracing::debug!("Preload script error: {}", e);
+ }
+ }
+
+ let reached_network_idle = page.lifecycle.is_network_idle();
+ let network_events: Vec<_> = page.network_events.drain(..).collect();
+ let page_url = page.url_string();
+ let page_id = page.id.clone();
+ (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle)
+ };
+
+ let es = session_id.clone();
+ let ts = timestamp();
+
+ let phase1 = vec![
+ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
+ CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
+ CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
+ CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
+ CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 100, "origin": page_url, "name": "__puppeteer_utility_world__24.40.0", "uniqueId": format!("ctx-isolated-nav-{}", page_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}), session_id: es.clone() },
+ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() },
+ ];
+ ctx.pending_events.extend(phase1);
+
+ if ctx.fetch_intercept.enabled {
+ for net_event in &network_events {
+ ctx.pending_events.push(CdpEvent {
+ method: "Fetch.requestPaused".into(),
+ params: json!({
+ "requestId": net_event.request_id,
+ "request": {
+ "url": net_event.url,
+ "method": net_event.method,
+ "headers": net_event.headers,
+ },
+ "frameId": frame_id,
+ "resourceType": net_event.resource_type,
+ "networkId": net_event.request_id,
+ }),
+ session_id: es.clone(),
+ });
+ }
+ }
+
+ for net_event in &network_events {
+ ctx.pending_events.push(CdpEvent {
+ method: "Network.requestWillBeSent".into(),
+ params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
+ session_id: es.clone(),
+ });
+ ctx.pending_events.push(CdpEvent {
+ method: "Network.responseReceived".into(),
+ params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}),
+ session_id: es.clone(),
+ });
+ ctx.pending_events.push(CdpEvent {
+ method: "Network.loadingFinished".into(),
+ params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}),
+ session_id: es.clone(),
+ });
+ }
+
+ let mut phase3 = vec![
+ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
+ CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
+ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
+ CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
+ ];
+ if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) {
+ let idle_ts = timestamp();
+ phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() });
+ }
+ phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es });
+ ctx.pending_events.extend(phase3);
+
+ Ok(json!({
+ "frameId": frame_id,
+ "loaderId": loader_id,
+ }))
+ }
+ "getFrameTree" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
+ Ok(json!({
+ "frameTree": {
+ "frame": {
+ "id": page.frame_id,
+ "loaderId": "initial-loader",
+ "url": page.url_string(),
+ "domainAndRegistry": "",
+ "securityOrigin": page.url_string(),
+ "mimeType": "text/html",
+ "adFrameStatus": { "adFrameType": "none" },
+ },
+ "childFrames": [],
+ }
+ }))
+ }
+ "createIsolatedWorld" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
+ let frame_id_param = params.get("frameId").and_then(|v| v.as_str())
+ .unwrap_or(&page.frame_id).to_string();
+ let world_name = params.get("worldName").and_then(|v| v.as_str())
+ .unwrap_or("").to_string();
+ let page_url = page.url_string();
+ let page_id = page.id.clone();
+ let context_id = 100;
+
+ ctx.pending_events.push(CdpEvent {
+ method: "Runtime.executionContextCreated".to_string(),
+ params: json!({
+ "context": {
+ "id": context_id,
+ "origin": page_url,
+ "name": world_name,
+ "uniqueId": format!("ctx-isolated-{}", page_id),
+ "auxData": {
+ "isDefault": false,
+ "type": "isolated",
+ "frameId": frame_id_param,
+ }
+ }
+ }),
+ session_id: session_id.clone(),
+ });
+
+ Ok(json!({ "executionContextId": context_id }))
+ }
+ "setLifecycleEventsEnabled" => Ok(json!({})),
+ "addScriptToEvaluateOnNewDocument" => {
+ let source = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
+ ctx.preload_counter += 1;
+ let identifier = format!("{}", ctx.preload_counter);
+ if !source.is_empty() {
+ ctx.preload_scripts.push((identifier.clone(), source.to_string()));
+ }
+ Ok(json!({ "identifier": identifier }))
+ }
+ "removeScriptToEvaluateOnNewDocument" => {
+ let identifier = params.get("identifier").and_then(|v| v.as_str()).unwrap_or("");
+ ctx.preload_scripts.retain(|(id, _)| id != identifier);
+ Ok(json!({}))
+ }
+ "setInterceptFileChooserDialog" => Ok(json!({})),
+ "getNavigationHistory" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
+ Ok(json!({
+ "currentIndex": 0,
+ "entries": [{
+ "id": 0,
+ "url": page.url_string(),
+ "userTypedURL": page.url_string(),
+ "title": page.title,
+ "transitionType": "typed",
+ }]
+ }))
+ }
+ _ => Err(format!("Unknown Page method: {}", method)),
+ }
+}
+
+fn timestamp() -> f64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs_f64()
+}
diff --git a/crates/obscura-cdp/src/domains/runtime.rs b/crates/obscura-cdp/src/domains/runtime.rs
new file mode 100644
index 0000000..5feb664
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/runtime.rs
@@ -0,0 +1,213 @@
+use obscura_js::runtime::RemoteObjectInfo;
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ session_id: &Option,
+) -> Result {
+ match method {
+ "enable" => {
+ let page = ctx.get_session_page(session_id).ok_or("No page")?;
+ let event = crate::types::CdpEvent {
+ method: "Runtime.executionContextCreated".to_string(),
+ params: json!({
+ "context": {
+ "id": 1,
+ "origin": page.url_string(),
+ "name": "",
+ "uniqueId": format!("ctx-{}", page.id),
+ "auxData": {
+ "isDefault": true,
+ "type": "default",
+ "frameId": page.frame_id,
+ }
+ }
+ }),
+ session_id: session_id.clone(),
+ };
+ ctx.pending_events.push(event);
+ Ok(json!({}))
+ }
+ "evaluate" => {
+ let expression = params
+ .get("expression")
+ .and_then(|v| v.as_str())
+ .ok_or("expression required")?;
+ let return_by_value = params
+ .get("returnByValue")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ let page = ctx
+ .get_session_page_mut(session_id)
+ .ok_or("No page")?;
+ let info = page.evaluate_for_cdp(expression, return_by_value);
+
+ Ok(json!({ "result": remote_object_from_info(&info) }))
+ }
+ "callFunctionOn" => {
+ let function_declaration = params
+ .get("functionDeclaration")
+ .and_then(|v| v.as_str())
+ .unwrap_or("() => undefined");
+ let return_by_value = params
+ .get("returnByValue")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let await_promise = params
+ .get("awaitPromise")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let object_id = params.get("objectId").and_then(|v| v.as_str());
+ let arguments = params
+ .get("arguments")
+ .and_then(|v| v.as_array())
+ .map(|a| a.to_vec())
+ .unwrap_or_default();
+
+ let page = ctx
+ .get_session_page_mut(session_id)
+ .ok_or("No page")?;
+ let info =
+ page.call_function_on_for_cdp(function_declaration, object_id, &arguments, return_by_value, await_promise).await;
+
+ Ok(json!({ "result": remote_object_from_info(&info) }))
+ }
+ "getProperties" => {
+ let object_id = params.get("objectId").and_then(|v| v.as_str());
+ if let Some(oid) = object_id {
+ let page = ctx
+ .get_session_page_mut(session_id)
+ .ok_or("No page")?;
+ let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'");
+ let code = format!(
+ "(function() {{\
+ var obj = globalThis.__obscura_objects['{oid}'];\
+ if (!obj || typeof obj !== 'object') return [];\
+ return Object.keys(obj).map(function(k) {{\
+ var v = obj[k];\
+ return {{ name: k, value: v, type: typeof v }};\
+ }});\
+ }})()",
+ oid = escaped_oid,
+ );
+ let result = page.evaluate(&code);
+ if let serde_json::Value::Array(props) = result {
+ let descriptors: Vec = props
+ .iter()
+ .map(|p| {
+ let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
+ let value = p.get("value").unwrap_or(&Value::Null);
+ let prop_type =
+ p.get("type").and_then(|v| v.as_str()).unwrap_or("undefined");
+ let mut remote = json!({
+ "type": prop_type,
+ });
+ match value {
+ Value::Null => {
+ remote["type"] = json!("object");
+ remote["subtype"] = json!("null");
+ remote["value"] = json!(null);
+ }
+ Value::String(s) => {
+ remote["type"] = json!("string");
+ remote["value"] = json!(s);
+ }
+ Value::Number(n) => {
+ remote["type"] = json!("number");
+ remote["value"] = json!(n);
+ }
+ Value::Bool(b) => {
+ remote["type"] = json!("boolean");
+ remote["value"] = json!(b);
+ }
+ _ => {
+ remote["value"] = value.clone();
+ }
+ }
+ json!({
+ "name": name,
+ "value": remote,
+ "configurable": true,
+ "enumerable": true,
+ "writable": true,
+ "isOwn": true,
+ })
+ })
+ .collect();
+ Ok(json!({ "result": descriptors, "internalProperties": [] }))
+ } else {
+ Ok(json!({ "result": [], "internalProperties": [] }))
+ }
+ } else {
+ Ok(json!({ "result": [], "internalProperties": [] }))
+ }
+ }
+ "releaseObject" => {
+ if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) {
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ page.release_object(oid);
+ }
+ }
+ Ok(json!({}))
+ }
+ "releaseObjectGroup" => {
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ page.release_object_group();
+ }
+ Ok(json!({}))
+ }
+ "addBinding" => {
+ let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
+ if !name.is_empty() {
+ if name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
+ && !name.chars().next().unwrap_or('0').is_ascii_digit() {
+ if let Some(page) = ctx.get_session_page_mut(session_id) {
+ let code = format!(
+ "if (typeof globalThis.{name} === 'undefined') {{\
+ globalThis.{name} = function() {{ return null; }};\
+ }}",
+ name = name,
+ );
+ page.evaluate(&code);
+ }
+ }
+ }
+ Ok(json!({}))
+ }
+ "runIfWaitingForDebugger" => Ok(json!({})),
+ "getExceptionDetails" => Ok(json!({ "exceptionDetails": null })),
+ "discardConsoleEntries" => Ok(json!({})),
+ _ => Err(format!("Unknown Runtime method: {}", method)),
+ }
+}
+
+fn remote_object_from_info(info: &RemoteObjectInfo) -> Value {
+ let mut obj = json!({ "type": info.js_type });
+
+ if let Some(ref subtype) = info.subtype {
+ obj["subtype"] = json!(subtype);
+ }
+
+ if !info.class_name.is_empty() {
+ obj["className"] = json!(info.class_name);
+ }
+
+ if !info.description.is_empty() {
+ obj["description"] = json!(info.description);
+ }
+
+ if let Some(ref oid) = info.object_id {
+ obj["objectId"] = json!(oid);
+ }
+
+ if let Some(ref value) = info.value {
+ obj["value"] = value.clone();
+ }
+
+ obj
+}
diff --git a/crates/obscura-cdp/src/domains/storage.rs b/crates/obscura-cdp/src/domains/storage.rs
new file mode 100644
index 0000000..01e1ed9
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/storage.rs
@@ -0,0 +1,117 @@
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+
+pub async fn handle(
+ method: &str,
+ params: &Value,
+ ctx: &mut CdpContext,
+ _session_id: &Option,
+) -> Result {
+ match method {
+ "getCookies" => {
+ let cookies = ctx.default_context.cookie_jar.get_all_cookies();
+ let cdp_cookies: Vec = cookies
+ .iter()
+ .map(|c| {
+ json!({
+ "name": c.name,
+ "value": c.value,
+ "domain": c.domain,
+ "path": c.path,
+ "expires": -1,
+ "size": c.name.len() + c.value.len(),
+ "httpOnly": c.http_only,
+ "secure": c.secure,
+ "session": true,
+ "priority": "Medium",
+ "sameParty": false,
+ "sourceScheme": if c.secure { "Secure" } else { "NonSecure" },
+ "sourcePort": 80,
+ })
+ })
+ .collect();
+ Ok(json!({ "cookies": cdp_cookies }))
+ }
+ "setCookies" => {
+ if let Some(cookies) = params.get("cookies").and_then(|v| v.as_array()) {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs_f64();
+
+ for c in cookies {
+ let name = match c.get("name").and_then(|v| v.as_str()) {
+ Some(n) => n.to_string(),
+ None => continue,
+ };
+ let value = c.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
+
+ let domain = c
+ .get("domain")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .or_else(|| {
+ c.get("url")
+ .and_then(|v| v.as_str())
+ .and_then(|u| url::Url::parse(u).ok())
+ .and_then(|u| u.host_str().map(|h| h.to_string()))
+ })
+ .unwrap_or_default();
+
+ let expires = c.get("expires").and_then(|v| v.as_f64());
+ if let Some(exp) = expires {
+ if exp > 0.0 && exp < now {
+ ctx.default_context.cookie_jar.delete_cookie(&name, &domain);
+ continue;
+ }
+ }
+
+ let path = c
+ .get("path")
+ .and_then(|v| v.as_str())
+ .unwrap_or("/")
+ .to_string();
+ let secure =
+ c.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
+ let http_only =
+ c.get("httpOnly").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ ctx.default_context.cookie_jar.set_cookies_from_cdp(vec![
+ obscura_net::CookieInfo {
+ name,
+ value,
+ domain,
+ path,
+ secure,
+ http_only,
+ },
+ ]);
+ }
+ }
+ Ok(json!({}))
+ }
+ "deleteCookies" => {
+ let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
+ let domain_from_url = params
+ .get("url")
+ .and_then(|v| v.as_str())
+ .and_then(|u| url::Url::parse(u).ok())
+ .and_then(|u| u.host_str().map(|h| h.to_string()));
+ let domain = params
+ .get("domain")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .or(domain_from_url)
+ .unwrap_or_default();
+
+ if !name.is_empty() {
+ ctx.default_context
+ .cookie_jar
+ .delete_cookie(name, &domain);
+ }
+ Ok(json!({}))
+ }
+ _ => Ok(json!({})),
+ }
+}
diff --git a/crates/obscura-cdp/src/domains/target.rs b/crates/obscura-cdp/src/domains/target.rs
new file mode 100644
index 0000000..bd9d1a2
--- /dev/null
+++ b/crates/obscura-cdp/src/domains/target.rs
@@ -0,0 +1,196 @@
+use serde_json::{json, Value};
+
+use crate::dispatch::CdpContext;
+use crate::types::CdpEvent;
+
+pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Result {
+ match method {
+ "setDiscoverTargets" => {
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.targetCreated",
+ json!({
+ "targetInfo": {
+ "targetId": "browser",
+ "type": "browser",
+ "title": "",
+ "url": "",
+ "attached": true,
+ "browserContextId": "",
+ }
+ }),
+ ));
+ for page in &ctx.pages {
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.targetCreated",
+ json!({
+ "targetInfo": {
+ "targetId": page.id,
+ "type": "page",
+ "title": page.title,
+ "url": page.url_string(),
+ "attached": false,
+ "browserContextId": page.context.id,
+ }
+ }),
+ ));
+ }
+ Ok(json!({}))
+ }
+ "getTargets" => {
+ let targets: Vec = ctx
+ .pages
+ .iter()
+ .map(|page| {
+ json!({
+ "targetId": page.id,
+ "type": "page",
+ "title": page.title,
+ "url": page.url_string(),
+ "attached": true,
+ "browserContextId": page.context.id,
+ })
+ })
+ .collect();
+ Ok(json!({ "targetInfos": targets }))
+ }
+ "createTarget" => {
+ let url = params.get("url").and_then(|v| v.as_str()).unwrap_or("about:blank");
+ let page_id = ctx.create_page();
+ let session_id = format!("{}-session", page_id);
+
+ if let Some(page) = ctx.get_page_mut(&page_id) {
+ if url == "about:blank" || url.is_empty() {
+ page.navigate_blank();
+ } else {
+ let _ = page.navigate(url).await;
+ }
+ }
+
+ ctx.sessions.insert(session_id.clone(), page_id.clone());
+
+ if let Some(page) = ctx.get_page(&page_id) {
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.targetCreated",
+ json!({
+ "targetInfo": {
+ "targetId": page_id,
+ "type": "page",
+ "title": page.title,
+ "url": page.url_string(),
+ "attached": false,
+ "browserContextId": page.context.id,
+ }
+ }),
+ ));
+ }
+
+ if let Some(page) = ctx.get_page(&page_id) {
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.attachedToTarget",
+ json!({
+ "sessionId": session_id,
+ "targetInfo": {
+ "targetId": page_id,
+ "type": "page",
+ "title": page.title,
+ "url": page.url_string(),
+ "attached": true,
+ "browserContextId": page.context.id,
+ },
+ "waitingForDebugger": false,
+ }),
+ ));
+ }
+
+ Ok(json!({ "targetId": page_id }))
+ }
+ "attachToTarget" => {
+ let target_id = params.get("targetId").and_then(|v| v.as_str())
+ .ok_or("targetId required")?;
+ let session_id = format!("{}-session", target_id);
+ ctx.sessions.insert(session_id.clone(), target_id.to_string());
+
+ if let Some(page) = ctx.get_page(target_id) {
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.attachedToTarget",
+ json!({
+ "sessionId": session_id,
+ "targetInfo": {
+ "targetId": target_id,
+ "type": "page",
+ "title": page.title,
+ "url": page.url_string(),
+ "attached": true,
+ "browserContextId": page.context.id,
+ },
+ "waitingForDebugger": false,
+ }),
+ ));
+ }
+
+ Ok(json!({ "sessionId": session_id }))
+ }
+ "closeTarget" => {
+ let target_id = params.get("targetId").and_then(|v| v.as_str())
+ .ok_or("targetId required")?;
+ let session_id = format!("{}-session", target_id);
+
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.detachedFromTarget",
+ json!({
+ "sessionId": session_id,
+ "targetId": target_id,
+ }),
+ ));
+ ctx.pending_events.push(CdpEvent::new(
+ "Target.targetDestroyed",
+ json!({ "targetId": target_id }),
+ ));
+
+ ctx.remove_page(target_id);
+ Ok(json!({ "success": true }))
+ }
+ "setAutoAttach" => Ok(json!({})),
+ "getBrowserContexts" => {
+ Ok(json!({ "browserContextIds": [ctx.default_context.id] }))
+ }
+ "createBrowserContext" => {
+ ctx.default_context.cookie_jar.clear();
+ Ok(json!({ "browserContextId": ctx.default_context.id }))
+ }
+ "disposeBrowserContext" => {
+ ctx.default_context.cookie_jar.clear();
+ Ok(json!({}))
+ }
+ "getTargetInfo" => {
+ let target_id = params.get("targetId").and_then(|v| v.as_str());
+ match target_id {
+ Some(id) => {
+ let page = ctx.get_page(id).ok_or("Target not found")?;
+ Ok(json!({
+ "targetInfo": {
+ "targetId": id,
+ "type": "page",
+ "title": page.title,
+ "url": page.url_string(),
+ "attached": true,
+ "browserContextId": page.context.id,
+ }
+ }))
+ }
+ None => {
+ Ok(json!({
+ "targetInfo": {
+ "targetId": "browser",
+ "type": "browser",
+ "title": "",
+ "url": "",
+ "attached": true,
+ }
+ }))
+ }
+ }
+ }
+ _ => Err(format!("Unknown Target method: {}", method)),
+ }
+}
diff --git a/crates/obscura-cdp/src/lib.rs b/crates/obscura-cdp/src/lib.rs
new file mode 100644
index 0000000..2bafcdf
--- /dev/null
+++ b/crates/obscura-cdp/src/lib.rs
@@ -0,0 +1,6 @@
+pub mod server;
+pub mod dispatch;
+pub mod types;
+pub mod domains;
+
+pub use server::{start, start_with_options};
diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs
new file mode 100644
index 0000000..31e0ec4
--- /dev/null
+++ b/crates/obscura-cdp/src/server.rs
@@ -0,0 +1,629 @@
+use std::collections::HashMap;
+use std::net::SocketAddr;
+
+use futures_util::{SinkExt, StreamExt};
+use serde_json::json;
+use tokio::net::{TcpListener, TcpStream};
+use tokio::sync::mpsc;
+use tokio_tungstenite::tungstenite::Message;
+use tracing::{error, info, warn};
+
+use crate::dispatch::{self, CdpContext};
+use crate::types::CdpRequest;
+
+struct CdpMessage {
+ text: String,
+ reply_tx: mpsc::UnboundedSender,
+}
+
+enum ServerMessage {
+ Cdp(CdpMessage),
+ NewConnection {
+ reply_tx: mpsc::UnboundedSender,
+ },
+}
+
+pub async fn start(port: u16) -> anyhow::Result<()> {
+ start_with_options(port, None).await
+}
+
+pub async fn start_with_options(port: u16, proxy: Option) -> anyhow::Result<()> {
+ let addr = SocketAddr::from(([127, 0, 0, 1], port));
+ let listener = TcpListener::bind(&addr).await?;
+
+ info!("Obscura CDP server listening on ws://127.0.0.1:{}", port);
+ info!(
+ "DevTools endpoint: ws://127.0.0.1:{}/devtools/browser",
+ port
+ );
+
+ let local = tokio::task::LocalSet::new();
+ local
+ .run_until(async {
+ let (msg_tx, msg_rx) = mpsc::unbounded_channel::();
+
+ let processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy));
+
+ loop {
+ match listener.accept().await {
+ Ok((stream, peer_addr)) => {
+ info!("New connection from {}", peer_addr);
+ let tx = msg_tx.clone();
+ tokio::task::spawn_local(async move {
+ if let Err(e) = handle_connection(stream, port, tx).await {
+ if !format!("{}", e).contains("close") {
+ error!("Connection error from {}: {}", peer_addr, e);
+ }
+ }
+ });
+ }
+ Err(e) => error!("Accept error: {}", e),
+ }
+ }
+ })
+ .await
+}
+
+async fn cdp_processor(
+ mut rx: mpsc::UnboundedReceiver,
+ proxy: Option,
+) {
+ let mut ctx = CdpContext::new_with_proxy(proxy);
+ let (itx, irx) = mpsc::unbounded_channel::();
+ ctx.intercept_tx = Some(itx);
+ let mut intercept_rx: Option> = Some(irx);
+ let mut intercepted_paused: HashMap> = HashMap::new();
+
+ while let Some(msg) = rx.recv().await {
+ match msg {
+ ServerMessage::NewConnection { reply_tx } => {
+ let _ = reply_tx.send(
+ json!({"__init": true})
+ .to_string(),
+ );
+ }
+ ServerMessage::Cdp(cdp_msg) => {
+ let is_navigation = cdp_msg.text.contains("Page.navigate");
+ let has_interception = ctx.fetch_intercept.enabled;
+
+ if is_navigation && has_interception {
+ process_with_interception(
+ &cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut rx,
+ &mut intercept_rx, &mut intercepted_paused,
+ ).await;
+ } else {
+ if cdp_msg.text.contains("Fetch.") {
+ handle_fetch_resolution(&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut intercepted_paused);
+ }
+ process_cdp_message(&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx).await;
+ }
+ }
+ }
+
+ }
+}
+
+fn handle_fetch_resolution(
+ text: &str,
+ ctx: &mut CdpContext,
+ reply_tx: &mpsc::UnboundedSender,
+ intercepted_paused: &mut HashMap>,
+) {
+ if let Ok(req) = serde_json::from_str::(text) {
+ let method = req.method.as_str();
+ let request_id = req.params.get("requestId").and_then(|v| v.as_str()).unwrap_or("");
+ tracing::info!("INTERCEPTION resolution: {} for {}, paused_count={}", method, request_id, intercepted_paused.len());
+
+ if let Some(resolver) = intercepted_paused.remove(request_id) {
+ tracing::info!("INTERCEPTION resolved: {}", request_id);
+ let resolution = match method {
+ "Fetch.continueRequest" => obscura_js::ops::InterceptResolution::Continue {
+ url: None, method: None, headers: None, body: None,
+ },
+ "Fetch.fulfillRequest" => {
+ let status = req.params.get("responseCode").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
+ let raw_body = req.params.get("body").and_then(|v| v.as_str()).unwrap_or("");
+ let body = decode_base64(raw_body);
+ let headers = req.params.get("responseHeaders")
+ .and_then(|v| v.as_array())
+ .map(|arr| arr.iter().filter_map(|h| {
+ Some((h.get("name")?.as_str()?.to_string(), h.get("value")?.as_str()?.to_string()))
+ }).collect())
+ .unwrap_or_default();
+ obscura_js::ops::InterceptResolution::Fulfill { status, headers, body }
+ }
+ "Fetch.failRequest" => {
+ let reason = req.params.get("errorReason").and_then(|v| v.as_str()).unwrap_or("Failed").to_string();
+ obscura_js::ops::InterceptResolution::Fail { reason }
+ }
+ _ => return,
+ };
+ let _ = resolver.send(resolution);
+ let resp = crate::types::CdpResponse::success(req.id, json!({}), req.session_id);
+ if let Ok(json) = serde_json::to_string(&resp) {
+ let _ = reply_tx.send(json);
+ }
+ }
+ }
+}
+
+async fn process_with_interception(
+ text: &str,
+ ctx: &mut CdpContext,
+ reply_tx: &mpsc::UnboundedSender,
+ rx: &mut mpsc::UnboundedReceiver,
+ intercept_rx: &mut Option>,
+ intercepted_paused: &mut HashMap>,
+) {
+ let req: CdpRequest = match serde_json::from_str(text) {
+ Ok(r) => r,
+ Err(e) => {
+ warn!("Invalid CDP: {}", e);
+ return;
+ }
+ };
+
+ tracing::info!("INTERCEPTION navigate: {} (id={})", req.method, req.id);
+
+ let session_id = &req.session_id;
+ let page_id = session_id
+ .as_ref()
+ .and_then(|sid| ctx.sessions.get(sid))
+ .cloned();
+
+ let page_id = match page_id {
+ Some(id) => id,
+ None => {
+ process_cdp_message(text, ctx, reply_tx).await;
+ return;
+ }
+ };
+
+ let page_index = ctx.pages.iter().position(|p| p.id == page_id);
+ let mut page = match page_index {
+ Some(idx) => ctx.pages.remove(idx),
+ None => {
+ process_cdp_message(text, ctx, reply_tx).await;
+ return;
+ }
+ };
+
+ let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or("");
+ let wait_until = req.params.get("waitUntil")
+ .and_then(|v| {
+ if let Some(s) = v.as_str() {
+ Some(obscura_browser::WaitUntil::from_str(s))
+ } else if let Some(arr) = v.as_array() {
+ arr.iter()
+ .filter_map(|item| item.as_str())
+ .map(obscura_browser::WaitUntil::from_str)
+ .max_by_key(|w| match w {
+ obscura_browser::WaitUntil::DomContentLoaded => 0,
+ obscura_browser::WaitUntil::Load => 1,
+ obscura_browser::WaitUntil::NetworkIdle2 => 2,
+ obscura_browser::WaitUntil::NetworkIdle0 => 3,
+ })
+ } else {
+ None
+ }
+ })
+ .unwrap_or(obscura_browser::WaitUntil::Load);
+
+ let preload_scripts: Vec = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect();
+
+ if let Some(tx) = &ctx.intercept_tx {
+ page.set_intercept_tx(tx.clone());
+ }
+
+ let session_for_events = req.session_id.clone();
+ let frame_id = page.frame_id.clone();
+ let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
+
+ let (nav_done_tx, mut nav_done_rx) = mpsc::channel::<(obscura_browser::Page, Result<(), String>)>(1);
+ let url_owned = url.to_string();
+
+ tokio::task::spawn_local(async move {
+ let result = page.navigate_with_wait(&url_owned, wait_until).await.map_err(|e| e.to_string());
+ for source in &preload_scripts {
+ if let Err(e) = page.execute_preload_script(source) {
+ tracing::debug!("Preload script error: {}", e);
+ }
+ }
+ let _ = nav_done_tx.send((page, result)).await;
+ });
+
+ let mut navigate_result: Result<(), String> = Ok(());
+ let mut page_back: Option = None;
+
+ loop {
+ let has_irx = intercept_rx.is_some();
+
+ tokio::select! {
+ Some((returned_page, result)) = nav_done_rx.recv() => {
+ page_back = Some(returned_page);
+ navigate_result = result;
+ break;
+ }
+ Some(intercepted) = async {
+ if let Some(ref mut irx) = intercept_rx {
+ irx.recv().await
+ } else {
+ std::future::pending().await
+ }
+ }, if has_irx => {
+ tracing::info!("INTERCEPTION: requestPaused for {} {} (sending to client)", intercepted.method, intercepted.url);
+ let rws_event = json!({
+ "method": "Network.requestWillBeSent",
+ "params": {
+ "requestId": intercepted.request_id,
+ "loaderId": "",
+ "documentURL": "",
+ "request": {
+ "url": intercepted.url,
+ "method": intercepted.method,
+ "headers": intercepted.headers,
+ "initialPriority": "High",
+ "referrerPolicy": "strict-origin-when-cross-origin",
+ },
+ "timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(),
+ "wallTime": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(),
+ "initiator": {"type": "script"},
+ "type": intercepted.resource_type,
+ "frameId": frame_id,
+ },
+ "sessionId": session_for_events,
+ });
+ let _ = reply_tx.send(rws_event.to_string());
+
+ let event_json = json!({
+ "method": "Fetch.requestPaused",
+ "params": {
+ "requestId": intercepted.request_id,
+ "request": {
+ "url": intercepted.url,
+ "method": intercepted.method,
+ "headers": intercepted.headers,
+ "initialPriority": "High",
+ "referrerPolicy": "strict-origin-when-cross-origin",
+ },
+ "frameId": frame_id,
+ "resourceType": intercepted.resource_type,
+ "networkId": intercepted.request_id,
+ "responseErrorReason": null,
+ "responseStatusCode": null,
+ "responseHeaders": null,
+ },
+ "sessionId": session_for_events,
+ });
+ let event_str = event_json.to_string();
+ tracing::info!("INTERCEPTION event JSON: {}", &event_str[..event_str.len().min(300)]);
+ let _ = reply_tx.send(event_str);
+ intercepted_paused.insert(intercepted.request_id.clone(), intercepted.resolver);
+ tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
+ }
+ Some(msg) = rx.recv() => {
+ tracing::info!("INTERCEPTION select: received CDP message during navigation");
+ match msg {
+ ServerMessage::NewConnection { reply_tx: new_tx } => {
+ let pid = ctx.create_page();
+ let sid = format!("{}-session", pid);
+ ctx.sessions.insert(sid.clone(), pid.clone());
+ let _ = new_tx.send(json!({"__init": true, "pageId": pid, "sessionId": sid}).to_string());
+ }
+ ServerMessage::Cdp(msg) => {
+ if msg.text.contains("Fetch.") {
+ handle_fetch_resolution(&msg.text, ctx, &msg.reply_tx, intercepted_paused);
+ } else {
+ process_cdp_message(&msg.text, ctx, &msg.reply_tx).await;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ let mut page = page_back.expect("navigation task should return the page");
+
+ let network_events: Vec<_> = page.network_events.drain(..).collect();
+ let page_url = page.url_string();
+ let page_id_for_events = page.id.clone();
+ let reached_network_idle = page.lifecycle.is_network_idle();
+
+ ctx.pages.push(page);
+
+ let response = match navigate_result {
+ Ok(()) => crate::types::CdpResponse::success(
+ req.id,
+ json!({"frameId": frame_id, "loaderId": loader_id}),
+ req.session_id.clone(),
+ ),
+ Err(e) => crate::types::CdpResponse::error(req.id, -32000, e, req.session_id.clone()),
+ };
+
+ if let Ok(json) = serde_json::to_string(&response) {
+ let _ = reply_tx.send(json);
+ }
+
+ let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64();
+ let es = session_for_events;
+
+ for event in [
+ crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id_for_events), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() },
+ ] {
+ if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
+ }
+
+ for net_event in &network_events {
+ for event in [
+ crate::types::CdpEvent { method: "Network.requestWillBeSent".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Network.responseReceived".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": ""}, "frameId": frame_id}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Network.loadingFinished".into(), params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}), session_id: es.clone() },
+ ] {
+ if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
+ }
+ }
+
+ for event in [
+ crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
+ crate::types::CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
+ ] {
+ if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
+ }
+ if reached_network_idle {
+ let idle_event = crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": ts}), session_id: es.clone() };
+ if let Ok(json) = serde_json::to_string(&idle_event) { let _ = reply_tx.send(json); }
+ }
+ let stop_event = crate::types::CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es };
+ if let Ok(json) = serde_json::to_string(&stop_event) { let _ = reply_tx.send(json); }
+}
+
+async fn process_cdp_message(
+ text: &str,
+ ctx: &mut CdpContext,
+ reply_tx: &mpsc::UnboundedSender,
+) {
+ let req: CdpRequest = match serde_json::from_str(text) {
+ Ok(r) => r,
+ Err(e) => {
+ warn!("Invalid CDP: {}: {}", e, &text[..text.len().min(200)]);
+ return;
+ }
+ };
+
+ tracing::debug!("CDP: {} (id={}, s={:?})", req.method, req.id, req.session_id);
+
+ let response = dispatch::dispatch(&req, ctx).await;
+
+ if let Ok(json) = serde_json::to_string(&response) {
+ let _ = reply_tx.send(json);
+ }
+
+ for event in ctx.pending_events.drain(..) {
+ if let Ok(json) = serde_json::to_string(&event) {
+ let _ = reply_tx.send(json);
+ }
+ }
+
+ if let Some((nav_url, nav_method, nav_body)) = check_pending_navigation(ctx, &req.session_id) {
+ tracing::info!("JS-triggered nav: {} {} (body: {} bytes)", nav_method, nav_url, nav_body.len());
+ let nav_req = CdpRequest {
+ id: 0,
+ method: "Page.navigate".to_string(),
+ params: json!({"url": nav_url, "__method": nav_method, "__body": nav_body}),
+ session_id: req.session_id.clone(),
+ };
+ let _ = dispatch::dispatch(&nav_req, ctx).await;
+ for event in ctx.pending_events.drain(..) {
+ if let Ok(json) = serde_json::to_string(&event) {
+ let _ = reply_tx.send(json);
+ }
+ }
+ }
+}
+
+fn decode_base64(input: &str) -> String {
+ fn val(c: u8) -> Option {
+ match c {
+ b'A'..=b'Z' => Some(c - b'A'),
+ b'a'..=b'z' => Some(c - b'a' + 26),
+ b'0'..=b'9' => Some(c - b'0' + 52),
+ b'+' => Some(62),
+ b'/' => Some(63),
+ _ => None,
+ }
+ }
+ let bytes: Vec = input.bytes().filter_map(val).collect();
+ let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
+ for chunk in bytes.chunks(4) {
+ let b = [
+ chunk.first().copied().unwrap_or(0),
+ chunk.get(1).copied().unwrap_or(0),
+ chunk.get(2).copied().unwrap_or(0),
+ chunk.get(3).copied().unwrap_or(0),
+ ];
+ out.push((b[0] << 2) | (b[1] >> 4));
+ if chunk.len() > 2 { out.push((b[1] << 4) | (b[2] >> 2)); }
+ if chunk.len() > 3 { out.push((b[2] << 6) | b[3]); }
+ }
+ String::from_utf8_lossy(&out).to_string()
+}
+
+fn fast_path_response(text: &str) -> Option {
+ let req: CdpRequest = serde_json::from_str(text).ok()?;
+
+ let result = match req.method.as_str() {
+ "Network.enable" | "Network.setCacheDisabled" | "Network.setRequestInterception" |
+ "Page.enable" | "Page.setLifecycleEventsEnabled" | "Page.setInterceptFileChooserDialog" |
+ "Runtime.runIfWaitingForDebugger" | "Runtime.discardConsoleEntries" |
+ "Performance.enable" | "Log.enable" | "Security.enable" |
+ "Emulation.setDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled" |
+ "CSS.enable" | "Accessibility.enable" | "ServiceWorker.enable" |
+ "Inspector.enable" | "Debugger.enable" | "Profiler.enable" |
+ "HeapProfiler.enable" | "Overlay.enable" | "Storage.enable" |
+ "Target.setAutoAttach" => {
+ Some(json!({}))
+ }
+ "Browser.getVersion" => {
+ Some(json!({
+ "protocolVersion": "1.3",
+ "product": "Obscura/0.1.0",
+ "revision": "0",
+ "userAgent": "Obscura/0.1.0",
+ "jsVersion": "V8",
+ }))
+ }
+ "Browser.setDownloadBehavior" | "Browser.getWindowBounds" => {
+ Some(json!({}))
+ }
+ _ => None,
+ };
+
+ if let Some(value) = result {
+ let resp = crate::types::CdpResponse::success(req.id, value, req.session_id);
+ serde_json::to_string(&resp).ok()
+ } else {
+ None
+ }
+}
+
+fn check_pending_navigation(ctx: &CdpContext, session_id: &Option) -> Option<(String, String, String)> {
+ let page_id = session_id
+ .as_ref()
+ .and_then(|sid| ctx.sessions.get(sid))?;
+ let page = ctx.pages.iter().find(|p| &p.id == page_id)?;
+ page.take_pending_navigation()
+}
+
+async fn handle_connection(
+ stream: TcpStream,
+ port: u16,
+ msg_tx: mpsc::UnboundedSender,
+) -> anyhow::Result<()> {
+ let mut buf = [0u8; 4];
+ stream.peek(&mut buf).await?;
+
+ if &buf == b"GET " {
+ let mut peek_buf = [0u8; 1024];
+ let n = stream.peek(&mut peek_buf).await?;
+ let line = String::from_utf8_lossy(&peek_buf[..n]);
+
+ if line.contains("/json/version") {
+ return handle_http_json(stream, port, "version").await;
+ } else if line.contains("/json/list") || line.contains("/json\r\n") || line.contains("/json HTTP") {
+ return handle_http_json(stream, port, "list").await;
+ } else if line.contains("/json/protocol") {
+ return handle_http_json(stream, port, "protocol").await;
+ }
+ }
+
+ let ws_stream = tokio_tungstenite::accept_async(stream).await?;
+ info!("WebSocket connected");
+ let (mut ws_sender, mut ws_receiver) = ws_stream.split();
+
+ let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::();
+
+ let _ = msg_tx.send(ServerMessage::NewConnection {
+ reply_tx: reply_tx.clone(),
+ });
+ if let Some(init_msg) = reply_rx.recv().await {
+ tracing::debug!("Connection init: {}", &init_msg[..init_msg.len().min(100)]);
+ }
+
+ let send_task = tokio::task::spawn_local(async move {
+ while let Some(msg) = reply_rx.recv().await {
+ if msg.contains("\"__init\"") {
+ continue;
+ }
+ if ws_sender.send(Message::Text(msg.into())).await.is_err() {
+ break;
+ }
+ }
+ });
+
+ while let Some(msg) = ws_receiver.next().await {
+ let msg = match msg {
+ Ok(m) => m,
+ Err(e) => {
+ warn!("WS read error: {}", e);
+ break;
+ }
+ };
+
+ match msg {
+ Message::Text(text) => {
+ if text.contains("\"Browser.close\"") {
+ if let Ok(req) = serde_json::from_str::(&text) {
+ let resp = crate::types::CdpResponse::success(req.id, json!({}), None);
+ if let Ok(json) = serde_json::to_string(&resp) {
+ let _ = reply_tx.send(json);
+ }
+ }
+ break;
+ }
+
+ if let Some(resp) = fast_path_response(&text) {
+ let _ = reply_tx.send(resp);
+ } else {
+ let _ = msg_tx.send(ServerMessage::Cdp(CdpMessage {
+ text: text.to_string(),
+ reply_tx: reply_tx.clone(),
+ }));
+ }
+ }
+ Message::Close(_) => {
+ info!("WS closed by client");
+ break;
+ }
+ _ => {}
+ }
+ }
+
+ send_task.abort();
+ Ok(())
+}
+
+async fn handle_http_json(stream: TcpStream, port: u16, endpoint: &str) -> anyhow::Result<()> {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ let mut stream = stream;
+ let mut buf = vec![0u8; 4096];
+ let _ = stream.read(&mut buf).await?;
+
+ let body = match endpoint {
+ "version" => serde_json::to_string_pretty(&json!({
+ "Browser": "Obscura/0.1.0",
+ "Protocol-Version": "1.3",
+ "User-Agent": "Obscura/0.1.0 (Headless Browser)",
+ "V8-Version": "N/A",
+ "WebKit-Version": "N/A",
+ "webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/browser", port),
+ }))?,
+ "list" => serde_json::to_string_pretty(&json!([{
+ "description": "",
+ "devtoolsFrontendUrl": "",
+ "id": "page-1",
+ "title": "",
+ "type": "page",
+ "url": "about:blank",
+ "webSocketDebuggerUrl": format!("ws://127.0.0.1:{}/devtools/page/page-1", port),
+ }]))?,
+ "protocol" => {
+ serde_json::to_string_pretty(&json!({ "version": { "major": "1", "minor": "3" } }))?
+ }
+ _ => "{}".to_string(),
+ };
+
+ let resp = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+ body.len(), body,
+ );
+ stream.write_all(resp.as_bytes()).await?;
+ stream.flush().await?;
+ Ok(())
+}
diff --git a/crates/obscura-cdp/src/types.rs b/crates/obscura-cdp/src/types.rs
new file mode 100644
index 0000000..4a50cc7
--- /dev/null
+++ b/crates/obscura-cdp/src/types.rs
@@ -0,0 +1,74 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Deserialize)]
+pub struct CdpRequest {
+ pub id: u64,
+ pub method: String,
+ #[serde(default)]
+ pub params: serde_json::Value,
+ #[serde(rename = "sessionId")]
+ pub session_id: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CdpResponse {
+ pub id: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub result: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+ #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
+ pub session_id: Option,
+}
+
+impl CdpResponse {
+ pub fn success(id: u64, result: serde_json::Value, session_id: Option) -> Self {
+ CdpResponse {
+ id,
+ result: Some(result),
+ error: None,
+ session_id,
+ }
+ }
+
+ pub fn error(id: u64, code: i64, message: String, session_id: Option) -> Self {
+ CdpResponse {
+ id,
+ result: None,
+ error: Some(CdpError { code, message }),
+ session_id,
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+pub struct CdpError {
+ pub code: i64,
+ pub message: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CdpEvent {
+ pub method: String,
+ pub params: serde_json::Value,
+ #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
+ pub session_id: Option,
+}
+
+impl CdpEvent {
+ pub fn new(method: &str, params: serde_json::Value) -> Self {
+ CdpEvent {
+ method: method.to_string(),
+ params,
+ session_id: None,
+ }
+ }
+
+ pub fn with_session(method: &str, params: serde_json::Value, session_id: String) -> Self {
+ CdpEvent {
+ method: method.to_string(),
+ params,
+ session_id: Some(session_id),
+ }
+ }
+}
diff --git a/crates/obscura-cli/Cargo.toml b/crates/obscura-cli/Cargo.toml
new file mode 100644
index 0000000..81fdc57
--- /dev/null
+++ b/crates/obscura-cli/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "obscura-cli"
+version.workspace = true
+edition.workspace = true
+
+[[bin]]
+name = "obscura"
+path = "src/main.rs"
+
+[[bin]]
+name = "obscura-worker"
+path = "src/worker.rs"
+
+[features]
+default = []
+stealth = ["obscura-browser/stealth", "obscura-net/stealth"]
+
+[dependencies]
+obscura-browser = { workspace = true }
+obscura-cdp = { workspace = true }
+obscura-dom = { workspace = true }
+obscura-net = { workspace = true }
+obscura-js = { workspace = true }
+tokio = { workspace = true }
+clap = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+anyhow = { workspace = true }
+url = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs
new file mode 100644
index 0000000..61beca1
--- /dev/null
+++ b/crates/obscura-cli/src/main.rs
@@ -0,0 +1,604 @@
+use std::sync::Arc;
+use std::time::Instant;
+
+use clap::{Parser, Subcommand};
+use obscura_browser::{BrowserContext, Page};
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::Command as TokioCommand;
+
+#[derive(Parser)]
+#[command(name = "obscura", about = "Obscura - A lightweight headless browser for web scraping and automation")]
+struct Args {
+ #[arg(short, long, global = true)]
+ verbose: bool,
+
+ #[command(subcommand)]
+ command: Option,
+
+ #[arg(short, long, default_value_t = 9222)]
+ port: u16,
+
+ #[arg(long)]
+ proxy: Option,
+
+ #[arg(long)]
+ obey_robots: bool,
+
+ #[arg(long)]
+ user_agent: Option,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ Serve {
+ #[arg(short, long, default_value_t = 9222)]
+ port: u16,
+
+ #[arg(long)]
+ proxy: Option,
+
+ #[arg(long)]
+ user_agent: Option,
+
+ #[arg(long)]
+ stealth: bool,
+
+ #[arg(long, default_value_t = 1)]
+ workers: u16,
+ },
+
+ Fetch {
+ url: String,
+
+ #[arg(long, default_value = "html")]
+ dump: DumpFormat,
+
+ #[arg(long)]
+ selector: Option,
+
+ #[arg(long, default_value_t = 5)]
+ wait: u64,
+
+ #[arg(long, default_value = "load")]
+ wait_until: String,
+
+ #[arg(long)]
+ user_agent: Option,
+
+ #[arg(long)]
+ stealth: bool,
+
+ #[arg(long, short)]
+ eval: Option,
+
+ #[arg(long, short)]
+ quiet: bool,
+ },
+
+ Scrape {
+ urls: Vec,
+
+ #[arg(long, short)]
+ eval: Option,
+
+ #[arg(long, default_value_t = 10)]
+ concurrency: usize,
+
+ #[arg(long, default_value = "json")]
+ format: String,
+ },
+
+}
+
+
+#[derive(Clone, Debug, clap::ValueEnum)]
+enum DumpFormat {
+ Html,
+ Text,
+ Links,
+}
+
+fn print_banner(port: u16) {
+ println!(r#"
+ ____ _
+ / __ \| |
+ | | | | |__ ___ ___ _ _ _ __ __ _
+ | | | | '_ \/ __|/ __| | | | '__/ _` |
+ | |__| | |_) \__ \ (__| |_| | | | (_| |
+ \____/|_.__/|___/\___|\__,_|_| \__,_|
+
+ Headless Browser v0.1.0
+ CDP server: ws://127.0.0.1:{}/devtools/browser
+"#, port);
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> anyhow::Result<()> {
+ let args = Args::parse();
+
+ let filter = if args.verbose { "debug" } else { "warn" };
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
+ )
+ .with_writer(std::io::stderr)
+ .init();
+
+ match args.command {
+ Some(Command::Serve { port, proxy, user_agent, stealth, workers }) => {
+ print_banner(port);
+ if let Some(ref proxy) = proxy {
+ tracing::info!("Using proxy: {}", proxy);
+ }
+ if let Some(ref ua) = user_agent {
+ tracing::info!("User-Agent: {}", ua);
+ }
+ if stealth {
+ tracing::info!("Stealth mode enabled (TLS fingerprint spoofing)");
+ }
+ let _ = stealth;
+
+ if workers > 1 {
+ tracing::info!("{} worker processes", workers);
+ run_multi_worker_serve(port, workers, proxy, stealth).await?;
+ } else {
+ obscura_cdp::start_with_options(port, proxy).await?;
+ }
+ }
+ Some(Command::Fetch { url, dump, selector, wait, wait_until, user_agent, stealth, eval, quiet }) => {
+ run_fetch(&url, dump, selector, wait, &wait_until, user_agent, stealth, eval, quiet).await?;
+ }
+ Some(Command::Scrape { urls, eval, concurrency, format }) => {
+ run_parallel_scrape(urls, eval, concurrency, &format).await?;
+ }
+ None => {
+ print_banner(args.port);
+ if let Some(ref proxy) = args.proxy {
+ tracing::info!("Using proxy: {}", proxy);
+ }
+ obscura_cdp::start_with_options(args.port, args.proxy).await?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn run_multi_worker_serve(
+ port: u16,
+ workers: u16,
+ proxy: Option,
+ stealth: bool,
+) -> anyhow::Result<()> {
+ use tokio::net::TcpListener;
+ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
+
+ let exe = std::env::current_exe()?;
+ let mut children = Vec::new();
+
+ for i in 0..workers {
+ let worker_port = port + 1 + i;
+ let mut cmd = std::process::Command::new(&exe);
+ cmd.arg("serve").arg("--port").arg(worker_port.to_string());
+ if let Some(ref p) = proxy {
+ cmd.arg("--proxy").arg(p);
+ }
+ if stealth {
+ cmd.arg("--stealth");
+ }
+ cmd.stdout(std::process::Stdio::null());
+ cmd.stderr(std::process::Stdio::null());
+
+ let child = cmd.spawn()?;
+ tracing::info!("Worker {} on port {}", i + 1, worker_port);
+ children.push(child);
+ }
+
+ tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
+
+ let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
+ let listener = TcpListener::bind(&addr).await?;
+ tracing::info!("Load balancer on port {}, {} workers", port, workers);
+
+ let mut next_worker: u16 = 0;
+
+ loop {
+ let (client_stream, peer_addr) = listener.accept().await?;
+ let worker_port = port + 1 + (next_worker % workers);
+ next_worker = next_worker.wrapping_add(1);
+
+ tracing::debug!("Routing {} to worker port {}", peer_addr, worker_port);
+
+ let mut peek_buf = [0u8; 4];
+ client_stream.peek(&mut peek_buf).await?;
+
+ if &peek_buf == b"GET " {
+ let mut full_peek = [0u8; 256];
+ let n = client_stream.peek(&mut full_peek).await?;
+ let request_line = String::from_utf8_lossy(&full_peek[..n]);
+
+ if request_line.contains("/json") {
+ let worker_addr = format!("127.0.0.1:{}", port + 1);
+ if let Ok(mut worker_stream) = tokio::net::TcpStream::connect(&worker_addr).await {
+ tokio::spawn(async move {
+ let _ = tokio::io::copy_bidirectional(&mut tokio::net::TcpStream::from_std(client_stream.into_std().unwrap()).unwrap(), &mut worker_stream).await;
+ });
+ }
+ continue;
+ }
+ }
+
+ let worker_addr = format!("127.0.0.1:{}", worker_port);
+ tokio::spawn(async move {
+ if let Ok(mut worker_stream) = tokio::net::TcpStream::connect(&worker_addr).await {
+ let mut client = client_stream;
+ let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
+ }
+ });
+ }
+}
+
+async fn run_fetch(
+ url_str: &str,
+ dump: DumpFormat,
+ selector: Option,
+ wait_secs: u64,
+ wait_until: &str,
+ user_agent: Option,
+ stealth: bool,
+ eval: Option,
+ quiet: bool,
+) -> anyhow::Result<()> {
+ let context = Arc::new(BrowserContext::with_options("fetch".to_string(), None, stealth));
+ let mut page = Page::new("fetch-page".to_string(), context);
+
+ if let Some(ref ua) = user_agent {
+ page.http_client.set_user_agent(ua).await;
+ }
+
+ let wait_condition = obscura_browser::lifecycle::WaitUntil::from_str(wait_until);
+
+ if !quiet {
+ eprintln!("Fetching {}...", url_str);
+ }
+
+ page.navigate_with_wait(url_str, wait_condition).await.map_err(|e| {
+ anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e)
+ })?;
+
+ if !quiet {
+ eprintln!("Page loaded: {} - \"{}\"", page.url_string(), page.title);
+ }
+
+ if let Some(ref sel) = selector {
+ let found = wait_for_selector(&mut page, sel, wait_secs).await;
+ if !found {
+ eprintln!("Warning: selector '{}' not found after {}s", sel, wait_secs);
+ }
+ }
+
+ if let Some(ref expr) = eval {
+ let result = page.evaluate(expr);
+ match result {
+ serde_json::Value::String(s) => println!("{}", s),
+ serde_json::Value::Null => println!("null"),
+ other => println!("{}", other),
+ }
+ return Ok(());
+ }
+
+ match dump {
+ DumpFormat::Html => {
+ dump_html(&page);
+ }
+ DumpFormat::Text => {
+ dump_text(&mut page);
+ }
+ DumpFormat::Links => {
+ dump_links(&page);
+ }
+ }
+
+ Ok(())
+}
+
+async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool {
+ let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
+ loop {
+ let found = page.with_dom(|dom| {
+ dom.query_selector(selector).ok().flatten().is_some()
+ }).unwrap_or(false);
+
+ if found {
+ return true;
+ }
+
+ if tokio::time::Instant::now() >= deadline {
+ return false;
+ }
+
+ tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
+ }
+}
+
+fn dump_html(page: &Page) {
+ page.with_dom(|dom| {
+ if let Ok(Some(html_node)) = dom.query_selector("html") {
+ let html = dom.outer_html(html_node);
+ println!("");
+ println!("{}", html);
+ } else {
+ let doc = dom.document();
+ let html = dom.inner_html(doc);
+ println!("{}", html);
+ }
+ });
+}
+
+fn dump_text(page: &mut Page) {
+ page.with_dom(|dom| {
+ if let Ok(Some(body)) = dom.query_selector("body") {
+ let text = extract_readable_text(dom, body);
+ println!("{}", text.trim());
+ }
+ });
+}
+
+fn extract_readable_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeId) -> String {
+ use obscura_dom::NodeData;
+
+ let mut result = String::new();
+ let node = match dom.get_node(node_id) {
+ Some(n) => n,
+ None => return result,
+ };
+
+ match &node.data {
+ NodeData::Text { contents } => {
+ let trimmed = contents.trim();
+ if !trimmed.is_empty() {
+ result.push_str(trimmed);
+ }
+ }
+ NodeData::Element { name, .. } => {
+ let tag = name.local.as_ref();
+ let is_block = matches!(
+ tag,
+ "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
+ | "li" | "tr" | "br" | "hr" | "blockquote" | "pre"
+ | "section" | "article" | "header" | "footer" | "nav"
+ | "main" | "aside" | "figure" | "figcaption" | "table"
+ | "thead" | "tbody" | "tfoot" | "dl" | "dt" | "dd"
+ | "ul" | "ol"
+ );
+
+ if tag == "script" || tag == "style" {
+ return result;
+ }
+
+ if is_block {
+ result.push('\n');
+ }
+
+ for child_id in dom.children(node_id) {
+ result.push_str(&extract_readable_text(dom, child_id));
+ }
+
+ if is_block {
+ result.push('\n');
+ }
+ }
+ _ => {
+ for child_id in dom.children(node_id) {
+ result.push_str(&extract_readable_text(dom, child_id));
+ }
+ }
+ }
+
+ result
+}
+
+async fn run_parallel_scrape(
+ urls: Vec,
+ eval: Option,
+ concurrency: usize,
+ format: &str,
+) -> anyhow::Result<()> {
+ let total = urls.len();
+ let start = Instant::now();
+
+ eprintln!(
+ "Scraping {} URLs with {} concurrent workers...",
+ total, concurrency
+ );
+
+ let worker_path = std::env::current_exe()
+ .ok()
+ .and_then(|p| p.parent().map(|d| d.join("obscura-worker")))
+ .unwrap_or_else(|| std::path::PathBuf::from("obscura-worker"));
+
+ if !worker_path.exists() {
+ anyhow::bail!(
+ "Worker binary not found at {}. Build with: cargo build --release",
+ worker_path.display()
+ );
+ }
+
+ let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
+ let eval = Arc::new(eval);
+ let worker_path = Arc::new(worker_path);
+
+ let mut handles = Vec::new();
+
+ for (i, url) in urls.into_iter().enumerate() {
+ let sem = semaphore.clone();
+ let eval = eval.clone();
+ let worker_path = worker_path.clone();
+
+ let handle = tokio::spawn(async move {
+ let _permit = sem.acquire().await.unwrap();
+ let task_start = Instant::now();
+
+ let mut child = match TokioCommand::new(worker_path.as_ref())
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ {
+ Ok(c) => c,
+ Err(e) => {
+ return serde_json::json!({
+ "url": url,
+ "error": format!("Failed to spawn worker: {}", e),
+ "time_ms": task_start.elapsed().as_millis(),
+ });
+ }
+ };
+
+ let stdin = child.stdin.as_mut().unwrap();
+ let stdout = child.stdout.take().unwrap();
+ let mut reader = BufReader::new(stdout);
+
+ let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url});
+ let mut line = serde_json::to_string(&nav_cmd).unwrap();
+ line.push('\n');
+ if stdin.write_all(line.as_bytes()).await.is_err() {
+ let _ = child.kill().await;
+ return serde_json::json!({"url": url, "error": "Write failed"});
+ }
+ let _ = stdin.flush().await;
+
+ let mut resp_line = String::new();
+ if reader.read_line(&mut resp_line).await.is_err() {
+ let _ = child.kill().await;
+ return serde_json::json!({"url": url, "error": "Read failed"});
+ }
+
+ let nav_resp: serde_json::Value =
+ serde_json::from_str(resp_line.trim()).unwrap_or(serde_json::json!({"ok": false}));
+
+ if !nav_resp["ok"].as_bool().unwrap_or(false) {
+ let _ = child.kill().await;
+ return serde_json::json!({
+ "url": url,
+ "error": nav_resp["error"].as_str().unwrap_or("navigate failed"),
+ "time_ms": task_start.elapsed().as_millis(),
+ });
+ }
+
+ let title = nav_resp["result"]["title"]
+ .as_str()
+ .unwrap_or("")
+ .to_string();
+
+ let eval_result = if let Some(ref expr) = *eval {
+ let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr});
+ let mut line = serde_json::to_string(&eval_cmd).unwrap();
+ line.push('\n');
+ let _ = stdin.write_all(line.as_bytes()).await;
+ let _ = stdin.flush().await;
+
+ let mut resp_line = String::new();
+ if reader.read_line(&mut resp_line).await.is_ok() {
+ let resp: serde_json::Value = serde_json::from_str(resp_line.trim())
+ .unwrap_or(serde_json::json!({"ok": false}));
+ resp["result"].clone()
+ } else {
+ serde_json::Value::Null
+ }
+ } else {
+ serde_json::Value::Null
+ };
+
+ let shutdown_cmd = serde_json::json!({"cmd": "shutdown"});
+ let mut line = serde_json::to_string(&shutdown_cmd).unwrap();
+ line.push('\n');
+ let _ = stdin.write_all(line.as_bytes()).await;
+ let _ = stdin.flush().await;
+ let _ = child.wait().await;
+
+ let elapsed = task_start.elapsed().as_millis();
+
+ serde_json::json!({
+ "url": url,
+ "title": title,
+ "eval": eval_result,
+ "time_ms": elapsed,
+ "worker": i,
+ })
+ });
+
+ handles.push(handle);
+ }
+
+ let mut results = Vec::new();
+ for handle in handles {
+ match handle.await {
+ Ok(result) => results.push(result),
+ Err(e) => results.push(serde_json::json!({"error": e.to_string()})),
+ }
+ }
+
+ let total_time = start.elapsed();
+
+ if format == "json" {
+ let output = serde_json::json!({
+ "total_urls": total,
+ "concurrency": concurrency,
+ "total_time_ms": total_time.as_millis(),
+ "avg_time_ms": total_time.as_millis() as f64 / total as f64,
+ "results": results,
+ });
+ println!("{}", serde_json::to_string_pretty(&output)?);
+ } else {
+ for r in &results {
+ let url = r["url"].as_str().unwrap_or("?");
+ let title = r["title"].as_str().unwrap_or("");
+ let time = r["time_ms"].as_u64().unwrap_or(0);
+ let eval = &r["eval"];
+ if eval.is_null() {
+ println!("{}ms\t{}\t{}", time, url, title);
+ } else {
+ println!("{}ms\t{}\t{}", time, url, eval);
+ }
+ }
+ eprintln!(
+ "\nTotal: {}ms for {} URLs ({} concurrent)",
+ total_time.as_millis(),
+ total,
+ concurrency
+ );
+ }
+
+ Ok(())
+}
+
+fn dump_links(page: &Page) {
+ let base_url = page.url.clone();
+ page.with_dom(|dom| {
+ let links = dom.query_selector_all("a").unwrap_or_default();
+ for link_id in links {
+ if let Some(node) = dom.get_node(link_id) {
+ let href = node.get_attribute("href").unwrap_or_default().to_string();
+ let text = dom.text_content(link_id);
+ let text = text.trim();
+
+ let full_url = if href.starts_with("http://") || href.starts_with("https://") {
+ href.clone()
+ } else if let Some(ref base) = base_url {
+ base.join(&href).map(|u| u.to_string()).unwrap_or(href.clone())
+ } else {
+ href.clone()
+ };
+
+ if !full_url.is_empty() {
+ if text.is_empty() {
+ println!("{}", full_url);
+ } else {
+ println!("{}\t{}", full_url, text);
+ }
+ }
+ }
+ }
+ });
+}
diff --git a/crates/obscura-cli/src/worker.rs b/crates/obscura-cli/src/worker.rs
new file mode 100644
index 0000000..57a6123
--- /dev/null
+++ b/crates/obscura-cli/src/worker.rs
@@ -0,0 +1,138 @@
+
+use std::sync::Arc;
+
+use obscura_browser::{BrowserContext, Page};
+use serde::{Deserialize, Serialize};
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "cmd")]
+enum WorkerCommand {
+ #[serde(rename = "navigate")]
+ Navigate { url: String },
+ #[serde(rename = "evaluate")]
+ Evaluate { expression: String },
+ #[serde(rename = "title")]
+ Title,
+ #[serde(rename = "dump_html")]
+ DumpHtml,
+ #[serde(rename = "dump_text")]
+ DumpText,
+ #[serde(rename = "shutdown")]
+ Shutdown,
+}
+
+#[derive(Debug, Serialize)]
+struct WorkerResponse {
+ ok: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
+impl WorkerResponse {
+ fn success(result: serde_json::Value) -> Self {
+ WorkerResponse { ok: true, result: Some(result), error: None }
+ }
+ fn error(msg: String) -> Self {
+ WorkerResponse { ok: false, result: None, error: Some(msg) }
+ }
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() {
+ tracing_subscriber::fmt()
+ .with_env_filter("warn")
+ .with_writer(std::io::stderr)
+ .init();
+
+ let context = Arc::new(BrowserContext::new("worker".to_string()));
+ let mut page = Page::new("page-1".to_string(), context);
+
+ let stdin = tokio::io::stdin();
+ let mut stdout = tokio::io::stdout();
+ let mut reader = BufReader::new(stdin);
+ let mut line = String::new();
+
+ loop {
+ line.clear();
+ match reader.read_line(&mut line).await {
+ Ok(0) => break,
+ Ok(_) => {}
+ Err(e) => {
+ eprintln!("Worker stdin error: {}", e);
+ break;
+ }
+ }
+
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+
+ let cmd: WorkerCommand = match serde_json::from_str(trimmed) {
+ Ok(c) => c,
+ Err(e) => {
+ let resp = WorkerResponse::error(format!("Invalid command: {}", e));
+ let mut out = serde_json::to_string(&resp).unwrap();
+ out.push('\n');
+ let _ = stdout.write_all(out.as_bytes()).await;
+ let _ = stdout.flush().await;
+ continue;
+ }
+ };
+
+ let resp = match cmd {
+ WorkerCommand::Navigate { url } => {
+ match page.navigate(&url).await {
+ Ok(()) => WorkerResponse::success(serde_json::json!({
+ "title": page.title,
+ "url": page.url_string(),
+ })),
+ Err(e) => WorkerResponse::error(e.to_string()),
+ }
+ }
+ WorkerCommand::Evaluate { expression } => {
+ let result = page.evaluate(&expression);
+ WorkerResponse::success(result)
+ }
+ WorkerCommand::Title => {
+ WorkerResponse::success(serde_json::json!(page.title))
+ }
+ WorkerCommand::DumpHtml => {
+ let html = page.with_dom(|dom| {
+ if let Ok(Some(html_node)) = dom.query_selector("html") {
+ dom.outer_html(html_node)
+ } else {
+ dom.inner_html(dom.document())
+ }
+ }).unwrap_or_default();
+ WorkerResponse::success(serde_json::json!(html))
+ }
+ WorkerCommand::DumpText => {
+ let text = page.with_dom(|dom| {
+ if let Ok(Some(body)) = dom.query_selector("body") {
+ dom.text_content(body)
+ } else {
+ String::new()
+ }
+ }).unwrap_or_default();
+ WorkerResponse::success(serde_json::json!(text))
+ }
+ WorkerCommand::Shutdown => {
+ let resp = WorkerResponse::success(serde_json::json!("bye"));
+ let mut out = serde_json::to_string(&resp).unwrap();
+ out.push('\n');
+ let _ = stdout.write_all(out.as_bytes()).await;
+ let _ = stdout.flush().await;
+ break;
+ }
+ };
+
+ let mut out = serde_json::to_string(&resp).unwrap();
+ out.push('\n');
+ let _ = stdout.write_all(out.as_bytes()).await;
+ let _ = stdout.flush().await;
+ }
+}
diff --git a/crates/obscura-dom/Cargo.toml b/crates/obscura-dom/Cargo.toml
new file mode 100644
index 0000000..58260db
--- /dev/null
+++ b/crates/obscura-dom/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "obscura-dom"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+html5ever = { workspace = true }
+markup5ever = { workspace = true }
+selectors = { workspace = true }
+servo_arc = { workspace = true }
+cssparser = { workspace = true }
+thiserror = { workspace = true }
+tracing = { workspace = true }
+precomputed-hash = "0.1"
+
+[dev-dependencies]
diff --git a/crates/obscura-dom/src/lib.rs b/crates/obscura-dom/src/lib.rs
new file mode 100644
index 0000000..6d3d6fc
--- /dev/null
+++ b/crates/obscura-dom/src/lib.rs
@@ -0,0 +1,10 @@
+#[macro_use]
+extern crate html5ever;
+
+pub mod tree;
+pub mod tree_sink;
+pub mod selector;
+pub mod serialize;
+
+pub use tree::{Attribute, DomTree, Node, NodeData, NodeId};
+pub use tree_sink::{parse_html, parse_fragment};
diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs
new file mode 100644
index 0000000..d0298c0
--- /dev/null
+++ b/crates/obscura-dom/src/selector.rs
@@ -0,0 +1,641 @@
+use cssparser::{CowRcStr, ToCss};
+use html5ever::{LocalName, Namespace};
+use precomputed_hash::PrecomputedHash;
+use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint};
+use selectors::bloom::BloomFilter;
+use selectors::context::QuirksMode;
+use selectors::matching::{
+ ElementSelectorFlags, MatchingContext, MatchingForInvalidation, MatchingMode,
+ NeedsSelectorFlags,
+};
+use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
+use selectors::{Element, OpaqueElement, SelectorList};
+use selectors::visitor::SelectorVisitor;
+
+use crate::tree::{DomTree, NodeData, NodeId};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ObscuraSelector;
+
+impl parser::SelectorImpl for ObscuraSelector {
+ type ExtraMatchingData<'a> = ();
+ type AttrValue = CssString;
+ type Identifier = CssString;
+ type LocalName = CssLocalName;
+ type NamespaceUrl = CssNamespace;
+ type NamespacePrefix = CssString;
+ type BorrowedLocalName = CssLocalName;
+ type BorrowedNamespaceUrl = CssNamespace;
+ type NonTSPseudoClass = PseudoClass;
+ type PseudoElement = PseudoElement;
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct CssString(pub String);
+
+impl<'a> From<&'a str> for CssString {
+ fn from(s: &'a str) -> Self {
+ CssString(s.to_string())
+ }
+}
+
+impl AsRef for CssString {
+ fn as_ref(&self) -> &str {
+ &self.0
+ }
+}
+
+impl ToCss for CssString {
+ fn to_css(&self, dest: &mut W) -> std::fmt::Result {
+ cssparser::serialize_string(&self.0, dest)
+ }
+}
+
+impl PrecomputedHash for CssString {
+ fn precomputed_hash(&self) -> u32 {
+ let mut h: u32 = 5381;
+ for b in self.0.as_bytes() {
+ h = h.wrapping_mul(33).wrapping_add(*b as u32);
+ }
+ h
+ }
+}
+
+impl Default for CssString {
+ fn default() -> Self {
+ CssString(String::new())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct CssLocalName(pub LocalName);
+
+impl<'a> From<&'a str> for CssLocalName {
+ fn from(s: &'a str) -> Self {
+ CssLocalName(LocalName::from(s))
+ }
+}
+
+impl ToCss for CssLocalName {
+ fn to_css(&self, dest: &mut W) -> std::fmt::Result {
+ dest.write_str(&self.0)
+ }
+}
+
+impl PrecomputedHash for CssLocalName {
+ fn precomputed_hash(&self) -> u32 {
+ self.0.precomputed_hash()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct CssNamespace(pub Namespace);
+
+impl PrecomputedHash for CssNamespace {
+ fn precomputed_hash(&self) -> u32 {
+ self.0.precomputed_hash()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PseudoClass {
+ Hover,
+ Active,
+ Focus,
+ Enabled,
+ Disabled,
+ Checked,
+}
+
+impl parser::NonTSPseudoClass for PseudoClass {
+ type Impl = ObscuraSelector;
+
+ fn is_active_or_hover(&self) -> bool {
+ matches!(self, PseudoClass::Hover | PseudoClass::Active)
+ }
+
+ fn is_user_action_state(&self) -> bool {
+ matches!(
+ self,
+ PseudoClass::Hover | PseudoClass::Active | PseudoClass::Focus
+ )
+ }
+
+ fn visit(&self, _visitor: &mut V) -> bool
+ where
+ V: SelectorVisitor,
+ {
+ true
+ }
+}
+
+impl ToCss for PseudoClass {
+ fn to_css(&self, dest: &mut W) -> std::fmt::Result {
+ match self {
+ PseudoClass::Hover => dest.write_str(":hover"),
+ PseudoClass::Active => dest.write_str(":active"),
+ PseudoClass::Focus => dest.write_str(":focus"),
+ PseudoClass::Enabled => dest.write_str(":enabled"),
+ PseudoClass::Disabled => dest.write_str(":disabled"),
+ PseudoClass::Checked => dest.write_str(":checked"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PseudoElement {
+ Before,
+ After,
+}
+
+impl parser::PseudoElement for PseudoElement {
+ type Impl = ObscuraSelector;
+}
+
+impl ToCss for PseudoElement {
+ fn to_css(&self, dest: &mut W) -> std::fmt::Result {
+ match self {
+ PseudoElement::Before => dest.write_str("::before"),
+ PseudoElement::After => dest.write_str("::after"),
+ }
+ }
+}
+
+pub struct ObscuraSelectorParser;
+
+impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
+ type Impl = ObscuraSelector;
+ type Error = SelectorParseErrorKind<'i>;
+
+ fn parse_non_ts_pseudo_class(
+ &self,
+ _location: cssparser::SourceLocation,
+ name: CowRcStr<'i>,
+ ) -> Result> {
+ match name.as_ref() {
+ "hover" => Ok(PseudoClass::Hover),
+ "active" => Ok(PseudoClass::Active),
+ "focus" => Ok(PseudoClass::Focus),
+ "enabled" => Ok(PseudoClass::Enabled),
+ "disabled" => Ok(PseudoClass::Disabled),
+ "checked" => Ok(PseudoClass::Checked),
+ _ => Err(cssparser::ParseError {
+ kind: cssparser::ParseErrorKind::Custom(
+ SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
+ ),
+ location: _location,
+ }),
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+pub struct DomElement<'a> {
+ pub tree: &'a DomTree,
+ pub node_id: NodeId,
+}
+
+impl<'a> DomElement<'a> {
+ pub fn new(tree: &'a DomTree, node_id: NodeId) -> Self {
+ DomElement { tree, node_id }
+ }
+}
+
+impl<'a> std::fmt::Debug for DomElement<'a> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "DomElement({:?})", self.node_id)
+ }
+}
+
+impl<'a> PartialEq for DomElement<'a> {
+ fn eq(&self, other: &Self) -> bool {
+ self.node_id == other.node_id
+ }
+}
+
+impl<'a> Eq for DomElement<'a> {}
+
+impl<'a> Element for DomElement<'a> {
+ type Impl = ObscuraSelector;
+
+ fn opaque(&self) -> OpaqueElement {
+ OpaqueElement::new(self)
+ }
+
+ fn parent_element(&self) -> Option {
+ let node = self.tree.get_node(self.node_id)?;
+ let parent_id = node.parent?;
+ let parent = self.tree.get_node(parent_id)?;
+ if parent.is_element() {
+ Some(DomElement::new(self.tree, parent_id))
+ } else {
+ None
+ }
+ }
+
+ fn parent_node_is_shadow_root(&self) -> bool {
+ false
+ }
+
+ fn containing_shadow_host(&self) -> Option {
+ None
+ }
+
+ fn pseudo_element_originating_element(&self) -> Option {
+ None
+ }
+
+ fn is_pseudo_element(&self) -> bool {
+ false
+ }
+
+ fn prev_sibling_element(&self) -> Option {
+ let node = self.tree.get_node(self.node_id)?;
+ let mut current = node.prev_sibling;
+ while let Some(sibling_id) = current {
+ let sibling = self.tree.get_node(sibling_id)?;
+ if sibling.is_element() {
+ return Some(DomElement::new(self.tree, sibling_id));
+ }
+ current = sibling.prev_sibling;
+ }
+ None
+ }
+
+ fn next_sibling_element(&self) -> Option {
+ let node = self.tree.get_node(self.node_id)?;
+ let mut current = node.next_sibling;
+ while let Some(sibling_id) = current {
+ let sibling = self.tree.get_node(sibling_id)?;
+ if sibling.is_element() {
+ return Some(DomElement::new(self.tree, sibling_id));
+ }
+ current = sibling.next_sibling;
+ }
+ None
+ }
+
+ fn first_element_child(&self) -> Option {
+ let node = self.tree.get_node(self.node_id)?;
+ let mut current = node.first_child;
+ while let Some(child_id) = current {
+ let child = self.tree.get_node(child_id)?;
+ if child.is_element() {
+ return Some(DomElement::new(self.tree, child_id));
+ }
+ current = child.next_sibling;
+ }
+ None
+ }
+
+ fn is_html_element_in_html_document(&self) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.as_element()
+ .map(|name| name.ns == ns!(html))
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn has_local_name(&self, local_name: &CssLocalName) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.as_element()
+ .map(|name| name.local == local_name.0)
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn has_namespace(&self, ns: &CssNamespace) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.as_element()
+ .map(|name| name.ns == ns.0)
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn is_same_type(&self, other: &Self) -> bool {
+ let self_name = self.tree.with_node(self.node_id, |n| {
+ n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
+ }).flatten();
+ let other_name = self.tree.with_node(other.node_id, |n| {
+ n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
+ }).flatten();
+ match (self_name, other_name) {
+ (Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
+ _ => false,
+ }
+ }
+
+ fn attr_matches(
+ &self,
+ ns: &NamespaceConstraint<&CssNamespace>,
+ local_name: &CssLocalName,
+ operation: &AttrSelectorOperation<&CssString>,
+ ) -> bool {
+ self.tree
+ .with_node(self.node_id, |node| {
+ let attrs = match node.attrs() {
+ Some(a) => a,
+ None => return false,
+ };
+ attrs.iter().any(|attr| {
+ let ns_match = match ns {
+ NamespaceConstraint::Any => true,
+ NamespaceConstraint::Specific(expected_ns) => attr.name.ns == expected_ns.0,
+ };
+ if !ns_match || attr.name.local != local_name.0 {
+ return false;
+ }
+ operation.eval_str(&attr.value)
+ })
+ })
+ .unwrap_or(false)
+ }
+
+ fn has_attr_in_no_namespace(&self, local_name: &CssLocalName) -> bool {
+ self.tree
+ .with_node(self.node_id, |node| {
+ node.attrs()
+ .map(|attrs| {
+ attrs.iter().any(|a| {
+ a.name.ns == html5ever::ns!()
+ && a.name.local == local_name.0
+ })
+ })
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn match_non_ts_pseudo_class(
+ &self,
+ _pc: &PseudoClass,
+ _context: &mut MatchingContext<'_, Self::Impl>,
+ ) -> bool {
+ false
+ }
+
+ fn match_pseudo_element(
+ &self,
+ _pe: &PseudoElement,
+ _context: &mut MatchingContext<'_, Self::Impl>,
+ ) -> bool {
+ false
+ }
+
+ fn apply_selector_flags(&self, _flags: ElementSelectorFlags) {}
+
+ fn is_link(&self) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.as_element()
+ .map(|name| {
+ matches!(name.local.as_ref(), "a" | "area" | "link")
+ && n.get_attribute("href").is_some()
+ })
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn is_html_slot_element(&self) -> bool {
+ false
+ }
+
+ fn assigned_slot(&self) -> Option {
+ None
+ }
+
+ fn has_id(&self, id: &CssString, case_sensitivity: CaseSensitivity) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.get_attribute("id")
+ .map(|value| case_sensitivity.eq(value.as_bytes(), id.0.as_bytes()))
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn has_class(&self, name: &CssString, case_sensitivity: CaseSensitivity) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.get_attribute("class")
+ .map(|class_attr| {
+ class_attr
+ .split_whitespace()
+ .any(|c| case_sensitivity.eq(c.as_bytes(), name.0.as_bytes()))
+ })
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn has_custom_state(&self, _name: &CssString) -> bool {
+ false
+ }
+
+ fn imported_part(&self, _name: &CssString) -> Option {
+ None
+ }
+
+ fn is_part(&self, _name: &CssString) -> bool {
+ false
+ }
+
+ fn is_empty(&self) -> bool {
+ self.tree
+ .with_node(self.node_id, |node| {
+ let mut child = node.first_child;
+ while let Some(child_id) = child {
+ if let Some(child_node) = self.tree.get_node(child_id) {
+ match &child_node.data {
+ NodeData::Element { .. } => return false,
+ NodeData::Text { contents } if !contents.is_empty() => return false,
+ _ => {}
+ }
+ child = child_node.next_sibling;
+ } else {
+ break;
+ }
+ }
+ true
+ })
+ .unwrap_or(true)
+ }
+
+ fn is_root(&self) -> bool {
+ self.tree
+ .with_node(self.node_id, |n| {
+ n.parent
+ .map(|parent_id| {
+ self.tree
+ .with_node(parent_id, |p| p.is_document())
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ })
+ .unwrap_or(false)
+ }
+
+ fn ignores_nth_child_selectors(&self) -> bool {
+ false
+ }
+
+ fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool {
+ false
+ }
+}
+
+pub fn parse_selector(selector: &str) -> Result, String> {
+ let mut parser_input = cssparser::ParserInput::new(selector);
+ let mut parser = cssparser::Parser::new(&mut parser_input);
+ SelectorList::parse(&ObscuraSelectorParser, &mut parser, ParseRelative::No)
+ .map_err(|e| format!("Failed to parse selector '{}': {:?}", selector, e))
+}
+
+impl DomTree {
+ pub fn query_selector(&self, selector: &str) -> Result