Max/smolmix wasm (#6784)
* Mod gitignore + license trimming + comment trimming
* Big rewrite
* SURB inputs + DNS button in internal-dev
* Make ipr addr optional
* Accidentatly omitted files from rewrite commit
* Makefile + readme
* Comment rewrite
* Optimisation comment
* Replace manual waker map with
smoltcp built-ins + adaptive poll
* Comments
* Extract socket creation helpers into stream.rs
* Cleanup comments
* Comment
* Comment notes and restrict ciphersuites wrt rustls-rustcrypto
* Dep. hack fix for demo + add clearnet fetch() for contrast
* Stripped down devtester
* Fix Clippy arg (fatfingered deletion)
* CodeRabbit catches
* Cargofmt
* Review nits: bridge logs, fetch early-return, static port counter, copyright years, README + Cargo + headless.js tidying
* PHONY + taskset override, switch internal-dev/tests to pnpm, fix wasm-pack out-dir
* Gate codec tests behind the codec feature for no-default-features builds
* IPv6 addr/route on smoltcp iface + configurable DNS resolvers via TunnelOpts
* DNS GUI inputs, close stale WS on reconnect, worker init guards + ws-send warning, Playwright listener cleanup, pnpm-lock in internal-dev
* Fix lp -> lp-data after rebase
* Revert nym-lp/nym-lp-data feature-gating left over from rebase
* Lift getrandom wasm_js cfg to workspace .cargo/config.toml so cargo check -p smolmix-wasm works from any CWD
* temp will amend git message
* Auto-discover IPR when none specified + 'Use random IPR' checkbox in internal-dev
* smolmix_tracker + State machine + ready_tunnel gate + getTunnelState JS surface
* Mirror red display() entries to console.error
* Add left out package-lock
* Reactor clock + yield_now + atomic seq + gateway-storage errors
* setupMixTunnel gate + MTU 1980 + http::Uri cleanup
* Review pass + fix test + clippy
* restore axum 0.8 bump from borked earlier merge
* Feature gating (dns/fetch/socket) + TunnelOptsBuilder + pnpm bypass
* Cont. with review comments
* tokio Nofity reactor wakes + cancellation + setup polishing
* Notify wakes + inner pattern + close_notify + util
* Tunable tunnelopts
* Fix tired commit
* CI prep
* Lint + Clippy
* coderabbit u32 fix
* nits + runtime debugging + expose in internal-dev
* remove redudant default-features
* Remove more redundant default-features
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
|
||||
Generated
+154
-64
@@ -630,7 +630,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
@@ -672,7 +672,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
@@ -695,7 +695,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
@@ -718,9 +718,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "axum-test"
|
||||
version = "20.0.0"
|
||||
version = "20.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7"
|
||||
checksum = "43c6a2f1d97ee33c39f13dacc0f84ae781a9c2ed373a75bad1129094f5a7c4bd"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -728,7 +728,7 @@ dependencies = [
|
||||
"bytesize",
|
||||
"cookie",
|
||||
"expect-json",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"hyper-util",
|
||||
@@ -1429,7 +1429,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39d2056bf065c8b4bce5a8898d40e175211ff4410add2a84d695845d3937c729"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2331,10 +2331,10 @@ dependencies = [
|
||||
"ip_network",
|
||||
"ip_network_table",
|
||||
"libc",
|
||||
"nix 0.31.1",
|
||||
"nix 0.31.3",
|
||||
"parking_lot",
|
||||
"ring",
|
||||
"socket2 0.6.0",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uniffi 0.31.0",
|
||||
@@ -2938,9 +2938,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "expect-json"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4"
|
||||
checksum = "5e80819dbfe83c8a651f5344b08910d0037dac72988aef27ee4e6bedd7ae2e33"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"email_address",
|
||||
@@ -2956,9 +2956,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "expect-json-macros"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9"
|
||||
checksum = "c0637949cd816934f3b7aab44ff98e7ec1fb903c379e07dcb9eac943ec33499e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3202,6 +3202,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-rustls"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb"
|
||||
dependencies = [
|
||||
"futures-io",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@@ -3341,7 +3352,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"gloo-utils 0.2.0",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"js-sys",
|
||||
"pin-project",
|
||||
"serde",
|
||||
@@ -3442,7 +3453,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
@@ -3593,7 +3604,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1",
|
||||
@@ -3605,7 +3616,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3678,7 +3689,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"h2 0.4.11",
|
||||
"hickory-proto",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni 0.22.4",
|
||||
@@ -3829,9 +3840,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -3855,7 +3866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3866,7 +3877,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -3966,7 +3977,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.11",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
@@ -3997,7 +4008,7 @@ version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"hyper 1.9.0",
|
||||
"hyper-util",
|
||||
"rustls 0.23.37",
|
||||
@@ -4032,14 +4043,14 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.9.0",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.0",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -4792,9 +4803,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libcrux-aesgcm"
|
||||
@@ -5394,13 +5405,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5611,9 +5622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.1"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
@@ -7104,7 +7115,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"fastrand",
|
||||
"hickory-resolver",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"inventory",
|
||||
"itertools 0.14.0",
|
||||
"mime",
|
||||
@@ -7229,7 +7240,6 @@ dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
@@ -8154,7 +8164,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"httpcodec",
|
||||
"log",
|
||||
"nym-bandwidth-controller",
|
||||
@@ -9260,7 +9270,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"opentelemetry",
|
||||
"reqwest 0.12.22",
|
||||
]
|
||||
@@ -9271,7 +9281,7 @@ version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
"opentelemetry-proto",
|
||||
@@ -9626,6 +9636,16 @@ dependencies = [
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs5"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
@@ -9633,6 +9653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"pkcs5",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
@@ -10341,7 +10362,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.4.11",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
@@ -10382,7 +10403,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
@@ -10556,7 +10577,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"mime",
|
||||
"rand 0.10.1",
|
||||
"thiserror 2.0.18",
|
||||
@@ -10752,6 +10773,36 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-rustcrypto"
|
||||
version = "0.0.2-alpha"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f12052947763ab8515f753315357599e9b0b4dab3b8ba15f30f725fe6d025557"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes-gcm",
|
||||
"chacha20poly1305",
|
||||
"crypto-common 0.1.6",
|
||||
"der 0.7.10",
|
||||
"digest 0.10.7",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"hmac",
|
||||
"p256",
|
||||
"p384",
|
||||
"paste",
|
||||
"pkcs8 0.10.2",
|
||||
"rand_core 0.6.4",
|
||||
"rsa",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.8",
|
||||
"sec1",
|
||||
"sha2 0.10.9",
|
||||
"signature 2.2.0",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
@@ -11128,9 +11179,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -11530,6 +11581,45 @@ dependencies = [
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smolmix-wasm"
|
||||
version = "1.21.0"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"bytes",
|
||||
"futures",
|
||||
"futures-rustls",
|
||||
"getrandom 0.2.16",
|
||||
"getrandom 0.4.1",
|
||||
"hickory-proto",
|
||||
"http 1.4.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
"js-sys",
|
||||
"nym-bin-common",
|
||||
"nym-ip-packet-requests",
|
||||
"nym-lp-data",
|
||||
"nym-validator-client",
|
||||
"nym-wasm-client-core",
|
||||
"nym-wasm-utils",
|
||||
"rand 0.8.6",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"rustls-rustcrypto",
|
||||
"semver 1.0.27",
|
||||
"serde",
|
||||
"serde-wasm-bindgen 0.6.5",
|
||||
"smoltcp",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tsify",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasmtimer",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.12.0"
|
||||
@@ -11596,12 +11686,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.0"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12126,7 +12216,7 @@ dependencies = [
|
||||
"camino",
|
||||
"crossbeam-channel",
|
||||
"home",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"libc",
|
||||
"memchr",
|
||||
"rayon",
|
||||
@@ -12459,17 +12549,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio 1.0.4",
|
||||
"mio 1.2.0",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.0",
|
||||
"socket2 0.6.3",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -12477,9 +12567,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -12770,7 +12860,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"h2 0.4.11",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
@@ -12799,7 +12889,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"h2 0.4.11",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.9.0",
|
||||
@@ -12808,7 +12898,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"socket2 0.6.0",
|
||||
"socket2 0.6.3",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.2",
|
||||
@@ -12860,7 +12950,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
@@ -13129,7 +13219,7 @@ dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.0",
|
||||
"http 1.4.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.6",
|
||||
@@ -13181,9 +13271,9 @@ checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "typetag"
|
||||
version = "0.2.21"
|
||||
version = "0.2.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf"
|
||||
checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c"
|
||||
dependencies = [
|
||||
"erased-serde",
|
||||
"inventory",
|
||||
@@ -13194,9 +13284,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typetag-impl"
|
||||
version = "0.2.21"
|
||||
version = "0.2.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846"
|
||||
checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -13668,9 +13758,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.21.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"js-sys",
|
||||
|
||||
+15
-6
@@ -175,6 +175,7 @@ members = [
|
||||
"tools/ts-rs-cli",
|
||||
"wasm/client",
|
||||
"wasm/mix-fetch",
|
||||
"wasm/smolmix",
|
||||
"wasm/zknym-lib",
|
||||
"nym-network-monitor-v3/nym-network-monitor-orchestrator",
|
||||
"nym-network-monitor-v3/nym-network-monitor-agent",
|
||||
@@ -225,6 +226,7 @@ anyhow = "1.0.98"
|
||||
arc-swap = "1.7.1"
|
||||
argon2 = "0.5.0"
|
||||
async-trait = "0.1.88"
|
||||
async-tungstenite = { version = "0.24", default-features = false }
|
||||
axum = "0.8.9"
|
||||
axum-client-ip = "1.3.1"
|
||||
axum-extra = "0.12.6"
|
||||
@@ -278,24 +280,27 @@ eyre = "0.6.9"
|
||||
fastrand = "2.1.1"
|
||||
flate2 = "1.1.1"
|
||||
futures = "0.3.31"
|
||||
futures-rustls = { version = "0.26", default-features = false }
|
||||
futures-util = "0.3"
|
||||
generic-array = "0.14.7"
|
||||
getrandom = "0.2.10"
|
||||
getrandom03 = { package = "getrandom", version = "=0.3.3" }
|
||||
getrandom04 = { package = "getrandom", version = "0.4" }
|
||||
glob = "0.3"
|
||||
handlebars = "3.5.5"
|
||||
hex = "0.4.3"
|
||||
hickory-proto = "0.26.1"
|
||||
hickory-proto = { version = "0.26.1", default-features = false }
|
||||
hickory-resolver = "0.26.1"
|
||||
hkdf = "0.12.3"
|
||||
hmac = "0.12.1"
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
httparse = "1.10"
|
||||
httpcodec = "0.2.3"
|
||||
human-repr = "1.1.0"
|
||||
humantime = "2.2.0"
|
||||
humantime-serde = "1.1.1"
|
||||
hyper = "1.6.0"
|
||||
hyper = { version = "1.6.0", default-features = false }
|
||||
hyper-util = "0.1"
|
||||
indicatif = "0.18.0"
|
||||
inquire = "0.6.2"
|
||||
@@ -341,6 +346,8 @@ regex = "1.10.6"
|
||||
reqwest = { version = "0.13.1", default-features = false }
|
||||
rs_merkle = "1.5.0"
|
||||
rustls = { version = "0.23.37", default-features = false }
|
||||
rustls-pki-types = "1"
|
||||
rustls-rustcrypto = "0.0.2-alpha"
|
||||
schemars = "0.8.22"
|
||||
semver = "1.0.26"
|
||||
serde = "1.0.219"
|
||||
@@ -354,6 +361,7 @@ serde_yaml = "0.9.25"
|
||||
serde_plain = "1.0.2"
|
||||
sha2 = "0.10.3"
|
||||
si-scale = "0.2.3"
|
||||
simple-dns = "0.7"
|
||||
smoltcp = "0.12"
|
||||
snow = "0.9.6"
|
||||
sphinx-packet = "=0.6.0"
|
||||
@@ -377,7 +385,7 @@ tokio-test = "0.4.4"
|
||||
tokio-tun = "0.11.5"
|
||||
tokio-rustls = "0.26"
|
||||
tokio-smoltcp = "0.5"
|
||||
tokio-tungstenite = { version = "0.20.1" }
|
||||
tokio-tungstenite = "0.20.1"
|
||||
tokio-util = "0.7.15"
|
||||
toml = "0.8.22"
|
||||
tower = "0.5.2"
|
||||
@@ -597,12 +605,14 @@ opt-level = 3
|
||||
# lto = true
|
||||
opt-level = 'z'
|
||||
|
||||
|
||||
|
||||
[profile.release.package.mix-fetch-wasm]
|
||||
# lto = true
|
||||
opt-level = 'z'
|
||||
|
||||
[profile.release.package.smolmix-wasm]
|
||||
# lto = true
|
||||
opt-level = 'z'
|
||||
|
||||
[workspace.lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tokio_unstable)'] }
|
||||
|
||||
@@ -620,4 +630,3 @@ exit = "deny"
|
||||
panic = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ sdk-wasm: sdk-wasm-build sdk-wasm-test sdk-wasm-lint
|
||||
sdk-wasm-build:
|
||||
$(MAKE) -C wasm/client
|
||||
$(MAKE) -C wasm/mix-fetch
|
||||
$(MAKE) -C wasm/smolmix
|
||||
# $(MAKE) -C wasm/zknym-lib
|
||||
|
||||
# run this from npm/yarn to ensure tools are in the path, e.g. yarn build:sdk from root of repo
|
||||
@@ -124,6 +125,7 @@ sdk-wasm-test:
|
||||
sdk-wasm-lint:
|
||||
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo clippy $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings
|
||||
$(MAKE) -C wasm/mix-fetch check-fmt
|
||||
$(MAKE) -C wasm/smolmix check-fmt
|
||||
|
||||
# Add to top-level targets
|
||||
build: sdk-wasm-build
|
||||
|
||||
@@ -14,6 +14,8 @@ publish = true
|
||||
|
||||
|
||||
[features]
|
||||
default = ["codec"]
|
||||
codec = ["dep:tokio-util"]
|
||||
test-utils = ["pnet_packet"]
|
||||
|
||||
[dependencies]
|
||||
@@ -29,6 +31,5 @@ semver = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tokio-util = { workspace = true, features = ["codec"] }
|
||||
tokio-util = { workspace = true, features = ["codec"], optional = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::{Buf, Bytes, BytesMut};
|
||||
#[cfg(feature = "codec")]
|
||||
use tokio_util::codec::{Decoder, Encoder};
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
@@ -38,6 +39,23 @@ impl MultiIpPacketCodec {
|
||||
bundled_packets.extend_from_slice(&packet);
|
||||
bundled_packets.freeze()
|
||||
}
|
||||
|
||||
/// Decode one length-prefixed packet from `src`, advancing past it.
|
||||
///
|
||||
/// Same logic as the `Decoder` impl but available without the `codec`
|
||||
/// feature (i.e. without depending on `tokio-util`).
|
||||
pub fn decode_one(&mut self, src: &mut BytesMut) -> Result<Option<IprPacket>, Error> {
|
||||
if src.len() < LENGTH_PREFIX_SIZE {
|
||||
return Ok(None);
|
||||
}
|
||||
let packet_size = u16::from_be_bytes([src[0], src[1]]) as usize;
|
||||
if src.len() < packet_size + LENGTH_PREFIX_SIZE {
|
||||
return Ok(None);
|
||||
}
|
||||
src.advance(LENGTH_PREFIX_SIZE);
|
||||
let packet = src.split_to(packet_size);
|
||||
Ok(Some(IprPacket::Data(packet.freeze())))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MultiIpPacketCodec {
|
||||
@@ -82,6 +100,7 @@ impl From<Vec<u8>> for IprPacket {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "codec")]
|
||||
impl Encoder<IprPacket> for MultiIpPacketCodec {
|
||||
type Error = Error;
|
||||
|
||||
@@ -125,6 +144,7 @@ impl Encoder<IprPacket> for MultiIpPacketCodec {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "codec")]
|
||||
impl Decoder for MultiIpPacketCodec {
|
||||
type Item = IprPacket;
|
||||
type Error = Error;
|
||||
@@ -152,7 +172,7 @@ impl Decoder for MultiIpPacketCodec {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "codec"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use tokio_util::codec::Decoder;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
@@ -84,7 +83,7 @@ pub fn handle_ipr_response(data: &[u8]) -> Option<MixnetMessageOutcome> {
|
||||
let mut buf = BytesMut::from(data_response.ip_packet.as_ref());
|
||||
let mut packets = Vec::new();
|
||||
loop {
|
||||
match codec.decode(&mut buf) {
|
||||
match codec.decode_one(&mut buf) {
|
||||
Ok(Some(packet)) => packets.push(packet.into_bytes()),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ bytes.workspace = true
|
||||
futures.workspace = true
|
||||
nym-config = { workspace = true }
|
||||
nym-common = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true, default-features = true }
|
||||
nym-sdk = { workspace = true }
|
||||
pnet_packet.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -23,7 +23,7 @@ tracing = { workspace = true }
|
||||
nym-authenticator-requests = { workspace = true }
|
||||
nym-credentials-interface = { workspace = true }
|
||||
nym-crypto = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true, default-features = true }
|
||||
nym-sphinx = { workspace = true }
|
||||
nym-wireguard-types = { workspace = true }
|
||||
nym-kkt-ciphersuite = { workspace = true }
|
||||
|
||||
@@ -23,4 +23,4 @@ indexed_db_futures = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
nym-store-cipher = { workspace = true, features = ["json"] }
|
||||
nym-wasm-utils = { workspace = true, default-features = false }
|
||||
nym-wasm-utils = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2021 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg(feature = "websocket")]
|
||||
@@ -11,6 +12,28 @@ pub mod crypto;
|
||||
|
||||
pub mod error;
|
||||
|
||||
#[doc(hidden)]
|
||||
pub static DEBUG_LOGGING_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Enable or disable `debug_log!` / `debug_error!` output at runtime.
|
||||
///
|
||||
/// Consumers call this from their own init path when their own `debug`
|
||||
/// (or equivalent) feature is on. The default is `false`, so the macros
|
||||
/// compile to a single relaxed-load + branch and otherwise no-op.
|
||||
///
|
||||
/// Also exposed to JS as `setDebugLogging(enabled: boolean)` for ad-hoc
|
||||
/// toggling without rebuilding.
|
||||
#[wasm_bindgen(js_name = "setDebugLogging")]
|
||||
pub fn set_debug_logging(enabled: bool) {
|
||||
DEBUG_LOGGING_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the current debug-logging state. Used by the macros below.
|
||||
#[inline]
|
||||
pub fn debug_logging_enabled() -> bool {
|
||||
DEBUG_LOGGING_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// will cause messages to be written as if console.log("...") was called
|
||||
#[macro_export]
|
||||
macro_rules! console_log {
|
||||
@@ -41,6 +64,48 @@ macro_rules! console_error {
|
||||
($($t:tt)*) => ($crate::error(&format_args!($($t)*).to_string()))
|
||||
}
|
||||
|
||||
/// `console.log` gated behind the runtime [`DEBUG_LOGGING_ENABLED`] flag.
|
||||
///
|
||||
/// Format-args evaluation stays inside the `if` arm, so it's skipped at
|
||||
/// runtime when logging is off. Consumers turn this on via
|
||||
/// [`set_debug_logging`] from their own init path (typically when their
|
||||
/// own `debug` feature is enabled).
|
||||
#[macro_export]
|
||||
macro_rules! debug_log {
|
||||
($($t:tt)*) => {{
|
||||
if $crate::debug_logging_enabled() {
|
||||
$crate::console_log!($($t)*);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// `console.error` gated behind the runtime [`DEBUG_LOGGING_ENABLED`] flag.
|
||||
/// See [`debug_log!`] for semantics.
|
||||
#[macro_export]
|
||||
macro_rules! debug_error {
|
||||
($($t:tt)*) => {{
|
||||
if $crate::debug_logging_enabled() {
|
||||
$crate::console_error!($($t)*);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// Hex preview of a buffer, truncated with ` ...` when over `max_bytes`.
|
||||
/// Useful for `console.log`-style binary debug output.
|
||||
pub fn hex_preview(buf: &[u8], max_bytes: usize) -> String {
|
||||
let len = buf.len().min(max_bytes);
|
||||
let hex: String = buf[..len]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if buf.len() > max_bytes {
|
||||
format!("{hex} ...")
|
||||
} else {
|
||||
hex
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn set_panic_hook() {
|
||||
// When the `console_error_panic_hook` feature is enabled, we can call the
|
||||
|
||||
@@ -57,7 +57,7 @@ nym-crypto = { workspace = true }
|
||||
nym-http-api-client = { path = "../common/http-api-client" }
|
||||
nym-http-api-client-macro = { path = "../common/http-api-client-macro" }
|
||||
nym-ip-packet-client = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true, default-features = true }
|
||||
nym-kkt-ciphersuite = { workspace = true }
|
||||
nym-lp = { path = "../common/nym-lp" }
|
||||
nym-lp-data.workspace = true
|
||||
|
||||
+2
-2
@@ -117,7 +117,7 @@ nym-network-requester = { path = "../service-providers/network-requester" }
|
||||
nym-ip-packet-router = { path = "../service-providers/ip-packet-router" }
|
||||
|
||||
# LP dependencies
|
||||
nym-lp = { workspace = true }
|
||||
nym-lp = { workspace = true, default-features = true }
|
||||
nym-lp-data.workspace = true
|
||||
nym-registration-common = { path = "../common/registration" }
|
||||
bincode = { workspace = true }
|
||||
@@ -142,7 +142,7 @@ harness = false
|
||||
cargo_metadata = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
nym-lp = { workspace = true, features = ["mock"] }
|
||||
nym-lp = { workspace = true, default-features = true, features = ["mock"] }
|
||||
criterion = { workspace = true, features = ["async_tokio"] }
|
||||
nym-test-utils = { workspace = true }
|
||||
|
||||
|
||||
Generated
+13
-556
@@ -4,549 +4,6 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
'@babel/core':
|
||||
specifier: ^7.22.10
|
||||
version: 7.29.0
|
||||
'@babel/helper-simple-access':
|
||||
specifier: ^7.25.9
|
||||
version: 7.27.1
|
||||
'@babel/plugin-transform-async-to-generator':
|
||||
specifier: ^7.22.5
|
||||
version: 7.28.6
|
||||
'@babel/preset-env':
|
||||
specifier: ^7.22.10
|
||||
version: 7.29.5
|
||||
'@babel/preset-react':
|
||||
specifier: ^7.14.5
|
||||
version: 7.28.5
|
||||
'@babel/preset-typescript':
|
||||
specifier: ^7.22.5
|
||||
version: 7.28.5
|
||||
'@babel/runtime':
|
||||
specifier: ^7.29.2
|
||||
version: 7.29.2
|
||||
'@cosmjs/cosmwasm-stargate':
|
||||
specifier: ^0.32.4
|
||||
version: 0.32.4
|
||||
'@cosmjs/math':
|
||||
specifier: ^0.32.4
|
||||
version: 0.32.4
|
||||
'@cosmjs/stargate':
|
||||
specifier: ^0.32.4
|
||||
version: 0.32.4
|
||||
'@emotion/cache':
|
||||
specifier: ^11.14.0
|
||||
version: 11.14.0
|
||||
'@emotion/hash':
|
||||
specifier: ^0.9.2
|
||||
version: 0.9.2
|
||||
'@emotion/is-prop-valid':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@emotion/memoize':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@emotion/react':
|
||||
specifier: ^11.13.5
|
||||
version: 11.14.0
|
||||
'@emotion/serialize':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
'@emotion/sheet':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@emotion/styled':
|
||||
specifier: ^11.13.5
|
||||
version: 11.14.1
|
||||
'@emotion/unitless':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
'@emotion/use-insertion-effect-with-fallbacks':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@emotion/utils':
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2
|
||||
'@emotion/weak-memoize':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
'@hookform/resolvers':
|
||||
specifier: ^2.8.0
|
||||
version: 2.9.11
|
||||
'@mui/base':
|
||||
specifier: 5.0.0-beta.40
|
||||
version: 5.0.0-beta.40
|
||||
'@mui/icons-material':
|
||||
specifier: ^5.2.0
|
||||
version: 5.18.0
|
||||
'@mui/lab':
|
||||
specifier: 5.0.0-alpha.170
|
||||
version: 5.0.0-alpha.170
|
||||
'@mui/material':
|
||||
specifier: ^5.2.2
|
||||
version: 5.18.0
|
||||
'@mui/private-theming':
|
||||
specifier: ^5.17.1
|
||||
version: 5.17.1
|
||||
'@mui/styles':
|
||||
specifier: ^5.18.0
|
||||
version: 5.18.0
|
||||
'@mui/system':
|
||||
specifier: ^5.18.0
|
||||
version: 5.18.0
|
||||
'@mui/utils':
|
||||
specifier: ^5.7.0
|
||||
version: 5.17.1
|
||||
'@mui/x-tree-view':
|
||||
specifier: ^7.11.1
|
||||
version: 7.29.10
|
||||
'@pmmmwh/react-refresh-webpack-plugin':
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.17
|
||||
'@popperjs/core':
|
||||
specifier: ^2.11.8
|
||||
version: 2.11.8
|
||||
'@remix-run/router':
|
||||
specifier: ^1.23.2
|
||||
version: 1.23.2
|
||||
'@storybook/addon-actions':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/addon-docs':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/addon-essentials':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/addon-interactions':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/addon-links':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/builder-webpack5':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/manager-webpack5':
|
||||
specifier: ^6.5.8
|
||||
version: 6.5.16
|
||||
'@storybook/react':
|
||||
specifier: ^6.5.15
|
||||
version: 6.5.16
|
||||
'@storybook/testing-library':
|
||||
specifier: ^0.0.9
|
||||
version: 0.0.9
|
||||
'@svgr/webpack':
|
||||
specifier: ^6.1.1
|
||||
version: 6.5.1
|
||||
'@tanstack/query-core':
|
||||
specifier: ^5.64.2
|
||||
version: 5.100.10
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.64.2
|
||||
version: 5.100.10
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/cli':
|
||||
specifier: ^2.10.1
|
||||
version: 2.11.1
|
||||
'@tauri-apps/plugin-clipboard-manager':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
'@tauri-apps/plugin-opener':
|
||||
specifier: ^2.5.3
|
||||
version: 2.5.4
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/tauri-forage':
|
||||
specifier: ^1.0.0-beta.2
|
||||
version: 1.0.0-beta.2
|
||||
'@testing-library/dom':
|
||||
specifier: ^10.4.1
|
||||
version: 10.4.1
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2
|
||||
'@types/big.js':
|
||||
specifier: ^6.1.6
|
||||
version: 6.2.2
|
||||
'@types/bs58':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.4
|
||||
'@types/flat':
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.5
|
||||
'@types/jest':
|
||||
specifier: ^29.5.14
|
||||
version: 29.5.14
|
||||
'@types/lodash':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.24
|
||||
'@types/minimatch':
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2
|
||||
'@types/qrcode.react':
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.5
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.14
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3
|
||||
'@types/semver':
|
||||
specifier: ^7.3.8
|
||||
version: 7.7.1
|
||||
'@types/uuid':
|
||||
specifier: ^8.3.4
|
||||
version: 8.3.4
|
||||
'@types/zxcvbn':
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.5
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^5.13.0
|
||||
version: 5.62.0
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^5.13.0
|
||||
version: 5.62.0
|
||||
axios:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0
|
||||
babel-loader:
|
||||
specifier: ^8.3.0
|
||||
version: 8.4.1
|
||||
base-x:
|
||||
specifier: ^3.0.11
|
||||
version: 3.0.11
|
||||
base64-js:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
bech32:
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4
|
||||
big.js:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.2
|
||||
bn.js:
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.3
|
||||
bs58:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
buffer:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
clean-webpack-plugin:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
clipboard-copy:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
clsx:
|
||||
specifier: ^1.1.1
|
||||
version: 1.2.1
|
||||
colornames:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
css-loader:
|
||||
specifier: ^6.8.1
|
||||
version: 6.11.0
|
||||
css-minimizer-webpack-plugin:
|
||||
specifier: ^3.0.2
|
||||
version: 3.4.1
|
||||
d3-array:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4
|
||||
d3-color:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
d3-format:
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
d3-interpolate:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
d3-path:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
d3-scale:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
d3-shape:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
d3-time:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
d3-time-format:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
date-fns:
|
||||
specifier: ^2.28.0
|
||||
version: 2.30.0
|
||||
decimal.js-light:
|
||||
specifier: ^2.5.1
|
||||
version: 2.5.1
|
||||
dom-helpers:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
dotenv-webpack:
|
||||
specifier: ^7.0.3
|
||||
version: 7.1.1
|
||||
eslint:
|
||||
specifier: ^9.26.0
|
||||
version: 9.39.4
|
||||
eslint-config-airbnb:
|
||||
specifier: ^19.0.4
|
||||
version: 19.0.4
|
||||
eslint-config-airbnb-typescript:
|
||||
specifier: ^16.1.0
|
||||
version: 16.2.0
|
||||
eslint-config-prettier:
|
||||
specifier: ^8.5.0
|
||||
version: 8.10.2
|
||||
eslint-import-resolver-root-import:
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4
|
||||
eslint-plugin-import:
|
||||
specifier: ^2.25.4
|
||||
version: 2.32.0
|
||||
eslint-plugin-jest:
|
||||
specifier: ^26.1.1
|
||||
version: 26.9.0
|
||||
eslint-plugin-jsx-a11y:
|
||||
specifier: ^6.5.1
|
||||
version: 6.10.2
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^4.0.0
|
||||
version: 4.2.5
|
||||
eslint-plugin-react:
|
||||
specifier: ^7.29.2
|
||||
version: 7.37.5
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^4.3.0
|
||||
version: 4.6.2
|
||||
eslint-plugin-storybook:
|
||||
specifier: ^0.5.12
|
||||
version: 0.5.13
|
||||
eventemitter3:
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7
|
||||
fast-equals:
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0
|
||||
favicons:
|
||||
specifier: ^7.0.2
|
||||
version: 7.2.0
|
||||
favicons-webpack-plugin:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
file-loader:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0
|
||||
flat:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
fork-ts-checker-webpack-plugin:
|
||||
specifier: ^7.2.1
|
||||
version: 7.3.0
|
||||
hex-rgb:
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
hoist-non-react-statics:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
html-webpack-plugin:
|
||||
specifier: ^5.5.3
|
||||
version: 5.6.7
|
||||
ieee754:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
internmap:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
jest:
|
||||
specifier: ^27.1.0
|
||||
version: 27.5.1
|
||||
joi:
|
||||
specifier: ^17.11.0
|
||||
version: 17.13.3
|
||||
localforage:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
lodash.padend:
|
||||
specifier: ^4.6.1
|
||||
version: 4.6.1
|
||||
lodash.trimstart:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1
|
||||
lodash.words:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
long:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
mini-css-extract-plugin:
|
||||
specifier: ^2.7.6
|
||||
version: 2.10.2
|
||||
nanoclone:
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1
|
||||
notistack:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.8
|
||||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
prettier:
|
||||
specifier: ^2.8.7
|
||||
version: 2.8.8
|
||||
prop-types:
|
||||
specifier: ^15.8.1
|
||||
version: 15.8.1
|
||||
property-expr:
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6
|
||||
qr.js:
|
||||
specifier: 0.0.0
|
||||
version: 0.0.0
|
||||
ramda:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.0
|
||||
react:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6
|
||||
react-dom:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6
|
||||
react-error-boundary:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.4
|
||||
react-hook-form:
|
||||
specifier: ^7.14.2
|
||||
version: 7.75.0
|
||||
react-is:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6
|
||||
react-refresh:
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
react-refresh-typescript:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.12
|
||||
react-router:
|
||||
specifier: ^6.30.3
|
||||
version: 6.30.3
|
||||
react-router-dom:
|
||||
specifier: '6'
|
||||
version: 6.30.3
|
||||
react-smooth:
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
react-transition-group:
|
||||
specifier: ^4.4.5
|
||||
version: 4.4.5
|
||||
recharts:
|
||||
specifier: ^2.1.13
|
||||
version: 2.15.4
|
||||
recharts-scale:
|
||||
specifier: ^0.4.5
|
||||
version: 0.4.5
|
||||
rgb-hex:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
rimraf:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
safe-buffer:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
scheduler:
|
||||
specifier: ^0.27.0
|
||||
version: 0.27.0
|
||||
semver:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.1
|
||||
string-to-color:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
style-loader:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.4
|
||||
stylis:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
thread-loader:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
tiny-invariant:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3
|
||||
toposort:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
ts-jest:
|
||||
specifier: ^27.0.5
|
||||
version: 27.1.5
|
||||
ts-loader:
|
||||
specifier: ^9.4.4
|
||||
version: 9.5.7
|
||||
tsconfig-paths-webpack-plugin:
|
||||
specifier: ^3.5.2
|
||||
version: 3.5.2
|
||||
tweetnacl:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
tweetnacl-util:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
url-loader:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
use-clipboard-copy:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
uuid:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2
|
||||
victory-vendor:
|
||||
specifier: ^36.9.2
|
||||
version: 36.9.2
|
||||
webpack:
|
||||
specifier: ^5.88.2
|
||||
version: 5.106.2
|
||||
webpack-cli:
|
||||
specifier: ^4.8.0
|
||||
version: 4.10.0
|
||||
webpack-dev-server:
|
||||
specifier: ^4.15.1
|
||||
version: 4.15.2
|
||||
webpack-favicons:
|
||||
specifier: ^1.3.8
|
||||
version: 1.5.43
|
||||
webpack-merge:
|
||||
specifier: ^5.9.0
|
||||
version: 5.10.0
|
||||
yup:
|
||||
specifier: ^0.32.9
|
||||
version: 0.32.11
|
||||
zxcvbn:
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -563,7 +20,7 @@ importers:
|
||||
version: 5.1.2
|
||||
lerna:
|
||||
specifier: ^7.3.0
|
||||
version: 7.4.2(@types/node@22.19.19)(encoding@0.1.13)
|
||||
version: 7.4.2(encoding@0.1.13)
|
||||
node-gyp:
|
||||
specifier: ^9.3.1
|
||||
version: 9.4.1
|
||||
@@ -16481,12 +15938,10 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.33.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@1.0.3(@types/node@22.19.19)':
|
||||
'@inquirer/external-editor@1.0.3':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
iconv-lite: 0.7.2
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.19
|
||||
|
||||
'@interchain-ui/react@1.26.3(@types/react@19.2.14)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
@@ -16986,7 +16441,7 @@ snapshots:
|
||||
execa: 5.0.0
|
||||
strong-log-transformer: 2.1.0
|
||||
|
||||
'@lerna/create@7.4.2(@types/node@22.19.19)(encoding@0.1.13)(typescript@5.9.3)':
|
||||
'@lerna/create@7.4.2(encoding@0.1.13)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@lerna/child-process': 7.4.2
|
||||
'@npmcli/run-script': 6.0.2
|
||||
@@ -17012,7 +16467,7 @@ snapshots:
|
||||
has-unicode: 2.0.1
|
||||
ini: 1.3.8
|
||||
init-package-json: 5.0.0
|
||||
inquirer: 8.2.7(@types/node@22.19.19)
|
||||
inquirer: 8.2.7
|
||||
is-ci: 3.0.1
|
||||
is-stream: 2.0.0
|
||||
js-yaml: 4.1.0
|
||||
@@ -17585,7 +17040,7 @@ snapshots:
|
||||
lru-cache: 7.18.3
|
||||
npm-pick-manifest: 8.0.2
|
||||
proc-log: 3.0.0
|
||||
promise-inflight: 1.0.1(bluebird@3.7.2)
|
||||
promise-inflight: 1.0.1
|
||||
promise-retry: 2.0.1
|
||||
semver: 7.8.0
|
||||
which: 3.0.1
|
||||
@@ -21372,7 +20827,7 @@ snapshots:
|
||||
minipass-pipeline: 1.2.4
|
||||
mkdirp: 1.0.4
|
||||
p-map: 4.0.0
|
||||
promise-inflight: 1.0.1(bluebird@3.7.2)
|
||||
promise-inflight: 1.0.1
|
||||
rimraf: 3.0.2
|
||||
ssri: 9.0.1
|
||||
tar: 6.2.1
|
||||
@@ -24822,9 +24277,9 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
inquirer@8.2.7(@types/node@22.19.19):
|
||||
inquirer@8.2.7:
|
||||
dependencies:
|
||||
'@inquirer/external-editor': 1.0.3(@types/node@22.19.19)
|
||||
'@inquirer/external-editor': 1.0.3
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
cli-cursor: 3.1.0
|
||||
@@ -26224,10 +25679,10 @@ snapshots:
|
||||
lefthook-windows-arm64: 1.13.6
|
||||
lefthook-windows-x64: 1.13.6
|
||||
|
||||
lerna@7.4.2(@types/node@22.19.19)(encoding@0.1.13):
|
||||
lerna@7.4.2(encoding@0.1.13):
|
||||
dependencies:
|
||||
'@lerna/child-process': 7.4.2
|
||||
'@lerna/create': 7.4.2(@types/node@22.19.19)(encoding@0.1.13)(typescript@5.9.3)
|
||||
'@lerna/create': 7.4.2(encoding@0.1.13)(typescript@5.9.3)
|
||||
'@npmcli/run-script': 6.0.2
|
||||
'@nx/devkit': 16.10.0(nx@16.10.0)
|
||||
'@octokit/plugin-enterprise-rest': 6.0.1
|
||||
@@ -26255,7 +25710,7 @@ snapshots:
|
||||
import-local: 3.1.0
|
||||
ini: 1.3.8
|
||||
init-package-json: 5.0.0
|
||||
inquirer: 8.2.7(@types/node@22.19.19)
|
||||
inquirer: 8.2.7
|
||||
is-ci: 3.0.1
|
||||
is-stream: 2.0.0
|
||||
jest-diff: 29.7.0
|
||||
@@ -28386,6 +27841,8 @@ snapshots:
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
promise-inflight@1.0.1: {}
|
||||
|
||||
promise-inflight@1.0.1(bluebird@3.7.2):
|
||||
optionalDependencies:
|
||||
bluebird: 3.7.2
|
||||
|
||||
@@ -71,7 +71,7 @@ url = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true, default-features = true }
|
||||
semver = { workspace = true }
|
||||
|
||||
# tcpproxy dependencies
|
||||
@@ -104,7 +104,7 @@ tokio-util = { workspace = true, features = ["codec"] }
|
||||
parking_lot = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
pnet_packet = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true, features = ["test-utils"] }
|
||||
nym-ip-packet-requests = { workspace = true, default-features = true, features = ["test-utils"] }
|
||||
|
||||
[features]
|
||||
libp2p-vanilla = []
|
||||
|
||||
@@ -25,7 +25,7 @@ tokio-smoltcp = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
nym-sdk = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true, default-features = true }
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -36,8 +36,8 @@ webpki-roots.workspace = true
|
||||
rustls = { workspace = true, features = ["std", "ring"] }
|
||||
tokio-rustls = { workspace = true }
|
||||
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
|
||||
hickory-proto = { workspace = true }
|
||||
hickory-resolver = { workspace = true, features = ["tokio", "system-config"] }
|
||||
hickory-proto = { workspace = true }
|
||||
hyper = { workspace = true, features = ["client", "http1"] }
|
||||
hyper-util = { workspace = true, features = ["tokio"] }
|
||||
http-body-util = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[build]
|
||||
target = "wasm32-unknown-unknown"
|
||||
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
|
||||
|
||||
[target.wasm32-unknown-unknown]
|
||||
runner = 'wasm-bindgen-test-runner'
|
||||
@@ -0,0 +1 @@
|
||||
connection-notes.md
|
||||
@@ -0,0 +1,118 @@
|
||||
[package]
|
||||
name = "smolmix-wasm"
|
||||
version = "1.21.0"
|
||||
authors = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
publish = false
|
||||
keywords = ["nym", "fetch", "wasm", "mixnet", "privacy"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
futures = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# `sync` feature only: gives us `tokio::sync::Notify` (the reactor wake source)
|
||||
# without pulling in tokio's runtime (wasm32-incompatible).
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
url = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
wasmtimer = { workspace = true }
|
||||
|
||||
# DNS wire-format types (always present; internal DNS resolution is needed
|
||||
# even when only `fetch` or `socket` is enabled, for hostname → IP).
|
||||
hickory-proto = { workspace = true, features = ["std"] }
|
||||
|
||||
# TLS stack: gated by the `_tls` private feature (pulled by `fetch` or `socket`).
|
||||
# rustls-rustcrypto is the only viable provider on wasm32-unknown-unknown:
|
||||
# ring + aws-lc-rs both fail to compile (no OS entropy / no C toolchain in browser).
|
||||
rustls = { workspace = true, features = ["std"], optional = true }
|
||||
rustls-pki-types = { workspace = true, features = ["web"], optional = true }
|
||||
futures-rustls = { workspace = true, features = ["tls12"], optional = true }
|
||||
webpki-roots = { workspace = true, optional = true }
|
||||
rustls-rustcrypto = { workspace = true, optional = true }
|
||||
|
||||
# HTTP/1.1 client (tokio-free; workspace declares default-features = false).
|
||||
# Gated by `fetch`: `mixSocket` does its upgrade handshake via async-tungstenite directly.
|
||||
hyper = { workspace = true, features = ["client", "http1"], optional = true }
|
||||
http-body-util = { workspace = true, optional = true }
|
||||
http = { workspace = true, optional = true }
|
||||
|
||||
# WebSocket (RFC 6455 client over futures::io streams). Gated by `socket`.
|
||||
async-tungstenite = { workspace = true, features = ["handshake"], optional = true }
|
||||
|
||||
# WASM utils (panic hook, console_log)
|
||||
nym-wasm-utils = { workspace = true }
|
||||
|
||||
# Tunnel: smoltcp stack + Nym mixnet client + IPR protocol
|
||||
smoltcp = { workspace = true, features = ["std", "medium-ip", "proto-ipv4", "socket-tcp", "socket-udp", "async"] }
|
||||
nym-wasm-client-core = { workspace = true }
|
||||
nym-ip-packet-requests = { workspace = true }
|
||||
# LP wire types (frame, header, codec)
|
||||
nym-lp-data = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
nym-bin-common = { workspace = true }
|
||||
# IPR auto-discovery (`NymApiClientExt` + `semver` gate for v9-capable IPRs).
|
||||
nym-validator-client = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
|
||||
# JS interop: typed `SetupOpts` deserialisation.
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde-wasm-bindgen = { workspace = true }
|
||||
tsify = { workspace = true, features = ["js"] }
|
||||
|
||||
# crypto.getRandomValues() backend for the 0.2 line.
|
||||
[target."cfg(target_arch = \"wasm32\")".dependencies.getrandom]
|
||||
workspace = true
|
||||
features = ["js"]
|
||||
|
||||
# The 0.4 line is pulled in transitively by hickory-proto via rand_core 0.10;
|
||||
# its wasm32 feature was renamed from "js" to "wasm_js".
|
||||
[target."cfg(target_arch = \"wasm32\")".dependencies.getrandom04]
|
||||
workspace = true
|
||||
features = ["wasm_js"]
|
||||
|
||||
[features]
|
||||
# Default: build all three JS entry points (mixFetch, mixSocket, mixResolve).
|
||||
# TS SDK packages that only need a subset can disable defaults and opt in:
|
||||
# smolmix-wasm = { ..., default-features = false, features = ["fetch"] }
|
||||
default = ["dns", "fetch", "socket"]
|
||||
|
||||
# Expose the JS `mixResolve(hostname)` entry point.
|
||||
# Note: the internal DNS resolver (used by fetch/socket for hostname lookups)
|
||||
# is always compiled — only the standalone JS export is gated here.
|
||||
dns = []
|
||||
|
||||
# Expose the JS `mixFetch(url, init)` HTTP client. Pulls in TLS + the hyper-based
|
||||
# HTTP/1.1 stack.
|
||||
fetch = ["_tls", "_http"]
|
||||
|
||||
# Expose the JS `mixSocket(url, protocols, onEvent)` WebSocket client.
|
||||
# Pulls TLS (for `wss://`) + async-tungstenite. Does not pull `_http` because
|
||||
# tungstenite handles the WebSocket upgrade handshake itself.
|
||||
socket = ["_tls", "dep:async-tungstenite"]
|
||||
|
||||
# Internal feature aggregating the rustls-based TLS stack. Shared between
|
||||
# `fetch` (HTTPS) and `socket` (WSS).
|
||||
_tls = [
|
||||
"dep:rustls",
|
||||
"dep:rustls-pki-types",
|
||||
"dep:futures-rustls",
|
||||
"dep:webpki-roots",
|
||||
"dep:rustls-rustcrypto",
|
||||
]
|
||||
|
||||
# Internal feature aggregating hyper + http types. `fetch`-only.
|
||||
_http = ["dep:hyper", "dep:http-body-util", "dep:http"]
|
||||
|
||||
# Verbose `console.log` tracing (off by default).
|
||||
debug = []
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
@@ -0,0 +1,50 @@
|
||||
# `taskset -c 0-11` caps cargo to 12 cores so the build does
|
||||
# not saturate CI runners. Override empty to
|
||||
# disable on machines without taskset (macOS, BSDs, sandboxes):
|
||||
# make TASKSET= build-debug
|
||||
TASKSET ?= taskset -c 0-11
|
||||
|
||||
.PHONY: build build-rust build-debug build-release-opt check-fmt dev dev-build \
|
||||
internal-dev-install test test\:smoke test\:suite
|
||||
|
||||
build: build-rust
|
||||
|
||||
build-rust:
|
||||
$(TASKSET) wasm-pack build --scope nymproject --target web
|
||||
|
||||
build-debug:
|
||||
$(TASKSET) wasm-pack build --debug --scope nymproject --target web --features debug
|
||||
|
||||
build-release-opt:
|
||||
$(TASKSET) wasm-pack build --scope nymproject --target web
|
||||
$(TASKSET) wasm-opt -Oz -o pkg/smolmix_wasm_bg.wasm pkg/smolmix_wasm_bg.wasm
|
||||
|
||||
check-fmt:
|
||||
cargo fmt --check
|
||||
cargo clippy --target wasm32-unknown-unknown -- -Dwarnings
|
||||
cargo clippy --target wasm32-unknown-unknown --features debug -- -Dwarnings
|
||||
|
||||
dev: build-debug
|
||||
cd internal-dev && ./node_modules/.bin/webpack serve --open
|
||||
|
||||
# Build the internal-dev webpack bundle for Playwright.
|
||||
dev-build: build-debug
|
||||
cd internal-dev && ./node_modules/.bin/webpack --mode production
|
||||
|
||||
# One-time setup for internal-dev. Run this after fresh clone or when
|
||||
# internal-dev/package.json changes. Standalone install (no workspace walk).
|
||||
internal-dev-install:
|
||||
cd internal-dev && pnpm install --ignore-workspace
|
||||
|
||||
# Playwright tests (headless browser).
|
||||
# Prereqs: make dev-build && cd tests && pnpm install && pnpm exec playwright install
|
||||
# IPR_ADDRESS env var is optional; tests fall back to the default in
|
||||
# internal-dev/index.html / internal-dev/headless.js when unset.
|
||||
test\:smoke:
|
||||
cd tests && pnpm exec playwright test --project=smoke-chromium --project=smoke-firefox --project=smoke-webkit
|
||||
|
||||
test\:suite:
|
||||
cd tests && pnpm exec playwright test --project=suite-chromium --project=suite-firefox --project=suite-webkit
|
||||
|
||||
test:
|
||||
cd tests && pnpm exec playwright test
|
||||
@@ -0,0 +1,192 @@
|
||||
# smolmix-wasm
|
||||
|
||||
Drop-in browser networking over the Nym mixnet. Routes HTTP and WebSocket
|
||||
traffic through a mixnet tunnel, giving web applications network-level
|
||||
privacy without changing application code.
|
||||
|
||||
## Public API
|
||||
|
||||
Three WASM exports that mirror the browser's native networking surface:
|
||||
|
||||
| Browser API | smolmix export | Description |
|
||||
|-------------|---------------|-------------|
|
||||
| `fetch()` | `mixFetch(url, init)` | HTTP/HTTPS request-response |
|
||||
| `new WebSocket()` | `mixSocket(url, protocols, onEvent)` | WebSocket (WS/WSS) |
|
||||
| (no direct browser equivalent) | `mixResolve(hostname)` | DNS-only hostname lookup over UDP/IPR (no TCP/TLS) |
|
||||
|
||||
## Arch
|
||||
|
||||
```text
|
||||
WasmTunnel
|
||||
+---------- tunnel.rs -----------+
|
||||
| |
|
||||
| Owns: smoltcp stack, Nym |
|
||||
| client, connection pool, |
|
||||
| DNS cache, origin locks |
|
||||
+--------------------------------+
|
||||
| |
|
||||
TCP/UDP sockets |
|
||||
(futures::io) |
|
||||
| |
|
||||
v v
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| Reactor | | Bridge | | Nym Client|
|
||||
| reactor.rs| | bridge.rs | | (base |
|
||||
| | | | | client) |
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| | |
|
||||
v v |
|
||||
+-----------+ +-------+ |
|
||||
| smoltcp | | IPR | |
|
||||
| Interface | |ipr.rs | |
|
||||
+-----------+ +-------+ |
|
||||
| | |
|
||||
v | |
|
||||
+-----------+ | |
|
||||
| Device |<----+ |
|
||||
| device.rs | | |
|
||||
| (virtual | v |
|
||||
| NIC) | LP frames |
|
||||
+-----------+ + SURBs |
|
||||
rx[] / tx[] | |
|
||||
+--------->------+
|
||||
mixnet
|
||||
```
|
||||
|
||||
### Component walkthrough
|
||||
|
||||
- Device (`device.rs`) - the virtual network interface card
|
||||
- Reactor (`reactor.rs`) - the smoltcp poll loop
|
||||
- Bridge (`bridge.rs`) - shuttles packets between the device and the mixnet
|
||||
- IPR (`ipr.rs`) - IP Packet Router protocol layer
|
||||
- WasmTcpStream / WasmUdpSocket / PooledConn (`stream.rs`) - `futures::io::AsyncRead + AsyncWrite` adapters over smoltcp sockets
|
||||
- WASM exports (`lib.rs`, `mixfetch.rs`, `mixsocket.rs`) - the surface JS calls into
|
||||
|
||||
### Tuning
|
||||
|
||||
The JS `setupMixTunnel(opts)` shape accepts the following optional fields for
|
||||
timeouts, buffer sizes, and protocol limits. All have sensible defaults; only
|
||||
override when you have a concrete reason.
|
||||
|
||||
| Field | Default | Notes |
|
||||
|-------------------|---------|----------------------------------------------------------------|
|
||||
| `connectTimeoutMs`| `60000` | IPR connect handshake timeout |
|
||||
| `dnsTimeoutMs` | `30000` | DNS query timeout (per primary/fallback attempt) |
|
||||
| `tcpKeepaliveMs` | `10000` | TCP keepalive probe interval |
|
||||
| `tcpBufferSize` | `65535` | Per-TCP-stream RX/TX buffer; capped at `u16::MAX` |
|
||||
| `maxRedirects` | `5` | `mixFetch` redirect chain depth before bail |
|
||||
|
||||
On the Rust side these live in `TunnelOpts::tuning: TuningOpts`. The builder
|
||||
exposes them flat (`.connect_timeout(d)`, `.tcp_buffer_size(n)`, etc.) so
|
||||
callers don't see the grouping.
|
||||
|
||||
### Feature flags
|
||||
|
||||
The crate is split into three user-facing cargo features matching the JS entry
|
||||
points. Default builds enable all three; downstream TS SDK packages can opt
|
||||
into a subset to drop the corresponding implementation + native deps from the
|
||||
wasm binary.
|
||||
|
||||
| Feature | JS export | Pulls |
|
||||
|---------|-----------------|----------------------------------------------------|
|
||||
| `dns` | `mixResolve` | (nothing extra; DNS resolver is always compiled) |
|
||||
| `fetch` | `mixFetch` | rustls TLS stack + hyper HTTP/1.1 client |
|
||||
| `socket`| `mixSocket` | rustls TLS stack + async-tungstenite |
|
||||
|
||||
Build a `dns`-only client:
|
||||
|
||||
```sh
|
||||
cargo build --target wasm32-unknown-unknown --no-default-features --features dns
|
||||
```
|
||||
|
||||
Build a `fetch`-only client (no WebSocket, no `mixResolve` JS export):
|
||||
|
||||
```sh
|
||||
cargo build --target wasm32-unknown-unknown --no-default-features --features fetch
|
||||
```
|
||||
|
||||
`fetch` and `socket` share the TLS stack (rustls + rustls-rustcrypto + webpki-roots);
|
||||
enabling both is roughly the same wasm size as either alone plus the hyper +
|
||||
async-tungstenite specifics.
|
||||
|
||||
### Debug logging
|
||||
|
||||
`debug_log!` and `debug_error!` (in `util.rs`) wrap `nym_wasm_utils::console_log!` /
|
||||
`console_error!` behind the `debug` cargo feature. Tunnel start/shutdown and the
|
||||
IPR connect handshake stay unconditional; everything else is silent in release.
|
||||
|
||||
`make build-debug` enables the feature automatically (it builds with
|
||||
`--features debug`). `make build-release-opt` leaves it off, so release
|
||||
artefacts ship no verbose logging.
|
||||
|
||||
### Cryptography
|
||||
|
||||
TLS terminates inside the WASM client, so we need a pure-Rust rustls
|
||||
crypto provider. [`rustls-rustcrypto`](https://github.com/RustCrypto/rustls-rustcrypto) as
|
||||
the only viable option: the underlying RustCrypto AEADs were
|
||||
[audited by NCC Group in 2020](https://www.nccgroup.com/research/public-report-rustcrypto-aesgcm-and-chacha20pluspoly1305-implementation-review/)
|
||||
with no findings, while the rustls integration glue is `0.0.2-alpha`.
|
||||
`src/tls.rs` restricts negotiation to AEAD-only suites with forward-secret
|
||||
key exchange.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make build # plain release wasm-pack build
|
||||
make build-debug # dev profile, verbose console logs on
|
||||
make build-release-opt # release + wasm-opt -Oz
|
||||
make dev # build-debug then start internal-dev webpack
|
||||
```
|
||||
|
||||
## Summary diagram
|
||||
|
||||
```text
|
||||
JS caller
|
||||
|
|
||||
+---------+---------+--------------+
|
||||
v v v
|
||||
mixFetch mixSocket mixResolve
|
||||
(mixfetch.rs) (mixsocket.rs) (mixdns.rs)
|
||||
| | |
|
||||
v v v
|
||||
fetch::fetch fetch::new_ dns::resolve
|
||||
connection + (dns.rs)
|
||||
async_tungst.
|
||||
\ | /
|
||||
\ v /
|
||||
'-> WasmTcpStream / WasmUdpSocket (stream.rs)
|
||||
|
|
||||
v smoltcp socket buffer
|
||||
+-------- smoltcp::Interface::poll() (reactor.rs)
|
||||
|
|
||||
v IP packet
|
||||
WasmDevice.tx_queue (device.rs)
|
||||
|
|
||||
v drained 5ms
|
||||
bridge::start_bridge (bridge.rs)
|
||||
|
|
||||
v
|
||||
ipr::send_ip_packet (ipr.rs)
|
||||
|
|
||||
v LP-framed DataRequest
|
||||
ClientInput::send (upstream, nym-wasm-client-core)
|
||||
|
|
||||
v Sphinx-packed
|
||||
JSWebsocket::new -> WebSocket::open -> web_sys::WebSocket::new
|
||||
(common/wasm/utils/src/websocket/mod.rs:58)
|
||||
|
|
||||
v
|
||||
Single wss:// to chosen gateway
|
||||
|
||||
(Separately, at startup + on TopologyRefresher tick:)
|
||||
nym_http_api_client::ClientBuilder
|
||||
-> reqwest -> web_sys::fetch
|
||||
(common/client-core/src/init/helpers.rs:155)
|
||||
|
|
||||
v
|
||||
HTTPS GET https://validator.nymtech.net/...
|
||||
```
|
||||
|
||||
Everything else (TLS handshakes, HTTP/1.1 requests, WebSocket frames in
|
||||
`mixSocket`) is content travelling inside that single gateway WSS as
|
||||
Sphinx-packed bytes.
|
||||
@@ -0,0 +1,13 @@
|
||||
; This is a self-contained dev harness, not part of the root pnpm workspace.
|
||||
; ignore-workspace=true prevents pnpm from walking up to the monorepo root
|
||||
; and trying to install every workspace package (which pulls unrelated deps
|
||||
; like `sharp` that have no business being on the smolmix dev path).
|
||||
ignore-workspace=true
|
||||
|
||||
; pnpm 11+ runs a "deps status check" before every `pnpm run <script>` that
|
||||
; auto-invokes `pnpm install` if the lockfile looks stale. That pre-check
|
||||
; runs BEFORE script-level flags are evaluated, so passing `--ignore-workspace`
|
||||
; to `pnpm start` doesn't stop it from walking up to the workspace root.
|
||||
; Disable the check entirely here: this is a dev-only harness, devs can
|
||||
; explicitly `pnpm install --ignore-workspace` when they need to refresh deps.
|
||||
verify-deps-before-run=false
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Async wrapper — ensures WASM dependencies resolve before index.js runs.
|
||||
import('./index.js').catch((e) =>
|
||||
console.error('Failed to load index.js:', e)
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
// Async wrapper — ensures WASM dependencies resolve before headless.js runs.
|
||||
import('./headless.js').catch((e) =>
|
||||
console.error('Failed to load headless.js:', e)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>smolmix-wasm headless test</title>
|
||||
<script src="headless.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<pre id="output" style="font-family: monospace; font-size: 13px; white-space: pre-wrap"></pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,316 @@
|
||||
// smolmix-wasm headless test runner
|
||||
//
|
||||
// Auto-runs a battery of tests on page load. Config via URL params:
|
||||
//
|
||||
// ?ipr=<address> IPR address (uses default if omitted)
|
||||
// ?cover=true Enable cover traffic (default: disabled)
|
||||
// ?poisson=true Enable Poisson traffic (default: disabled)
|
||||
// ?count=10 Stress test request count
|
||||
//
|
||||
// Two runs needed for the full matrix (OnceLock prevents re-init):
|
||||
// http://localhost:9000/headless.html
|
||||
// http://localhost:9000/headless.html?cover=true&poisson=true
|
||||
|
||||
import * as Comlink from "comlink";
|
||||
|
||||
// Config
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
const IPR_ADDRESS =
|
||||
params.get("ipr") ||
|
||||
"6B6iuWX4bQP4GVA4Yq7XmZencaaGw6BaPY6xJWYSwsbF.6g6LRx1fgU2Q2A4ZPKonYHtfBARh1GPMe1LtXk6vpRR8@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1";
|
||||
|
||||
const ENABLE_COVER = params.get("cover") === "true";
|
||||
const ENABLE_POISSON = params.get("poisson") === "true";
|
||||
// Clamp to [1, 500]: parseInt of garbage returns NaN, negative values would
|
||||
// underflow the test loop, very large values would exhaust browser memory.
|
||||
const STRESS_COUNT = Math.min(500, Math.max(1, parseInt(params.get("count") || "10", 10) || 10));
|
||||
|
||||
const CONFIG_LABEL = `cover=${ENABLE_COVER ? "ON" : "OFF"}, poisson=${
|
||||
ENABLE_POISSON ? "ON" : "OFF"
|
||||
}`;
|
||||
|
||||
// Output
|
||||
|
||||
const outputEl = document.getElementById("output");
|
||||
|
||||
function log(msg) {
|
||||
const ts = new Date().toISOString().slice(11, 23);
|
||||
const line = `[${ts}] ${msg}`;
|
||||
outputEl.textContent += line + "\n";
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
// Worker setup (shared worker.js)
|
||||
|
||||
function createWorker() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL("./worker.js", import.meta.url));
|
||||
worker.addEventListener("error", reject);
|
||||
worker.addEventListener(
|
||||
"message",
|
||||
(msg) => {
|
||||
if (msg.data?.kind === "Loaded") {
|
||||
worker.removeEventListener("error", reject);
|
||||
resolve(Comlink.wrap(worker));
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function toResponse(raw) {
|
||||
return new Response(raw.body, {
|
||||
status: raw.status,
|
||||
statusText: raw.statusText,
|
||||
headers: new Headers(raw.headers),
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
async function timedFetch(api, url, init = {}) {
|
||||
const t0 = performance.now();
|
||||
const raw = await api.mixFetch(url, init);
|
||||
const ms = performance.now() - t0;
|
||||
const resp = toResponse(raw);
|
||||
return { ok: resp.ok, status: resp.status, statusText: resp.statusText, ms };
|
||||
}
|
||||
|
||||
function fmtMs(ms) {
|
||||
return (ms / 1000).toFixed(2) + "s";
|
||||
}
|
||||
|
||||
// Test: Smoke (cold HTTPS GET)
|
||||
|
||||
async function testSmoke(api) {
|
||||
log("");
|
||||
log("=== Smoke Test (cold HTTPS GET) ===");
|
||||
log("GET https://httpbin.org/get");
|
||||
|
||||
try {
|
||||
const r = await timedFetch(api, "https://httpbin.org/get");
|
||||
log(` ${r.status} ${r.statusText} in ${fmtMs(r.ms)}`);
|
||||
return { name: "Smoke (cold HTTPS)", ok: true, ms: r.ms };
|
||||
} catch (e) {
|
||||
log(` FAIL: ${e}`);
|
||||
return { name: "Smoke (cold HTTPS)", ok: false, ms: 0, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// Test: HTTPS GET (warm, pooled connection)
|
||||
|
||||
async function testHttpsGetWarm(api) {
|
||||
log("");
|
||||
log("=== HTTPS GET (warm) ===");
|
||||
log("GET https://httpbin.org/get (pooled connection)");
|
||||
|
||||
try {
|
||||
const r = await timedFetch(api, "https://httpbin.org/get");
|
||||
log(` ${r.status} ${r.statusText} in ${fmtMs(r.ms)}`);
|
||||
return { name: "HTTPS GET (warm)", ok: true, ms: r.ms };
|
||||
} catch (e) {
|
||||
log(` FAIL: ${e}`);
|
||||
return { name: "HTTPS GET (warm)", ok: false, ms: 0, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Stress (httpbin mixed sizes)
|
||||
|
||||
const SIZE_PROFILES = [
|
||||
{ label: "tiny", bytes: 128 },
|
||||
{ label: "small", bytes: 1024 },
|
||||
{ label: "medium", bytes: 10240 },
|
||||
{ label: "large", bytes: 102400 },
|
||||
];
|
||||
|
||||
async function testStressHttpbin(api) {
|
||||
log("");
|
||||
log(`=== Stress Test: httpbin (${STRESS_COUNT} requests, mixed sizes) ===`);
|
||||
|
||||
const requests = [];
|
||||
for (let i = 0; i < STRESS_COUNT; i++) {
|
||||
const p = SIZE_PROFILES[Math.floor(Math.random() * SIZE_PROFILES.length)];
|
||||
requests.push({
|
||||
id: i + 1,
|
||||
url: `https://httpbin.org/bytes/${p.bytes}`,
|
||||
label: p.label,
|
||||
});
|
||||
}
|
||||
|
||||
// Log profile distribution
|
||||
const dist = {};
|
||||
for (const r of requests) dist[r.label] = (dist[r.label] || 0) + 1;
|
||||
log(` Profiles: ${JSON.stringify(dist)}`);
|
||||
|
||||
const t0 = performance.now();
|
||||
const perReq = [];
|
||||
|
||||
// Fire all concurrently (origin lock serialises per-host)
|
||||
const settled = await Promise.allSettled(
|
||||
requests.map(async (req) => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const r = await timedFetch(api, req.url);
|
||||
const elapsed = performance.now() - start;
|
||||
log(` #${req.id} ${req.label}: ${r.status} OK ${fmtMs(elapsed)}`);
|
||||
perReq.push({ id: req.id, label: req.label, ok: true, ms: elapsed });
|
||||
} catch (e) {
|
||||
const elapsed = performance.now() - start;
|
||||
log(` #${req.id} ${req.label}: FAIL ${fmtMs(elapsed)} — ${e}`);
|
||||
perReq.push({ id: req.id, label: req.label, ok: false, ms: elapsed });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const totalMs = performance.now() - t0;
|
||||
const okCount = perReq.filter((r) => r.ok).length;
|
||||
const avgMs =
|
||||
perReq.filter((r) => r.ok).reduce((s, r) => s + r.ms, 0) / (okCount || 1);
|
||||
|
||||
log(
|
||||
` Result: ${okCount}/${STRESS_COUNT} OK, total ${fmtMs(
|
||||
totalMs
|
||||
)}, avg ${fmtMs(avgMs)}/req`
|
||||
);
|
||||
|
||||
return {
|
||||
name: `Stress httpbin (${STRESS_COUNT})`,
|
||||
ok: okCount === STRESS_COUNT,
|
||||
ms: totalMs,
|
||||
okCount,
|
||||
total: STRESS_COUNT,
|
||||
avgMs,
|
||||
perReq,
|
||||
};
|
||||
}
|
||||
|
||||
// Summary
|
||||
|
||||
function printSummary(results) {
|
||||
log("");
|
||||
log("================================================================");
|
||||
log(` smolmix-wasm test results`);
|
||||
log(` Config: ${CONFIG_LABEL}`);
|
||||
log(` Date: ${new Date().toISOString()}`);
|
||||
log("================================================================");
|
||||
log("");
|
||||
|
||||
const nameWidth = 28;
|
||||
const resultWidth = 10;
|
||||
|
||||
log(` ${"Test".padEnd(nameWidth)}${"Result".padEnd(resultWidth)}Time`);
|
||||
log(
|
||||
` ${"".padEnd(nameWidth, "-")}${"".padEnd(resultWidth, "-")}${"".padEnd(
|
||||
20,
|
||||
"-"
|
||||
)}`
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
let resultStr;
|
||||
if (r.total !== undefined) {
|
||||
resultStr = `${r.okCount}/${r.total}`;
|
||||
} else {
|
||||
resultStr = r.ok ? "PASS" : "FAIL";
|
||||
}
|
||||
|
||||
let timeStr = r.ms ? fmtMs(r.ms) : "N/A";
|
||||
if (r.avgMs !== undefined) {
|
||||
timeStr += ` (avg ${fmtMs(r.avgMs)}/req)`;
|
||||
}
|
||||
|
||||
log(
|
||||
` ${r.name.padEnd(nameWidth)}${resultStr.padEnd(resultWidth)}${timeStr}`
|
||||
);
|
||||
}
|
||||
|
||||
log("");
|
||||
log("================================================================");
|
||||
|
||||
// Also output as JSON for programmatic consumption
|
||||
const json = {
|
||||
config: { cover: ENABLE_COVER, poisson: ENABLE_POISSON },
|
||||
date: new Date().toISOString(),
|
||||
results: results.map((r) => ({
|
||||
name: r.name,
|
||||
ok: r.ok,
|
||||
ms: Math.round(r.ms),
|
||||
...(r.okCount !== undefined && { okCount: r.okCount, total: r.total }),
|
||||
...(r.avgMs !== undefined && { avgMs: Math.round(r.avgMs) }),
|
||||
...(r.error && { error: r.error }),
|
||||
})),
|
||||
};
|
||||
// Machine-readable output for Playwright (bypasses log() timestamp prefix)
|
||||
console.log("RESULTS_JSON:" + JSON.stringify(json));
|
||||
}
|
||||
|
||||
// Main
|
||||
|
||||
async function runSuite() {
|
||||
log("smolmix-wasm headless test runner");
|
||||
log(`Config: ${CONFIG_LABEL}`);
|
||||
log(`Stress count: ${STRESS_COUNT}`);
|
||||
log(`IPR: ${IPR_ADDRESS.slice(0, 40)}...`);
|
||||
log("");
|
||||
|
||||
// 1. Start worker
|
||||
log("Starting worker...");
|
||||
let api;
|
||||
try {
|
||||
api = await createWorker();
|
||||
log("Worker started");
|
||||
} catch (e) {
|
||||
log(`FATAL: Worker creation failed: ${e}`);
|
||||
printSummary([{ name: "Worker init", ok: false, ms: 0, error: String(e) }]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Setup tunnel
|
||||
log("Setting up tunnel...");
|
||||
const setupT0 = performance.now();
|
||||
try {
|
||||
await api.setupMixTunnel({
|
||||
preferredIpr: IPR_ADDRESS,
|
||||
clientId: "headless-" + Math.random().toString(36).slice(2, 8),
|
||||
forceTls: true,
|
||||
disablePoissonTraffic: !ENABLE_POISSON,
|
||||
disableCoverTraffic: !ENABLE_COVER,
|
||||
});
|
||||
const setupMs = performance.now() - setupT0;
|
||||
log(`Tunnel ready in ${fmtMs(setupMs)}`);
|
||||
} catch (e) {
|
||||
log(`FATAL: Tunnel setup failed: ${e}`);
|
||||
printSummary([
|
||||
{ name: "Tunnel setup", ok: false, ms: 0, error: String(e) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Run tests sequentially
|
||||
const results = [];
|
||||
|
||||
results.push(await testSmoke(api));
|
||||
results.push(await testHttpsGetWarm(api));
|
||||
results.push(await testStressHttpbin(api));
|
||||
|
||||
// 4. Summary
|
||||
printSummary(results);
|
||||
|
||||
// 5. Disconnect
|
||||
log("");
|
||||
log("Disconnecting...");
|
||||
try {
|
||||
await api.disconnectMixTunnel();
|
||||
log("Disconnected");
|
||||
} catch (e) {
|
||||
log(`Disconnect error: ${e}`);
|
||||
}
|
||||
|
||||
log("Done.");
|
||||
}
|
||||
|
||||
runSuite();
|
||||
@@ -0,0 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>smolmix-wasm dev</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 1000px;
|
||||
margin: 16px auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
fieldset { margin-bottom: 12px; }
|
||||
legend { font-weight: bold; }
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status { font-size: 0.85em; color: gray; }
|
||||
.local-log {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px;
|
||||
margin: 8px 0 0;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.82em;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
<script src="bundle.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>smolmix-wasm dev</h1>
|
||||
|
||||
<!-- Connection -->
|
||||
<fieldset id="startup-controls">
|
||||
<legend>Connection</legend>
|
||||
<div>
|
||||
<label>IPR address: </label>
|
||||
<input type="text" size="80" id="ipr-address" disabled
|
||||
value="6B6iuWX4bQP4GVA4Yq7XmZencaaGw6BaPY6xJWYSwsbF.6g6LRx1fgU2Q2A4ZPKonYHtfBARh1GPMe1LtXk6vpRR8@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1"
|
||||
placeholder="<nym-address of IPR exit node>" />
|
||||
<label style="margin-left: 8px">
|
||||
<input type="checkbox" id="opt-random-ipr" checked /> Use random IPR
|
||||
</label>
|
||||
</div>
|
||||
<details style="margin-top: 8px" open>
|
||||
<summary style="cursor: pointer; font-size: 0.9em; color: #555">Advanced Options</summary>
|
||||
<div style="margin-top: 6px; padding: 8px; background: #f9f9f9; font-size: 0.9em">
|
||||
<div>
|
||||
<label><input type="checkbox" id="opt-force-tls" checked /> Force TLS</label>
|
||||
</div>
|
||||
<div style="margin-top: 4px">
|
||||
<label>Client ID: <input type="text" id="opt-client-id" size="20" /></label>
|
||||
<span style="font-size: 0.85em; color: #888">(randomised on load for clean state)</span>
|
||||
</div>
|
||||
<div style="margin-top: 4px">
|
||||
<label><input type="checkbox" id="opt-disable-poisson" /> Disable Poisson traffic</label>
|
||||
</div>
|
||||
<div style="margin-top: 4px">
|
||||
<label><input type="checkbox" id="opt-disable-cover" /> Disable cover traffic</label>
|
||||
</div>
|
||||
<div style="margin-top: 4px">
|
||||
<label>Open reply SURBs:
|
||||
<input type="number" id="opt-open-surbs" value="10" min="1" max="50" style="width: 60px" />
|
||||
</label>
|
||||
<label style="margin-left: 8px">Data reply SURBs:
|
||||
<input type="number" id="opt-data-surbs" value="0" min="0" max="50" style="width: 60px" />
|
||||
</label>
|
||||
<div style="font-size: 0.85em; color: #888; margin-top: 2px">
|
||||
Optional. Larger pools raise inbound throughput; each outgoing Sphinx packet then carries more reply blocks.
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 4px">
|
||||
<label>Primary DNS:
|
||||
<input type="text" id="opt-primary-dns" placeholder="8.8.8.8:53" size="18" />
|
||||
</label>
|
||||
<label style="margin-left: 8px">Fallback DNS:
|
||||
<input type="text" id="opt-fallback-dns" placeholder="1.1.1.1:53" size="18" />
|
||||
</label>
|
||||
<div style="font-size: 0.85em; color: #888; margin-top: 2px">
|
||||
Optional. `host:port`. Leave blank to use the defaults shown.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div style="margin-top: 8px">
|
||||
<button id="btn-setup">setupMixTunnel</button>
|
||||
<button id="btn-disconnect" disabled>disconnectMixTunnel</button>
|
||||
<label style="margin-left: 16px">
|
||||
<input type="checkbox" id="opt-debug-logging" checked /> Debug logging
|
||||
</label>
|
||||
<span id="tunnel-status" style="margin-left: 10px; color: gray">Not started</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- DNS Resolve: tunnel (smoltcp UDP to 8.8.8.8) vs clearnet (DoH JSON) -->
|
||||
<fieldset id="dns-controls">
|
||||
<legend>DNS Resolve</legend>
|
||||
<div class="row">
|
||||
<input type="text" size="40" id="dns-host" value="example.com" />
|
||||
<button id="btn-dns-tunnel" disabled>via tunnel</button>
|
||||
<button id="btn-dns-clearnet">via DoH (clearnet)</button>
|
||||
</div>
|
||||
<pre class="local-log" id="dns-log"></pre>
|
||||
</fieldset>
|
||||
|
||||
<!-- GET: clearnet vs tunnel, same URL -->
|
||||
<fieldset id="get-controls">
|
||||
<legend>GET</legend>
|
||||
<div class="row">
|
||||
<input type="text" size="60" id="get-url" value="https://httpbin.org/get" />
|
||||
<button id="btn-get-tunnel" disabled>via tunnel</button>
|
||||
<button id="btn-get-clearnet">via window.fetch (clearnet)</button>
|
||||
</div>
|
||||
<pre class="local-log" id="get-log"></pre>
|
||||
</fieldset>
|
||||
|
||||
<!-- WebSocket -->
|
||||
<fieldset id="ws-controls" disabled>
|
||||
<legend>WebSocket</legend>
|
||||
<div class="row">
|
||||
<input type="text" size="60" id="ws-url" value="wss://echo.websocket.org" />
|
||||
<button id="btn-ws-connect">Connect</button>
|
||||
<button id="btn-ws-close" disabled>Close</button>
|
||||
<span id="ws-status" class="status">Not connected</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 4px">
|
||||
<input type="text" size="50" id="ws-message" value="Hello from smolmix-wasm!" />
|
||||
<button id="btn-ws-send" disabled>Send</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 4px">
|
||||
<label>Echo burst:</label>
|
||||
<input type="number" id="ws-burst-count" value="10" min="1" max="500" style="width: 60px" />
|
||||
<label>Size:</label>
|
||||
<input type="number" id="ws-burst-min" value="64" min="1" max="1048576" style="width: 80px" />
|
||||
<span>–</span>
|
||||
<input type="number" id="ws-burst-max" value="1024" min="1" max="1048576" style="width: 80px" />
|
||||
<span class="status">bytes</span>
|
||||
<button id="btn-ws-burst" disabled>Send Burst</button>
|
||||
</div>
|
||||
<pre class="local-log" id="ws-log"></pre>
|
||||
</fieldset>
|
||||
|
||||
<!-- Stress Test -->
|
||||
<fieldset id="stress-controls" disabled>
|
||||
<legend>Stress Test</legend>
|
||||
<div class="row">
|
||||
<label>Requests:</label>
|
||||
<input type="number" id="stress-count" value="10" min="1" max="200" style="width: 60px" />
|
||||
<label>Mode:</label>
|
||||
<select id="stress-mode">
|
||||
<option value="uniform">Uniform</option>
|
||||
<option value="mixed" selected>Mixed sizes</option>
|
||||
<option value="drip">Slow drip</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="stress-uniform-opts" style="display: none; margin-top: 8px; padding: 8px; background: #f9f9f9">
|
||||
<label>Base URL:</label>
|
||||
<input type="text" size="50" id="stress-url" value="https://jsonplaceholder.typicode.com/posts/" />
|
||||
</div>
|
||||
|
||||
<div id="stress-mixed-opts" style="margin-top: 8px; padding: 8px; background: #f9f9f9">
|
||||
<table style="font-size: 0.9em; border-collapse: collapse">
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>tiny</b></td><td>128 B</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>small</b></td><td>1 KB</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>medium</b></td><td>10 KB</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>large</b></td><td>100 KB</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>xlarge</b></td><td>1 MB</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="stress-drip-opts" style="display: none; margin-top: 8px; padding: 8px; background: #f9f9f9">
|
||||
<table style="font-size: 0.9em; border-collapse: collapse">
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>safe</b></td><td>~50% of timeout</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>boundary</b></td><td>~92% of timeout</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>over</b></td><td>~108% of timeout</td></tr>
|
||||
<tr><td style="padding: 1px 10px 1px 0"><b>slow-start</b></td><td>~17% delay + ~83% drip</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 6px">
|
||||
<label>Request timeout (s):</label>
|
||||
<input type="number" id="stress-timeout" value="60" min="5" max="300" style="width: 60px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<button id="btn-stress">Run Stress Test</button>
|
||||
<span id="stress-status" class="status"></span>
|
||||
</div>
|
||||
<pre class="local-log" id="stress-log"></pre>
|
||||
</fieldset>
|
||||
|
||||
<!-- File Download -->
|
||||
<fieldset id="download-controls" disabled>
|
||||
<legend>File Download</legend>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap">
|
||||
<div style="flex: 1; min-width: 220px; padding: 8px; background: #f9f9f9; border: 1px solid #ddd">
|
||||
<b>UTF-8 Demo</b>
|
||||
<div class="status">Unicode text (Cambridge CS)</div>
|
||||
<button id="btn-verify-text">Fetch</button>
|
||||
<span id="verify-text-status" class="status"></span>
|
||||
<pre id="verify-text-output" style="margin-top: 6px; max-height: 200px; overflow-y: auto; font-size: 0.8em; white-space: pre-wrap; display: none; background: #fff; padding: 6px; border: 1px solid #eee"></pre>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 220px; padding: 8px; background: #f9f9f9; border: 1px solid #ddd">
|
||||
<b>File Download</b>
|
||||
<div style="margin: 4px 0 6px">
|
||||
<input type="text" size="50" id="download-url"
|
||||
value="https://nymtech.net/uploads/Nym_WFP_Paper_5_58a1105679.pdf" />
|
||||
</div>
|
||||
<button id="btn-verify-pdf">Fetch</button>
|
||||
<button id="btn-save-pdf" disabled>Save</button>
|
||||
<span id="verify-pdf-status" class="status"></span>
|
||||
<div id="verify-pdf-output" style="margin-top: 6px; font-size: 0.8em; display: none">
|
||||
<div>Size: <code id="verify-pdf-size"></code></div>
|
||||
<div>SHA-256: <code id="verify-pdf-sha"></code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<button id="btn-verify-all">Run Both</button>
|
||||
<span id="verify-all-status" class="status"></span>
|
||||
</div>
|
||||
<pre class="local-log" id="download-log"></pre>
|
||||
</fieldset>
|
||||
|
||||
<!-- Master timeline -->
|
||||
<fieldset>
|
||||
<legend>Output (master timeline)</legend>
|
||||
<pre id="output" style="background: #f5f5f5; padding: 8px; max-height: 300px; overflow-y: auto; font-size: 0.85em; white-space: pre-wrap"></pre>
|
||||
</fieldset>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,858 @@
|
||||
// smolmix-wasm internal-dev test harness.
|
||||
// WASM runs in a Web Worker so mixnet I/O stays off the main thread.
|
||||
|
||||
import * as Comlink from "comlink";
|
||||
import { MixSocket } from "./mix-socket.js";
|
||||
|
||||
function createWorker() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL("./worker.js", import.meta.url));
|
||||
worker.addEventListener("error", reject);
|
||||
worker.addEventListener(
|
||||
"message",
|
||||
(msg) => {
|
||||
if (msg.data?.kind === "Loaded") {
|
||||
worker.removeEventListener("error", reject);
|
||||
resolve({ worker, api: Comlink.wrap(worker) });
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let api = null;
|
||||
|
||||
const outputEl = document.getElementById("output");
|
||||
|
||||
// Global timeline. Per-section feedback goes through logTo() instead.
|
||||
// Red-coloured entries (the in-page convention for errors) are also
|
||||
// mirrored to console.error so they show up in the browser dev tools
|
||||
// alongside the Rust-side `[smolmix] ...` logs.
|
||||
function display(msg, colour) {
|
||||
const ts = new Date().toISOString().slice(11, 23);
|
||||
const line = document.createElement("div");
|
||||
if (colour) line.style.color = colour;
|
||||
line.textContent = `[${ts}] ${msg}`;
|
||||
outputEl.appendChild(line);
|
||||
outputEl.scrollTop = outputEl.scrollHeight;
|
||||
if (colour === "red") console.error("[smolmix-dev]", msg);
|
||||
}
|
||||
|
||||
function logTo(targetId, msg, colour) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
const ts = new Date().toISOString().slice(11, 23);
|
||||
const line = document.createElement("div");
|
||||
if (colour) line.style.color = colour;
|
||||
line.textContent = `[${ts}] ${msg}`;
|
||||
target.appendChild(line);
|
||||
target.scrollTop = target.scrollHeight;
|
||||
if (colour === "red") console.error(`[smolmix-dev:${targetId}]`, msg);
|
||||
}
|
||||
|
||||
function hexPreview(data, maxBytes = 64) {
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
const len = Math.min(bytes.length, maxBytes);
|
||||
const hex = Array.from(bytes.slice(0, len), (b) =>
|
||||
b.toString(16).padStart(2, "0"),
|
||||
).join(" ");
|
||||
return bytes.length > maxBytes ? `${hex} ...` : hex;
|
||||
}
|
||||
|
||||
// Wrap mixFetch's { body, status, statusText, headers } in a native Response
|
||||
// so callers get .json(), .text(), .arrayBuffer().
|
||||
function toResponse(raw) {
|
||||
return new Response(raw.body, {
|
||||
status: raw.status,
|
||||
statusText: raw.statusText,
|
||||
headers: new Headers(raw.headers),
|
||||
});
|
||||
}
|
||||
|
||||
// Tunnel-gated fieldsets and buttons. GET and DNS fieldsets are excluded:
|
||||
// their clearnet buttons work without a tunnel, so only the tunnel buttons
|
||||
// inside them are gated individually.
|
||||
const GATED_FIELDSETS = [
|
||||
"ws-controls",
|
||||
"stress-controls",
|
||||
"download-controls",
|
||||
];
|
||||
|
||||
const GATED_BUTTONS = ["btn-get-tunnel", "btn-dns-tunnel"];
|
||||
|
||||
function setTunnelButtonsEnabled(enabled) {
|
||||
for (const id of GATED_FIELDSETS) {
|
||||
document.getElementById(id).disabled = !enabled;
|
||||
}
|
||||
for (const id of GATED_BUTTONS) {
|
||||
document.getElementById(id).disabled = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Grey out the IPR text input when "Use random IPR" is checked.
|
||||
document.getElementById("opt-random-ipr").addEventListener("change", (e) => {
|
||||
document.getElementById("ipr-address").disabled = e.target.checked;
|
||||
});
|
||||
|
||||
document.getElementById("btn-setup").addEventListener("click", async () => {
|
||||
const useRandomIpr = document.getElementById("opt-random-ipr").checked;
|
||||
const iprAddress = useRandomIpr
|
||||
? undefined
|
||||
: document.getElementById("ipr-address").value.trim();
|
||||
if (!useRandomIpr && !iprAddress) {
|
||||
display("IPR address is required (or check 'Use random IPR')", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById("tunnel-status");
|
||||
document.getElementById("btn-setup").disabled = true;
|
||||
statusEl.textContent = "Starting worker...";
|
||||
statusEl.style.color = "orange";
|
||||
|
||||
try {
|
||||
const result = await createWorker();
|
||||
api = result.api;
|
||||
MixSocket._initWorker(result.worker);
|
||||
display("Worker started");
|
||||
|
||||
// Sync the debug-logging checkbox into WASM so the UI default applies
|
||||
// from setup time — without this, the checkbox could read `checked`
|
||||
// while the WASM static stays false until the user re-toggles.
|
||||
const debugOn = document.getElementById("opt-debug-logging").checked;
|
||||
await api.setDebugLogging(debugOn);
|
||||
} catch (e) {
|
||||
display(`Worker creation failed: ${e}`, "red");
|
||||
document.getElementById("btn-setup").disabled = false;
|
||||
statusEl.textContent = "Failed";
|
||||
statusEl.style.color = "red";
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = document.getElementById("opt-client-id").value;
|
||||
const forceTls = document.getElementById("opt-force-tls").checked;
|
||||
const disablePoisson = document.getElementById("opt-disable-poisson").checked;
|
||||
const disableCover = document.getElementById("opt-disable-cover").checked;
|
||||
|
||||
// Allow 0 for the data slot — the default is "lean entirely on
|
||||
// nym-client-core's pre-emptive SURB topup" (see SurbsConfig::default
|
||||
// in wasm/smolmix/src/ipr.rs).
|
||||
const clampSurbs = (n) => Math.min(50, Math.max(0, n));
|
||||
const openReplySurbs = clampSurbs(
|
||||
parseInt(document.getElementById("opt-open-surbs").value, 10) || 10,
|
||||
);
|
||||
const dataReplySurbs = clampSurbs(
|
||||
parseInt(document.getElementById("opt-data-surbs").value, 10) || 0,
|
||||
);
|
||||
|
||||
// `undefined` (omitted) means "use the Rust default"; see SetupOpts.
|
||||
const primaryDns =
|
||||
document.getElementById("opt-primary-dns").value.trim() || undefined;
|
||||
const fallbackDns =
|
||||
document.getElementById("opt-fallback-dns").value.trim() || undefined;
|
||||
|
||||
display(
|
||||
useRandomIpr
|
||||
? `setupMixTunnel (clientId=${clientId}, IPR: auto-discover)...`
|
||||
: `setupMixTunnel (clientId=${clientId}, IPR: ${iprAddress.slice(0, 30)}...)...`,
|
||||
);
|
||||
statusEl.textContent = "Connecting to mixnet...";
|
||||
|
||||
try {
|
||||
await api.setupMixTunnel({
|
||||
// Omit preferredIpr entirely when auto-discovery is requested.
|
||||
// Rust SetupOpts treats `null`/undefined as "discover one yourself".
|
||||
...(iprAddress ? { preferredIpr: iprAddress } : {}),
|
||||
clientId,
|
||||
forceTls,
|
||||
disablePoissonTraffic: disablePoisson,
|
||||
disableCoverTraffic: disableCover,
|
||||
openReplySurbs,
|
||||
dataReplySurbs,
|
||||
primaryDns,
|
||||
fallbackDns,
|
||||
});
|
||||
display("setupMixTunnel OK: tunnel ready", "green");
|
||||
statusEl.textContent = "Connected";
|
||||
statusEl.style.color = "green";
|
||||
setTunnelButtonsEnabled(true);
|
||||
document.getElementById("btn-disconnect").disabled = false;
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
display(`setupMixTunnel failed: ${msg}`, "red");
|
||||
statusEl.textContent = `Failed: ${msg}`;
|
||||
statusEl.style.color = "red";
|
||||
statusEl.title = msg;
|
||||
document.getElementById("btn-setup").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("btn-disconnect")
|
||||
.addEventListener("click", async () => {
|
||||
display("Disconnecting...");
|
||||
try {
|
||||
await api.disconnectMixTunnel();
|
||||
display("Disconnected", "green");
|
||||
document.getElementById("tunnel-status").textContent = "Disconnected";
|
||||
document.getElementById("tunnel-status").style.color = "gray";
|
||||
setTunnelButtonsEnabled(false);
|
||||
document.getElementById("btn-disconnect").disabled = true;
|
||||
document.getElementById("btn-setup").disabled = true; // OnceLock: can't reinit
|
||||
} catch (e) {
|
||||
display(`Disconnect failed: ${e}`, "red");
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("opt-debug-logging")
|
||||
.addEventListener("change", async (e) => {
|
||||
const enabled = e.target.checked;
|
||||
// Pre-setup clicks: WASM isn't loaded yet. The checkbox state is read
|
||||
// again right after the worker boots, so the toggle still takes effect.
|
||||
if (!api) {
|
||||
display(`Debug logging queued ${enabled ? "ON" : "OFF"} (applies on setup)`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.setDebugLogging(enabled);
|
||||
display(
|
||||
`Debug logging ${enabled ? "ON" : "OFF"} (logs in browser console)`,
|
||||
enabled ? "green" : "gray",
|
||||
);
|
||||
} catch (err) {
|
||||
display(`setDebugLogging failed: ${err}`, "red");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-dns-tunnel").addEventListener("click", async () => {
|
||||
const hostname = document.getElementById("dns-host").value.trim();
|
||||
if (!hostname) {
|
||||
logTo("dns-log", "Hostname is required", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("btn-dns-tunnel");
|
||||
btn.disabled = true;
|
||||
logTo("dns-log", `tunnel resolve ${hostname}`);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const ip = await api.mixResolve(hostname);
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
logTo("dns-log", `tunnel ${hostname} => ${ip} (${ms} ms)`, "green");
|
||||
} catch (e) {
|
||||
logTo("dns-log", `tunnel resolve failed: ${e}`, "red");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// The browser exposes no raw DNS API; "clearnet DNS" from JS is DoH (HTTPS)
|
||||
// to a public resolver. Google's JSON API is CORS-friendly and returns
|
||||
// { Status, Answer: [{ name, type, TTL, data }] }, where type=1 is an A
|
||||
// record. The request appears in DevTools Network as an HTTPS fetch.
|
||||
document.getElementById("btn-dns-clearnet").addEventListener("click", async () => {
|
||||
const hostname = document.getElementById("dns-host").value.trim();
|
||||
if (!hostname) {
|
||||
logTo("dns-log", "Hostname is required", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
logTo("dns-log", `clearnet DoH resolve ${hostname}`);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const resp = await window.fetch(
|
||||
`https://dns.google/resolve?name=${encodeURIComponent(hostname)}&type=A`,
|
||||
{ mode: "cors" },
|
||||
);
|
||||
const json = await resp.json();
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
|
||||
if (json.Status !== 0) {
|
||||
logTo("dns-log", `clearnet DoH error: status=${json.Status} (${ms} ms)`, "red");
|
||||
return;
|
||||
}
|
||||
const aRecord = json.Answer?.find((a) => a.type === 1);
|
||||
if (!aRecord) {
|
||||
logTo("dns-log", `clearnet DoH: no A record (${ms} ms)`, "orange");
|
||||
return;
|
||||
}
|
||||
logTo(
|
||||
"dns-log",
|
||||
`clearnet ${hostname} => ${aRecord.data} (${ms} ms); visible in DevTools Network`,
|
||||
"green",
|
||||
);
|
||||
} catch (e) {
|
||||
logTo("dns-log", `clearnet DoH failed: ${e}`, "red");
|
||||
}
|
||||
});
|
||||
|
||||
// Same URL, two transports. Filter DevTools Network by the target host:
|
||||
// clearnet produces a row, tunnel does not.
|
||||
|
||||
document.getElementById("btn-get-tunnel").addEventListener("click", async () => {
|
||||
const url = document.getElementById("get-url").value.trim();
|
||||
if (!url) {
|
||||
logTo("get-log", "URL is required", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
logTo("get-log", `tunnel GET ${url}`);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const raw = await api.mixFetch(url, {});
|
||||
const resp = toResponse(raw);
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
logTo("get-log", `tunnel ${resp.status} ${resp.statusText} (${ms} ms)`, "green");
|
||||
} catch (e) {
|
||||
logTo("get-log", `tunnel GET failed: ${e}`, "red");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-get-clearnet").addEventListener("click", async () => {
|
||||
const url = document.getElementById("get-url").value.trim();
|
||||
if (!url) {
|
||||
logTo("get-log", "URL is required", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
logTo("get-log", `clearnet GET ${url}`);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const resp = await window.fetch(url, { mode: "cors" });
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
logTo(
|
||||
"get-log",
|
||||
`clearnet ${resp.status} ${resp.statusText} (${ms} ms); visible in DevTools Network`,
|
||||
"green",
|
||||
);
|
||||
} catch (e) {
|
||||
logTo("get-log", `clearnet fetch failed: ${e}`, "red");
|
||||
}
|
||||
});
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return bytes + " B";
|
||||
}
|
||||
|
||||
function formatRate(bytes, ms) {
|
||||
const kbps = bytes / 1024 / (ms / 1000);
|
||||
return kbps.toFixed(1) + " KB/s";
|
||||
}
|
||||
|
||||
let activeWs = null;
|
||||
let wsConnectT0 = 0;
|
||||
|
||||
// Queue of send timestamps for RTT tracking.
|
||||
// WebSocket preserves message order, so each recv pops the oldest send.
|
||||
const wsSendQueue = [];
|
||||
|
||||
let wsBurstActive = false;
|
||||
let wsBurstRtts = [];
|
||||
let wsBurstExpected = 0;
|
||||
let wsBurstResolve = null;
|
||||
|
||||
function setWsButtonState(state) {
|
||||
const connected = state === "connected";
|
||||
const connecting = state === "connecting";
|
||||
document.getElementById("btn-ws-connect").disabled = connected || connecting;
|
||||
document.getElementById("btn-ws-send").disabled = !connected;
|
||||
document.getElementById("btn-ws-close").disabled = !connected;
|
||||
document.getElementById("btn-ws-burst").disabled = !connected;
|
||||
}
|
||||
|
||||
document.getElementById("btn-ws-connect").addEventListener("click", () => {
|
||||
const url = document.getElementById("ws-url").value.trim();
|
||||
if (!url) {
|
||||
logTo("ws-log", "WebSocket URL is required", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
// Tear down any prior connection so a rapid double-click doesn't leak it.
|
||||
if (activeWs && activeWs.readyState !== MixSocket.CLOSED) {
|
||||
activeWs.close();
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById("ws-status");
|
||||
statusEl.textContent = "Connecting...";
|
||||
statusEl.style.color = "orange";
|
||||
setWsButtonState("connecting");
|
||||
wsSendQueue.length = 0;
|
||||
|
||||
logTo("ws-log", `connecting to ${url}`);
|
||||
wsConnectT0 = performance.now();
|
||||
|
||||
const ws = new MixSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
const ms = (performance.now() - wsConnectT0).toFixed(0);
|
||||
logTo("ws-log", `connected in ${ms} ms (protocol=${ws.protocol || "none"})`, "green");
|
||||
statusEl.textContent = `Connected (${ms} ms)`;
|
||||
statusEl.style.color = "green";
|
||||
setWsButtonState("connected");
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
let preview;
|
||||
if (typeof e.data === "string") {
|
||||
preview = e.data.length <= 200 ? e.data : e.data.slice(0, 200) + "...";
|
||||
} else if (e.data instanceof ArrayBuffer) {
|
||||
preview = `[binary ${e.data.byteLength} bytes] ${hexPreview(e.data)}`;
|
||||
} else if (e.data instanceof Blob) {
|
||||
preview = `[blob ${e.data.size} bytes]`;
|
||||
} else {
|
||||
preview = `[unknown ${typeof e.data}]`;
|
||||
}
|
||||
|
||||
let rttMs = null;
|
||||
if (wsSendQueue.length > 0) {
|
||||
rttMs = performance.now() - wsSendQueue.shift();
|
||||
}
|
||||
|
||||
// During burst: collect RTTs silently, don't log each message
|
||||
if (wsBurstActive) {
|
||||
if (rttMs != null) wsBurstRtts.push(rttMs);
|
||||
if (wsBurstRtts.length >= wsBurstExpected && wsBurstResolve) {
|
||||
wsBurstResolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (rttMs != null) {
|
||||
logTo("ws-log", `recv (${rttMs.toFixed(0)} ms RTT): ${preview}`, "green");
|
||||
} else {
|
||||
logTo("ws-log", `recv: ${preview}`, "green");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
logTo(
|
||||
"ws-log",
|
||||
`closed: ${e.code} ${e.reason}${e.wasClean ? "" : " (unclean)"}`,
|
||||
"orange",
|
||||
);
|
||||
statusEl.textContent = "Closed";
|
||||
statusEl.style.color = "gray";
|
||||
setWsButtonState("disconnected");
|
||||
activeWs = null;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
logTo("ws-log", "error", "red");
|
||||
statusEl.textContent = "Error";
|
||||
statusEl.style.color = "red";
|
||||
};
|
||||
|
||||
activeWs = ws;
|
||||
});
|
||||
|
||||
document.getElementById("btn-ws-send").addEventListener("click", () => {
|
||||
if (!activeWs || activeWs.readyState !== MixSocket.OPEN) return;
|
||||
const msg = document.getElementById("ws-message").value;
|
||||
wsSendQueue.push(performance.now());
|
||||
activeWs.send(msg);
|
||||
logTo("ws-log", `send: ${msg}`);
|
||||
});
|
||||
|
||||
document.getElementById("btn-ws-close").addEventListener("click", () => {
|
||||
if (!activeWs) return;
|
||||
const t0 = performance.now();
|
||||
activeWs.onclose = (e) => {
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
logTo(
|
||||
"ws-log",
|
||||
`closed in ${ms} ms: ${e.code} ${e.reason}${e.wasClean ? "" : " (unclean)"}`,
|
||||
"orange",
|
||||
);
|
||||
document.getElementById("ws-status").textContent = "Closed";
|
||||
document.getElementById("ws-status").style.color = "gray";
|
||||
setWsButtonState("disconnected");
|
||||
activeWs = null;
|
||||
};
|
||||
logTo("ws-log", "closing...");
|
||||
activeWs.close();
|
||||
});
|
||||
|
||||
document.getElementById("btn-ws-burst").addEventListener("click", async () => {
|
||||
if (!activeWs || activeWs.readyState !== MixSocket.OPEN) return;
|
||||
const count = parseInt(document.getElementById("ws-burst-count").value, 10);
|
||||
const minSize = parseInt(document.getElementById("ws-burst-min").value, 10);
|
||||
const maxSize = parseInt(document.getElementById("ws-burst-max").value, 10);
|
||||
|
||||
if (count < 1 || count > 500) {
|
||||
logTo("ws-log", "burst count must be 1-500", "red");
|
||||
return;
|
||||
}
|
||||
if (minSize < 1 || maxSize < minSize) {
|
||||
logTo("ws-log", "invalid size range", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to arraybuffer mode for binary round-trip verification
|
||||
const prevBinaryType = activeWs.binaryType;
|
||||
activeWs.binaryType = "arraybuffer";
|
||||
|
||||
document.getElementById("btn-ws-burst").disabled = true;
|
||||
document.getElementById("btn-ws-send").disabled = true;
|
||||
|
||||
const payloads = [];
|
||||
let totalBytes = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size =
|
||||
minSize === maxSize
|
||||
? minSize
|
||||
: minSize + Math.floor(Math.random() * (maxSize - minSize + 1));
|
||||
const buf = new Uint8Array(size);
|
||||
crypto.getRandomValues(buf);
|
||||
payloads.push(buf);
|
||||
totalBytes += size;
|
||||
}
|
||||
|
||||
logTo(
|
||||
"ws-log",
|
||||
`echo burst: ${count} msgs, ${formatSize(minSize)}-${formatSize(maxSize)} (${formatSize(totalBytes)} total)`,
|
||||
);
|
||||
|
||||
wsBurstActive = true;
|
||||
wsBurstRtts = [];
|
||||
wsBurstExpected = count;
|
||||
|
||||
let received = 0;
|
||||
let verified = 0;
|
||||
let mismatches = 0;
|
||||
const sizes = [];
|
||||
let firstRecvHex = null;
|
||||
|
||||
const burstDone = new Promise((resolve) => {
|
||||
wsBurstResolve = resolve;
|
||||
|
||||
const origOnmessage = activeWs.onmessage;
|
||||
activeWs.onmessage = (e) => {
|
||||
let rttMs = null;
|
||||
if (wsSendQueue.length > 0) {
|
||||
rttMs = performance.now() - wsSendQueue.shift();
|
||||
wsBurstRtts.push(rttMs);
|
||||
}
|
||||
|
||||
const sent = payloads[received];
|
||||
const recvBuf = new Uint8Array(e.data);
|
||||
sizes.push(recvBuf.byteLength);
|
||||
if (firstRecvHex === null) firstRecvHex = hexPreview(recvBuf);
|
||||
|
||||
if (sent && recvBuf.byteLength === sent.byteLength) {
|
||||
let match = true;
|
||||
for (let j = 0; j < sent.byteLength; j++) {
|
||||
if (recvBuf[j] !== sent[j]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) verified++;
|
||||
else mismatches++;
|
||||
} else {
|
||||
mismatches++;
|
||||
}
|
||||
|
||||
received++;
|
||||
if (received >= count) {
|
||||
activeWs.onmessage = origOnmessage;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < count; i++) {
|
||||
wsSendQueue.push(performance.now());
|
||||
activeWs.send(payloads[i]);
|
||||
}
|
||||
|
||||
await burstDone;
|
||||
const totalMs = performance.now() - t0;
|
||||
|
||||
wsBurstActive = false;
|
||||
wsBurstResolve = null;
|
||||
activeWs.binaryType = prevBinaryType;
|
||||
|
||||
const rtts = wsBurstRtts.slice().sort((a, b) => a - b);
|
||||
const rttMin = rtts[0].toFixed(0);
|
||||
const rttMax = rtts[rtts.length - 1].toFixed(0);
|
||||
const rttAvg = (rtts.reduce((a, b) => a + b, 0) / rtts.length).toFixed(0);
|
||||
const p50 = rtts[Math.floor(rtts.length * 0.5)].toFixed(0);
|
||||
const p95 = rtts[Math.floor(rtts.length * 0.95)].toFixed(0);
|
||||
const msgPerSec = (count / (totalMs / 1000)).toFixed(1);
|
||||
const throughput = formatRate(totalBytes, totalMs);
|
||||
|
||||
const verifyColour = mismatches === 0 ? "green" : "red";
|
||||
logTo(
|
||||
"ws-log",
|
||||
`burst done: ${count} msgs in ${(totalMs / 1000).toFixed(2)}s (${msgPerSec} msg/s, ${throughput})`,
|
||||
"green",
|
||||
);
|
||||
logTo(
|
||||
"ws-log",
|
||||
`verify: ${verified}/${count} OK` + (mismatches > 0 ? `, ${mismatches} MISMATCH` : ""),
|
||||
verifyColour,
|
||||
);
|
||||
logTo(
|
||||
"ws-log",
|
||||
`RTT: min=${rttMin} avg=${rttAvg} p50=${p50} p95=${p95} max=${rttMax} ms`,
|
||||
);
|
||||
|
||||
document.getElementById("btn-ws-burst").disabled = false;
|
||||
document.getElementById("btn-ws-send").disabled = false;
|
||||
});
|
||||
|
||||
const SIZE_PROFILES = [
|
||||
{ label: "tiny", bytes: 128 },
|
||||
{ label: "small", bytes: 1024 },
|
||||
{ label: "medium", bytes: 10240 },
|
||||
{ label: "large", bytes: 102400 },
|
||||
{ label: "xlarge", bytes: 1048576 },
|
||||
];
|
||||
|
||||
function buildDripProfiles(timeoutSec) {
|
||||
return [
|
||||
{ label: "safe", duration: Math.round(timeoutSec * 0.5), delay: 0, bytes: 100 },
|
||||
{ label: "boundary", duration: Math.round(timeoutSec * 0.92), delay: 0, bytes: 100 },
|
||||
{ label: "over", duration: Math.round(timeoutSec * 1.08), delay: 0, bytes: 100 },
|
||||
{ label: "slow-start", duration: Math.round(timeoutSec * 0.83), delay: Math.round(timeoutSec * 0.17), bytes: 100 },
|
||||
];
|
||||
}
|
||||
|
||||
function generateRequests(count, mode, timeoutSec) {
|
||||
const requests = [];
|
||||
if (mode === "uniform") {
|
||||
const baseUrl = document.getElementById("stress-url").value.trim();
|
||||
for (let i = 1; i <= count; i++) {
|
||||
requests.push({ id: i, url: `${baseUrl}${i}`, label: "uniform" });
|
||||
}
|
||||
} else if (mode === "mixed") {
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const p = SIZE_PROFILES[Math.floor(Math.random() * SIZE_PROFILES.length)];
|
||||
requests.push({ id: i, url: `https://httpbin.org/bytes/${p.bytes}`, label: p.label });
|
||||
}
|
||||
} else if (mode === "drip") {
|
||||
const profiles = buildDripProfiles(timeoutSec);
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const p = profiles[Math.floor(Math.random() * profiles.length)];
|
||||
requests.push({
|
||||
id: i,
|
||||
url: `https://httpbin.org/drip?duration=${p.duration}&numbytes=${p.bytes}&delay=${p.delay}&code=200`,
|
||||
label: p.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function runOneStressRequest(req) {
|
||||
const tag = `#${req.id} ${req.label}`;
|
||||
const start = performance.now();
|
||||
try {
|
||||
const raw = await api.mixFetch(req.url, {});
|
||||
const resp = toResponse(raw);
|
||||
const body = await resp.text();
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
logTo("stress-log", `[${tag}] ${resp.status} OK ${elapsed}s (${body.length}B)`, "green");
|
||||
return { id: req.id, label: req.label, ok: true, status: resp.status, elapsed, textLength: body.length };
|
||||
} catch (e) {
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
logTo("stress-log", `[${tag}] FAIL ${elapsed}s: ${e}`, "red");
|
||||
return { id: req.id, label: req.label, ok: false, elapsed, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btn-stress").addEventListener("click", async () => {
|
||||
const count = parseInt(document.getElementById("stress-count").value, 10);
|
||||
const mode = document.getElementById("stress-mode").value;
|
||||
const timeoutSec = parseInt(
|
||||
(document.getElementById("stress-timeout") || { value: "60" }).value,
|
||||
10,
|
||||
);
|
||||
|
||||
const statusEl = document.getElementById("stress-status");
|
||||
document.getElementById("btn-stress").disabled = true;
|
||||
statusEl.textContent = "Running...";
|
||||
|
||||
const requests = generateRequests(count, mode, timeoutSec);
|
||||
|
||||
if (mode === "mixed" || mode === "drip") {
|
||||
const breakdown = {};
|
||||
for (const r of requests) breakdown[r.label] = (breakdown[r.label] || 0) + 1;
|
||||
logTo("stress-log", `${count} requests, ${mode} mode, profiles: ${JSON.stringify(breakdown)}`);
|
||||
} else {
|
||||
logTo("stress-log", `${count} requests, ${mode} mode`);
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
requests.map((r) => runOneStressRequest(r)),
|
||||
);
|
||||
|
||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
const results = settled.map((s) =>
|
||||
s.status === "fulfilled" ? s.value : { ok: false, error: s.reason },
|
||||
);
|
||||
const ok = results.filter((r) => r.ok).length;
|
||||
const fail = results.filter((r) => !r.ok).length;
|
||||
|
||||
const colour = fail === 0 ? "green" : "red";
|
||||
logTo("stress-log", `done: ${ok}/${count} OK, ${fail} failed (${totalSec}s total)`, colour);
|
||||
|
||||
if (fail > 0) {
|
||||
for (const r of results.filter((r) => !r.ok)) {
|
||||
logTo("stress-log", ` FAIL #${r.id} ${r.label} (${r.elapsed}s): ${r.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
statusEl.textContent = `Done: ${ok}/${count} OK, ${fail} failed (${totalSec}s)`;
|
||||
document.getElementById("btn-stress").disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById("stress-mode").addEventListener("change", function () {
|
||||
document.getElementById("stress-uniform-opts").style.display =
|
||||
this.value === "uniform" ? "block" : "none";
|
||||
document.getElementById("stress-mixed-opts").style.display =
|
||||
this.value === "mixed" ? "block" : "none";
|
||||
document.getElementById("stress-drip-opts").style.display =
|
||||
this.value === "drip" ? "block" : "none";
|
||||
});
|
||||
|
||||
const VERIFY_TEXT_URL =
|
||||
"https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt";
|
||||
|
||||
async function sha256hex(bytes) {
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(hash), (b) =>
|
||||
b.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
|
||||
function saveFile(buf, filename, mimeType) {
|
||||
const blob = new Blob([buf], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
let cachedPdf = null;
|
||||
|
||||
async function verifyText() {
|
||||
const statusEl = document.getElementById("verify-text-status");
|
||||
const outputEl = document.getElementById("verify-text-output");
|
||||
document.getElementById("btn-verify-text").disabled = true;
|
||||
statusEl.textContent = "Fetching...";
|
||||
statusEl.style.color = "orange";
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const raw = await api.mixFetch(VERIFY_TEXT_URL, {});
|
||||
const resp = toResponse(raw);
|
||||
const text = await resp.text();
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
|
||||
statusEl.textContent = `${formatSize(text.length)} in ${ms} ms`;
|
||||
statusEl.style.color = "green";
|
||||
outputEl.style.display = "block";
|
||||
outputEl.textContent = text;
|
||||
logTo("download-log", `UTF-8 demo: ${formatSize(text.length)} in ${ms} ms`, "green");
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Failed: ${e}`;
|
||||
statusEl.style.color = "red";
|
||||
logTo("download-log", `UTF-8 demo FAILED: ${e}`, "red");
|
||||
}
|
||||
document.getElementById("btn-verify-text").disabled = false;
|
||||
}
|
||||
|
||||
async function fetchFile() {
|
||||
const url = document.getElementById("download-url").value.trim();
|
||||
if (!url) {
|
||||
logTo("download-log", "Download URL is required", "red");
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById("verify-pdf-status");
|
||||
const outputEl = document.getElementById("verify-pdf-output");
|
||||
document.getElementById("btn-verify-pdf").disabled = true;
|
||||
document.getElementById("btn-save-pdf").disabled = true;
|
||||
cachedPdf = null;
|
||||
statusEl.textContent = "Fetching...";
|
||||
statusEl.style.color = "orange";
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const raw = await api.mixFetch(url, {});
|
||||
const resp = toResponse(raw);
|
||||
const buf = await resp.arrayBuffer();
|
||||
const ms = (performance.now() - t0).toFixed(0);
|
||||
const hash = await sha256hex(buf);
|
||||
|
||||
document.getElementById("verify-pdf-size").textContent =
|
||||
`${buf.byteLength.toLocaleString()} bytes`;
|
||||
document.getElementById("verify-pdf-sha").textContent = hash;
|
||||
|
||||
statusEl.textContent = `${formatSize(buf.byteLength)} in ${(parseFloat(ms) / 1000).toFixed(1)}s`;
|
||||
statusEl.style.color = "green";
|
||||
outputEl.style.display = "block";
|
||||
|
||||
cachedPdf = buf;
|
||||
document.getElementById("btn-save-pdf").disabled = false;
|
||||
|
||||
logTo(
|
||||
"download-log",
|
||||
`${formatSize(buf.byteLength)} in ${(parseFloat(ms) / 1000).toFixed(1)}s (${formatRate(buf.byteLength, parseFloat(ms))}); SHA-256: ${hash.slice(0, 16)}...`,
|
||||
"green",
|
||||
);
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Failed: ${e}`;
|
||||
statusEl.style.color = "red";
|
||||
logTo("download-log", `FAILED: ${e}`, "red");
|
||||
}
|
||||
document.getElementById("btn-verify-pdf").disabled = false;
|
||||
}
|
||||
|
||||
document.getElementById("btn-verify-text").addEventListener("click", verifyText);
|
||||
document.getElementById("btn-verify-pdf").addEventListener("click", fetchFile);
|
||||
|
||||
document.getElementById("btn-save-pdf").addEventListener("click", () => {
|
||||
if (!cachedPdf) return;
|
||||
const url = document.getElementById("download-url").value.trim();
|
||||
const filename = url.split("/").pop()?.split("?")[0] || "download";
|
||||
saveFile(cachedPdf, filename, "application/octet-stream");
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("btn-verify-all")
|
||||
.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("verify-all-status");
|
||||
statusEl.textContent = "Running...";
|
||||
statusEl.style.color = "orange";
|
||||
logTo("download-log", "running both downloads...");
|
||||
|
||||
const t0 = performance.now();
|
||||
await Promise.allSettled([verifyText(), fetchFile()]);
|
||||
const totalMs = (performance.now() - t0).toFixed(0);
|
||||
|
||||
statusEl.textContent = `Done in ${(parseFloat(totalMs) / 1000).toFixed(1)}s`;
|
||||
statusEl.style.color = "green";
|
||||
logTo(
|
||||
"download-log",
|
||||
`both complete in ${(parseFloat(totalMs) / 1000).toFixed(1)}s`,
|
||||
"green",
|
||||
);
|
||||
});
|
||||
|
||||
// Randomise client ID on each page load for clean state
|
||||
document.getElementById("opt-client-id").value =
|
||||
"smolmix-" + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
display(
|
||||
"smolmix-wasm dev ready. Enter an IPR address and click setupMixTunnel. The clearnet GET works without setup.",
|
||||
);
|
||||
@@ -0,0 +1,230 @@
|
||||
// MixSocket — drop-in WebSocket replacement over the Nym mixnet.
|
||||
//
|
||||
// Mirrors the standard browser WebSocket API (RFC 6455):
|
||||
//
|
||||
// const ws = new MixSocket('wss://echo.example.com/ws');
|
||||
// ws.onopen = () => ws.send('hello');
|
||||
// ws.onmessage = (e) => console.log(e.data);
|
||||
// ws.onclose = (e) => console.log(e.code, e.reason);
|
||||
//
|
||||
// Communicates with the worker via raw postMessage (not Comlink).
|
||||
// The worker maps connId → WASM handleId and forwards events back.
|
||||
|
||||
const CONNECTING = 0;
|
||||
const OPEN = 1;
|
||||
const CLOSING = 2;
|
||||
const CLOSED = 3;
|
||||
|
||||
let _worker = null;
|
||||
let _nextConnId = 1;
|
||||
const _instances = new Map();
|
||||
|
||||
function _onWorkerMessage(event) {
|
||||
const msg = event.data;
|
||||
if (msg?.kind !== 'ws-event') return;
|
||||
|
||||
const instance = _instances.get(msg.connId);
|
||||
if (!instance) return;
|
||||
|
||||
instance._handleEvent(msg.type, msg.data);
|
||||
}
|
||||
|
||||
export class MixSocket extends EventTarget {
|
||||
static CONNECTING = CONNECTING;
|
||||
static OPEN = OPEN;
|
||||
static CLOSING = CLOSING;
|
||||
static CLOSED = CLOSED;
|
||||
|
||||
/**
|
||||
* Bind the raw Worker so MixSocket can post messages to it.
|
||||
* Call once during app setup, after the worker emits 'Loaded'.
|
||||
*/
|
||||
static _initWorker(worker) {
|
||||
_worker = worker;
|
||||
_worker.addEventListener('message', _onWorkerMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url - WebSocket URL (ws:// or wss://)
|
||||
* @param {string|string[]} [protocols] - Sub-protocol(s) to negotiate
|
||||
*/
|
||||
constructor(url, protocols) {
|
||||
super();
|
||||
|
||||
if (!_worker) {
|
||||
throw new Error(
|
||||
'MixSocket: worker not initialised — call MixSocket._initWorker(worker) first',
|
||||
);
|
||||
}
|
||||
|
||||
this._connId = _nextConnId++;
|
||||
this._url = url;
|
||||
this._readyState = CONNECTING;
|
||||
this._protocol = '';
|
||||
this._binaryType = 'blob';
|
||||
|
||||
// Standard event handler properties
|
||||
this.onopen = null;
|
||||
this.onmessage = null;
|
||||
this.onclose = null;
|
||||
this.onerror = null;
|
||||
|
||||
_instances.set(this._connId, this);
|
||||
|
||||
const protoList = protocols
|
||||
? typeof protocols === 'string'
|
||||
? [protocols]
|
||||
: [...protocols]
|
||||
: [];
|
||||
|
||||
_worker.postMessage({
|
||||
kind: 'ws-connect',
|
||||
connId: this._connId,
|
||||
url,
|
||||
protocols: protoList,
|
||||
});
|
||||
}
|
||||
|
||||
get url() {
|
||||
return this._url;
|
||||
}
|
||||
get readyState() {
|
||||
return this._readyState;
|
||||
}
|
||||
get protocol() {
|
||||
return this._protocol;
|
||||
}
|
||||
get extensions() {
|
||||
return '';
|
||||
}
|
||||
get binaryType() {
|
||||
return this._binaryType;
|
||||
}
|
||||
set binaryType(val) {
|
||||
if (val === 'blob' || val === 'arraybuffer') this._binaryType = val;
|
||||
}
|
||||
get bufferedAmount() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data over the WebSocket.
|
||||
* @param {string|ArrayBuffer|ArrayBufferView} data
|
||||
*/
|
||||
send(data) {
|
||||
if (this._readyState !== OPEN) {
|
||||
throw new DOMException('WebSocket is not open', 'InvalidStateError');
|
||||
}
|
||||
|
||||
// Normalise typed arrays to Uint8Array for structured clone
|
||||
let payload = data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
payload = new Uint8Array(data);
|
||||
} else if (ArrayBuffer.isView(data) && !(data instanceof Uint8Array)) {
|
||||
payload = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
|
||||
_worker.postMessage({
|
||||
kind: 'ws-send',
|
||||
connId: this._connId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate the closing handshake.
|
||||
* @param {number} [code=1000] - Status code
|
||||
* @param {string} [reason=''] - Human-readable reason
|
||||
*/
|
||||
close(code = 1000, reason = '') {
|
||||
if (this._readyState === CLOSING || this._readyState === CLOSED) return;
|
||||
this._readyState = CLOSING;
|
||||
_worker.postMessage({
|
||||
kind: 'ws-close',
|
||||
connId: this._connId,
|
||||
code,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal Route an event from the worker to the appropriate handler. */
|
||||
_handleEvent(type, data) {
|
||||
switch (type) {
|
||||
case 'open': {
|
||||
this._readyState = OPEN;
|
||||
this._protocol = data || '';
|
||||
const ev = new Event('open');
|
||||
this.dispatchEvent(ev);
|
||||
if (this.onopen) this.onopen(ev);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const ev = new MessageEvent('message', { data });
|
||||
this.dispatchEvent(ev);
|
||||
if (this.onmessage) this.onmessage(ev);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'binary': {
|
||||
let payload;
|
||||
if (this._binaryType === 'arraybuffer') {
|
||||
payload = data instanceof Uint8Array ? data.buffer : data;
|
||||
} else {
|
||||
// Default: blob
|
||||
payload = data instanceof Uint8Array ? new Blob([data]) : data;
|
||||
}
|
||||
const ev = new MessageEvent('message', { data: payload });
|
||||
this.dispatchEvent(ev);
|
||||
if (this.onmessage) this.onmessage(ev);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'close': {
|
||||
this._readyState = CLOSED;
|
||||
_instances.delete(this._connId);
|
||||
|
||||
// Parse close info: "1000 normal closure" → code=1000, reason="normal closure"
|
||||
let code = 1005;
|
||||
let reason = '';
|
||||
if (typeof data === 'string') {
|
||||
const match = data.match(/^(\d+)\s*(.*)/);
|
||||
if (match) {
|
||||
code = parseInt(match[1], 10);
|
||||
reason = match[2] || '';
|
||||
} else {
|
||||
reason = data;
|
||||
}
|
||||
}
|
||||
|
||||
const ev = new CloseEvent('close', {
|
||||
code,
|
||||
reason,
|
||||
wasClean: code === 1000,
|
||||
});
|
||||
this.dispatchEvent(ev);
|
||||
if (this.onclose) this.onclose(ev);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
this._readyState = CLOSED;
|
||||
_instances.delete(this._connId);
|
||||
|
||||
const errorEv = new Event('error');
|
||||
this.dispatchEvent(errorEv);
|
||||
if (this.onerror) this.onerror(errorEv);
|
||||
|
||||
// Spec: error is always followed by close (code 1006 = abnormal closure)
|
||||
const closeEv = new CloseEvent('close', {
|
||||
code: 1006,
|
||||
reason: typeof data === 'string' ? data : '',
|
||||
wasClean: false,
|
||||
});
|
||||
this.dispatchEvent(closeEv);
|
||||
if (this.onclose) this.onclose(closeEv);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "smolmix-wasm-internal-dev",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack serve --open",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"comlink": "^4.3.1",
|
||||
"smolmix-wasm": "file:../pkg"
|
||||
},
|
||||
"devDependencies": {
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.2.1"
|
||||
}
|
||||
}
|
||||
Generated
+3051
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
const path = require('path');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
bundle: './bootstrap.js',
|
||||
headless: './headless-bootstrap.js',
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{ from: 'index.html', to: '.' },
|
||||
{ from: 'headless.html', to: '.' },
|
||||
{ from: '../pkg/smolmix_wasm_bg.wasm', to: '.' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
experiments: {
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
devServer: {
|
||||
static: { directory: path.resolve(__dirname, 'dist') },
|
||||
port: 9000,
|
||||
// Required for SharedArrayBuffer (if ever needed) and secure context:
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
mode: 'development',
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
// smolmix-wasm Web Worker
|
||||
//
|
||||
// Loads the WASM module in a dedicated thread. All mixnet I/O
|
||||
// (smoltcp polling, TLS crypto, DNS resolution, mixnet messaging)
|
||||
// runs here, keeping the main thread responsive for UI rendering.
|
||||
//
|
||||
// Two communication channels:
|
||||
// 1. Comlink — request/response for fetch (setupMixTunnel, mixFetch, disconnect)
|
||||
// 2. Raw postMessage — bidirectional events for WebSocket (ws-connect, ws-send, ws-close)
|
||||
//
|
||||
// Comlink ignores messages without its internal UUID markers,
|
||||
// so both channels coexist on the same Worker without conflicts.
|
||||
|
||||
import initWasm, {
|
||||
setupMixTunnel as wasmSetup,
|
||||
mixFetch as wasmFetch,
|
||||
mixResolve as wasmResolve,
|
||||
disconnectMixTunnel as wasmDisconnect,
|
||||
mixSocket as wasmMixSocket,
|
||||
wsSend as wasmWsSend,
|
||||
wsClose as wasmWsClose,
|
||||
setDebugLogging as wasmSetDebugLogging,
|
||||
} from "smolmix-wasm";
|
||||
import * as Comlink from "comlink";
|
||||
|
||||
let wasmReady = false;
|
||||
|
||||
// Comlink API (fetch)
|
||||
|
||||
const api = {
|
||||
async setupMixTunnel(opts) {
|
||||
if (!wasmReady) {
|
||||
await initWasm();
|
||||
wasmReady = true;
|
||||
}
|
||||
await wasmSetup(opts);
|
||||
},
|
||||
|
||||
async mixFetch(url, init) {
|
||||
if (!wasmReady) {
|
||||
throw new Error("WASM not initialised; call setupMixTunnel first");
|
||||
}
|
||||
return await wasmFetch(url, init || {});
|
||||
},
|
||||
|
||||
async mixResolve(hostname) {
|
||||
if (!wasmReady) {
|
||||
throw new Error("WASM not initialised; call setupMixTunnel first");
|
||||
}
|
||||
return await wasmResolve(hostname);
|
||||
},
|
||||
|
||||
async disconnectMixTunnel() {
|
||||
if (!wasmReady) {
|
||||
throw new Error("WASM not initialised; call setupMixTunnel first");
|
||||
}
|
||||
await wasmDisconnect();
|
||||
},
|
||||
|
||||
async setDebugLogging(enabled) {
|
||||
if (!wasmReady) {
|
||||
await initWasm();
|
||||
wasmReady = true;
|
||||
}
|
||||
wasmSetDebugLogging(enabled);
|
||||
},
|
||||
};
|
||||
|
||||
Comlink.expose(api);
|
||||
|
||||
// Raw postMessage API (WebSocket)
|
||||
|
||||
// Maps main-thread connId → WASM handleId
|
||||
const wsConnMap = new Map();
|
||||
|
||||
self.addEventListener("message", async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg.kind !== "string") return;
|
||||
|
||||
switch (msg.kind) {
|
||||
case "ws-connect": {
|
||||
if (!wasmReady) {
|
||||
self.postMessage({
|
||||
kind: "ws-event",
|
||||
connId: msg.connId,
|
||||
type: "error",
|
||||
data: "WASM not initialised — call setupMixTunnel first",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// WASM callback: fires for open/text/binary/close/error events.
|
||||
// Captures connId so all events route to the correct MixSocket instance.
|
||||
const onEvent = (handleId, type, data) => {
|
||||
if (!wsConnMap.has(msg.connId)) {
|
||||
wsConnMap.set(msg.connId, handleId);
|
||||
}
|
||||
self.postMessage({ kind: "ws-event", connId: msg.connId, type, data });
|
||||
};
|
||||
|
||||
try {
|
||||
await wasmMixSocket(msg.url, msg.protocols, onEvent);
|
||||
} catch (e) {
|
||||
console.error("[ws] connect failed:", e);
|
||||
self.postMessage({
|
||||
kind: "ws-event",
|
||||
connId: msg.connId,
|
||||
type: "error",
|
||||
data: String(e),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ws-send": {
|
||||
const handleId = wsConnMap.get(msg.connId);
|
||||
if (handleId == null) {
|
||||
console.warn(`[ws] send for connId ${msg.connId} before connection ready`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wasmWsSend(handleId, msg.payload);
|
||||
} catch (e) {
|
||||
self.postMessage({
|
||||
kind: "ws-event",
|
||||
connId: msg.connId,
|
||||
type: "error",
|
||||
data: String(e),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ws-close": {
|
||||
const handleId = wsConnMap.get(msg.connId);
|
||||
if (handleId == null) {
|
||||
console.warn(`[ws] close for connId ${msg.connId} before connection ready`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wasmWsClose(handleId, msg.code || 1000, msg.reason || "");
|
||||
} catch (e) {
|
||||
self.postMessage({
|
||||
kind: "ws-event",
|
||||
connId: msg.connId,
|
||||
type: "error",
|
||||
data: String(e),
|
||||
});
|
||||
}
|
||||
wsConnMap.delete(msg.connId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.postMessage({ kind: "Loaded" });
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Packet bridge between the smoltcp device and the Nym mixnet.
|
||||
//!
|
||||
//! Two concurrent loops in one `spawn_local` task:
|
||||
//!
|
||||
//! **Outgoing** (smoltcp → mixnet): on each tick, drain the device tx queue
|
||||
//! and send each IP packet to the IPR as an LP-framed DataRequest.
|
||||
//!
|
||||
//! **Incoming** (mixnet → smoltcp): receive `ReconstructedMessage` batches,
|
||||
//! LP-decode, parse IPR responses, unbundle IP packets, push to device rx.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use nym_wasm_client_core::Recipient;
|
||||
use nym_wasm_client_core::client::base_client::ClientInput;
|
||||
use nym_wasm_client_core::nym_task::ShutdownTracker;
|
||||
|
||||
use crate::ipr::{self, ReconstructedReceiver};
|
||||
use crate::reactor::{ReactorNotify, SmoltcpStack};
|
||||
use crate::state::{State, TaskName};
|
||||
|
||||
/// Maximum outgoing packets sent per bridge tick.
|
||||
///
|
||||
/// Limits how long the bridge spends in the serial send loop before
|
||||
/// returning to check for incoming messages. Remaining packets are
|
||||
/// picked up on the next tick.
|
||||
const MAX_OUTGOING_PER_TICK: usize = 8;
|
||||
|
||||
/// Start the bridge as a `spawn_local` background task.
|
||||
///
|
||||
/// Each iteration:
|
||||
/// 1. Wait for an event (incoming message OR timer tick)
|
||||
/// 2. Drain all pending incoming messages (non-blocking)
|
||||
/// 3. Drain outgoing packets (capped at `MAX_OUTGOING_PER_TICK`)
|
||||
///
|
||||
/// Incoming is processed first to prevent starvation; the timer can
|
||||
/// dominate `select!` if always ready.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start_bridge(
|
||||
stack: SmoltcpStack,
|
||||
client_input: Arc<ClientInput>,
|
||||
mut msg_receiver: ReconstructedReceiver,
|
||||
ipr_address: Recipient,
|
||||
stream_id: u64,
|
||||
notify_reactor: ReactorNotify,
|
||||
tracker: &ShutdownTracker,
|
||||
state: State,
|
||||
data_surbs: u32,
|
||||
) {
|
||||
// Cloned so finalise_task can check is_cancelled() on the way out.
|
||||
let token = tracker.clone_shutdown_token();
|
||||
tracker.try_spawn_named_with_shutdown(
|
||||
async move {
|
||||
let mut tx_interval = wasmtimer::tokio::interval(Duration::from_millis(5));
|
||||
// Outbound seq starts at 1; seq=0 is reserved for handshake
|
||||
// frames (see `ipr::open_and_connect`).
|
||||
let mut seq: u32 = 1;
|
||||
|
||||
loop {
|
||||
// Block until something happens (incoming message or timer tick).
|
||||
// `futures::select!` polls pseudo-randomly when both branches are
|
||||
// ready, so textual order is not a priority guarantee.
|
||||
// TODO: consider `futures::select_biased!` if a sustained timer
|
||||
// burst ever shows up as incoming-side latency.
|
||||
futures::select! {
|
||||
batch = msg_receiver.next().fuse() => {
|
||||
let Some(messages) = batch else {
|
||||
crate::util::debug_error!(
|
||||
"[bridge] message channel closed"
|
||||
);
|
||||
break;
|
||||
};
|
||||
process_incoming(
|
||||
&stack, &messages, stream_id, ¬ify_reactor,
|
||||
);
|
||||
}
|
||||
_ = tx_interval.tick().fuse() => {}
|
||||
}
|
||||
|
||||
// Non-blockingly drain any remaining incoming messages so we
|
||||
// never let them queue up while we're sending outgoing packets.
|
||||
while let Some(Some(messages)) = msg_receiver.next().now_or_never() {
|
||||
process_incoming(&stack, &messages, stream_id, ¬ify_reactor);
|
||||
}
|
||||
|
||||
// Drain outgoing packets (capped to avoid starving incoming).
|
||||
let packets: Vec<Vec<u8>> =
|
||||
stack.with(|s| s.device.drain_tx().take(MAX_OUTGOING_PER_TICK).collect());
|
||||
|
||||
if !packets.is_empty() {
|
||||
crate::util::debug_log!("[bridge] ▲ tx ({} packets)", packets.len());
|
||||
}
|
||||
for packet in packets {
|
||||
let current_seq = seq;
|
||||
// Skip 0 on wrap: it's reserved for handshake frames
|
||||
// (see `ipr::open_and_connect`).
|
||||
seq = if seq == u32::MAX { 1 } else { seq + 1 };
|
||||
if let Err(e) = ipr::send_ip_packet(
|
||||
&client_input,
|
||||
&ipr_address,
|
||||
stream_id,
|
||||
current_seq,
|
||||
&packet,
|
||||
data_surbs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::util::debug_error!("[bridge] send error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.finalise_task(TaskName::Bridge, &token);
|
||||
},
|
||||
"smolmix-bridge",
|
||||
);
|
||||
}
|
||||
|
||||
/// Process a batch of incoming mixnet messages: LP-decode, parse IPR
|
||||
/// responses, push IP packets to the device rx queue, notify reactor.
|
||||
fn process_incoming(
|
||||
stack: &SmoltcpStack,
|
||||
messages: &[nym_wasm_client_core::ReconstructedMessage],
|
||||
stream_id: u64,
|
||||
notify_reactor: &ReactorNotify,
|
||||
) {
|
||||
let mut pushed = 0usize;
|
||||
for msg in messages {
|
||||
match ipr::parse_incoming(msg, stream_id) {
|
||||
Ok(Some(packets)) => {
|
||||
stack.with(|s| {
|
||||
for packet in &packets {
|
||||
s.device.push_rx(packet.clone());
|
||||
pushed += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
crate::util::debug_error!("[bridge] incoming error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pushed > 0 {
|
||||
crate::util::debug_log!("[bridge] ▼ rx ({pushed} packets)");
|
||||
notify_reactor.notify_one();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! smoltcp `Device` implementation backed by in-memory VecDeque buffers.
|
||||
//!
|
||||
//! The native smolmix uses `NymAsyncDevice` (Stream/Sink over mpsc channels) fed
|
||||
//! into `tokio-smoltcp`. On wasm32 we drive smoltcp directly, so we need a sync
|
||||
//! `Device` impl instead. The bridge pushes incoming IP packets into `rx_queue`
|
||||
//! and pops outgoing packets from `tx_queue`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
||||
use smoltcp::time::Instant;
|
||||
|
||||
/// smoltcp device backed by in-memory packet queues.
|
||||
///
|
||||
/// The bridge task feeds incoming IP packets via [`push_rx`](Self::push_rx) and
|
||||
/// drains outgoing packets via [`drain_tx`](Self::drain_tx). The reactor calls
|
||||
/// smoltcp's `Interface::poll()` which invokes the `Device` trait methods.
|
||||
pub struct WasmDevice {
|
||||
rx_queue: VecDeque<Vec<u8>>,
|
||||
tx_queue: VecDeque<Vec<u8>>,
|
||||
capabilities: DeviceCapabilities,
|
||||
}
|
||||
|
||||
impl WasmDevice {
|
||||
pub fn new() -> Self {
|
||||
let mut capabilities = DeviceCapabilities::default();
|
||||
capabilities.medium = Medium::Ip;
|
||||
// Sized so one IP packet fits in one sphinx packet payload (no
|
||||
// chunking-layer fragmentation). Budget in bytes from the 2048 B
|
||||
// sphinx plaintext: − 344 (SURB-ack) − 32 (x25519 ephemeral key,
|
||||
// Repliable msgs) − 7 (frag header) − 1 (padding) − 53 (LP+IPR
|
||||
// framing + AEAD) ≈ 1611. 1600 leaves ~11 B headroom for IPR
|
||||
// overhead variability.
|
||||
capabilities.max_transmission_unit = 1600;
|
||||
// Native smolmix also uses Some(1) in the device, but tokio-smoltcp
|
||||
// compensates with a burst loop that calls Interface::poll() up to 100
|
||||
// times per reactor iteration (each processing 1 packet). Our WASM
|
||||
// reactor only calls poll() once per tick, so we set a higher burst
|
||||
// size to let smoltcp drain all pending packets in a single poll().
|
||||
capabilities.max_burst_size = Some(100);
|
||||
|
||||
Self {
|
||||
rx_queue: VecDeque::new(),
|
||||
tx_queue: VecDeque::new(),
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an incoming IP packet (from the mixnet) into the receive queue.
|
||||
pub fn push_rx(&mut self, packet: Vec<u8>) {
|
||||
self.rx_queue.push_back(packet);
|
||||
}
|
||||
|
||||
/// Drain all outgoing IP packets (generated by smoltcp) from the transmit queue.
|
||||
pub fn drain_tx(&mut self) -> impl Iterator<Item = Vec<u8>> + '_ {
|
||||
self.tx_queue.drain(..)
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for WasmDevice {
|
||||
type RxToken<'a> = WasmRxToken;
|
||||
type TxToken<'a> = WasmTxToken<'a>;
|
||||
|
||||
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
||||
let packet = self.rx_queue.pop_front()?;
|
||||
Some((
|
||||
WasmRxToken { buffer: packet },
|
||||
WasmTxToken {
|
||||
queue: &mut self.tx_queue,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
|
||||
Some(WasmTxToken {
|
||||
queue: &mut self.tx_queue,
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> DeviceCapabilities {
|
||||
self.capabilities.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive token: delivers one packet from the rx queue to smoltcp.
|
||||
pub struct WasmRxToken {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl RxToken for WasmRxToken {
|
||||
fn consume<R, F>(self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&[u8]) -> R,
|
||||
{
|
||||
f(&self.buffer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transmit token: captures one packet from smoltcp into the tx queue.
|
||||
pub struct WasmTxToken<'a> {
|
||||
queue: &'a mut VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> TxToken for WasmTxToken<'a> {
|
||||
fn consume<R, F>(self, len: usize, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut [u8]) -> R,
|
||||
{
|
||||
let mut buffer = vec![0u8; len];
|
||||
let result = f(&mut buffer);
|
||||
self.queue.push_back(buffer);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_rx_and_receive() {
|
||||
let mut dev = WasmDevice::new();
|
||||
dev.push_rx(vec![1, 2, 3]);
|
||||
|
||||
let now = Instant::from_millis(0);
|
||||
let (rx, _tx) = dev.receive(now).expect("should have a packet");
|
||||
let data = rx.consume(|buf| buf.to_vec());
|
||||
assert_eq!(data, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transmit_and_drain() {
|
||||
let mut dev = WasmDevice::new();
|
||||
let now = Instant::from_millis(0);
|
||||
|
||||
let tx = dev.transmit(now).expect("should get tx token");
|
||||
tx.consume(4, |buf| {
|
||||
buf.copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
});
|
||||
|
||||
let packets: Vec<_> = dev.drain_tx().collect();
|
||||
assert_eq!(packets.len(), 1);
|
||||
assert_eq!(packets[0], vec![0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_receive_returns_none() {
|
||||
let mut dev = WasmDevice::new();
|
||||
assert!(dev.receive(Instant::from_millis(0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_are_ip_mode() {
|
||||
let dev = WasmDevice::new();
|
||||
let caps = dev.capabilities();
|
||||
assert_eq!(caps.medium, Medium::Ip);
|
||||
assert_eq!(caps.max_transmission_unit, 1980);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! DNS A/AAAA resolution over the tunnel's UDP socket. Defaults are 8.8.8.8
|
||||
//! primary with 1.1.1.1 fallback, overridable via `TunnelOpts::primary_dns`
|
||||
//! / `fallback_dns`. Wire format via `hickory-proto`; results cached per session.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use hickory_proto::op::{Message, Query};
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::stream::WasmUdpSocket;
|
||||
use crate::tunnel::WasmTunnel;
|
||||
|
||||
/// Maximum number of CNAME hops before giving up.
|
||||
const MAX_CNAME_HOPS: usize = 8;
|
||||
|
||||
pub const DEFAULT_PRIMARY_DNS: SocketAddr =
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53);
|
||||
pub const DEFAULT_FALLBACK_DNS: SocketAddr =
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53);
|
||||
|
||||
/// Resolve a hostname to an IP through the mixnet tunnel.
|
||||
pub async fn resolve(tunnel: &WasmTunnel, hostname: &str) -> Result<IpAddr, FetchError> {
|
||||
if let Ok(ip) = hostname.parse::<IpAddr>() {
|
||||
return Ok(ip);
|
||||
}
|
||||
|
||||
// Serialise DNS lookups so concurrent callers coalesce on the cache.
|
||||
let _guard = tunnel.dns_lock().lock().await;
|
||||
|
||||
if let Some(&ip) = tunnel.dns_cache().lock().unwrap().get(hostname) {
|
||||
crate::util::debug_log!("[dns] cache hit: '{hostname}' => {ip}");
|
||||
return Ok(ip);
|
||||
}
|
||||
|
||||
crate::util::debug_log!("[dns] resolving '{hostname}'...");
|
||||
let udp = tunnel.udp_socket().await.map_err(FetchError::Io)?;
|
||||
|
||||
let timeout = tunnel.dns_timeout();
|
||||
let ip = match resolve_with(&udp, hostname, tunnel.dns_primary(), timeout).await {
|
||||
Ok(ip) => ip,
|
||||
Err(_) => resolve_with(&udp, hostname, tunnel.dns_fallback(), timeout).await?,
|
||||
};
|
||||
|
||||
crate::util::debug_log!("[dns] resolved '{hostname}' => {ip}");
|
||||
tunnel
|
||||
.dns_cache()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(hostname.to_string(), ip);
|
||||
Ok(ip)
|
||||
}
|
||||
|
||||
/// Try A then AAAA against a specific DNS server, following CNAME chains.
|
||||
async fn resolve_with(
|
||||
udp: &WasmUdpSocket,
|
||||
hostname: &str,
|
||||
server: SocketAddr,
|
||||
timeout: Duration,
|
||||
) -> Result<IpAddr, FetchError> {
|
||||
match query_following_cnames(udp, hostname, RecordType::A, server, timeout).await {
|
||||
Ok(ip) => Ok(ip),
|
||||
Err(_) => query_following_cnames(udp, hostname, RecordType::AAAA, server, timeout).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a DNS query and follow any CNAME chain until we get an IP or exhaust hops.
|
||||
async fn query_following_cnames(
|
||||
udp: &WasmUdpSocket,
|
||||
hostname: &str,
|
||||
record_type: RecordType,
|
||||
server: SocketAddr,
|
||||
timeout: Duration,
|
||||
) -> Result<IpAddr, FetchError> {
|
||||
let mut current_name = hostname.to_string();
|
||||
|
||||
for _ in 0..MAX_CNAME_HOPS {
|
||||
match query_record(udp, ¤t_name, record_type, server, timeout).await? {
|
||||
DnsResult::Ip(ip) => return Ok(ip),
|
||||
DnsResult::Cname(target) => current_name = target,
|
||||
}
|
||||
}
|
||||
|
||||
Err(FetchError::Dns(format!(
|
||||
"CNAME chain too long (>{MAX_CNAME_HOPS} hops) for {hostname}"
|
||||
)))
|
||||
}
|
||||
|
||||
enum DnsResult {
|
||||
Ip(IpAddr),
|
||||
Cname(String),
|
||||
}
|
||||
|
||||
/// Send a single DNS query and parse the response.
|
||||
///
|
||||
/// The `WasmUdpSocket` is shared across PRIMARY → FALLBACK, A → AAAA, and
|
||||
/// every CNAME hop in one resolve, so leftover datagrams from a prior query
|
||||
/// can be sitting in the receive buffer. We loop on `recv_from`, dropping
|
||||
/// any datagram whose transaction ID doesn't match the query we just sent,
|
||||
/// until either a match arrives or `timeout` elapses.
|
||||
async fn query_record(
|
||||
udp: &WasmUdpSocket,
|
||||
hostname: &str,
|
||||
record_type: RecordType,
|
||||
server: SocketAddr,
|
||||
timeout: Duration,
|
||||
) -> Result<DnsResult, FetchError> {
|
||||
let (query_bytes, query_id) = build_query(hostname, record_type)?;
|
||||
udp.send_to(&query_bytes, server)
|
||||
.await
|
||||
.map_err(FetchError::Io)?;
|
||||
crate::util::debug_log!("[dns] query sent to {server} (id={query_id:#06x}), waiting...");
|
||||
|
||||
let start = wasmtimer::std::Instant::now();
|
||||
|
||||
loop {
|
||||
let remaining = timeout
|
||||
.checked_sub(start.elapsed())
|
||||
.ok_or(FetchError::Timeout)?;
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
let (len, src) = wasmtimer::tokio::timeout(remaining, udp.recv_from(&mut buf))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::util::debug_error!("[dns] recv_from TIMED OUT after {timeout:?}");
|
||||
FetchError::Timeout
|
||||
})?
|
||||
.map_err(FetchError::Io)?;
|
||||
|
||||
// Anti-spoof layer 1: source address must match the server we queried.
|
||||
// The UDP socket is reused across CNAME hops and primary/fallback
|
||||
// retries, so a late reply from an earlier `server` is a real case,
|
||||
// not just hypothetical.
|
||||
if src != server {
|
||||
crate::util::debug_log!(
|
||||
"[dns] dropped datagram from unexpected source {src} (expected {server})"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anti-spoof layer 2: parse failures and ID mismatches are also
|
||||
// "keep reading," not "abort the lookup." A single malformed packet
|
||||
// from `server` (or an attacker who can spoof the source) shouldn't
|
||||
// turn a live query into a hard failure.
|
||||
let response = match Message::from_vec(&buf[..len]) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::util::debug_log!("[dns] dropped malformed datagram from {src}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Anti-spoof layer 3: transaction ID must match. The id was filled
|
||||
// from `rand::random()` (CSPRNG via `getrandom`/js), so the guess
|
||||
// rate for an off-path attacker is the theoretical 1/65536 per try.
|
||||
if response.id != query_id {
|
||||
crate::util::debug_log!(
|
||||
"[dns] dropped stale datagram (id={:#06x}, expected {query_id:#06x})",
|
||||
response.id,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
return parse_response(&response, hostname);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a DNS query and return its bytes plus transaction ID.
|
||||
fn build_query(hostname: &str, record_type: RecordType) -> Result<(Vec<u8>, u16), FetchError> {
|
||||
let mut msg = Message::query();
|
||||
msg.metadata.recursion_desired = true;
|
||||
let id = msg.metadata.id;
|
||||
|
||||
let name = Name::from_ascii(hostname)
|
||||
.map_err(|e| FetchError::Dns(format!("invalid hostname '{hostname}': {e}")))?;
|
||||
msg.add_query(Query::query(name, record_type));
|
||||
|
||||
let bytes = msg
|
||||
.to_vec()
|
||||
.map_err(|e| FetchError::Dns(format!("failed to serialise DNS query: {e}")))?;
|
||||
Ok((bytes, id))
|
||||
}
|
||||
|
||||
/// Parse a DNS response message, returning an IP or CNAME target.
|
||||
fn parse_response(msg: &Message, hostname: &str) -> Result<DnsResult, FetchError> {
|
||||
let mut cname_target: Option<String> = None;
|
||||
|
||||
for record in &msg.answers {
|
||||
match &record.data {
|
||||
RData::A(a) => return Ok(DnsResult::Ip(IpAddr::V4(a.0))),
|
||||
RData::AAAA(aaaa) => return Ok(DnsResult::Ip(IpAddr::V6(aaaa.0))),
|
||||
RData::CNAME(cname) if cname_target.is_none() => {
|
||||
cname_target = Some(cname.0.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(target) = cname_target {
|
||||
return Ok(DnsResult::Cname(target));
|
||||
}
|
||||
|
||||
Err(FetchError::Dns(format!(
|
||||
"no A, AAAA, or CNAME records for {hostname}"
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_wasm_utils::wasm_error;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FetchError {
|
||||
#[error("URL error: {0}")]
|
||||
Url(#[from] url::ParseError),
|
||||
|
||||
#[error("DNS error: {0}")]
|
||||
Dns(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[cfg(feature = "fetch")]
|
||||
#[error("hyper error: {0}")]
|
||||
Hyper(#[from] hyper::Error),
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[cfg(feature = "socket")]
|
||||
#[error("WebSocket error: {0}")]
|
||||
WebSocket(#[from] async_tungstenite::tungstenite::Error),
|
||||
|
||||
#[error("JS interop error: {0}")]
|
||||
Js(String),
|
||||
|
||||
#[error("tunnel error: {0}")]
|
||||
Tunnel(String),
|
||||
|
||||
#[error("tunnel not connected")]
|
||||
NotConnected,
|
||||
|
||||
#[error("operation timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
wasm_error!(FetchError);
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! TCP/TLS connection construction shared by `mixFetch` and `mixSocket`,
|
||||
//! plus (under the `fetch` feature) the HTTP orchestration + JS `RequestInit` shim.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::dns;
|
||||
use crate::error::FetchError;
|
||||
use crate::stream::PooledConn;
|
||||
use crate::tls;
|
||||
use crate::tunnel::WasmTunnel;
|
||||
|
||||
// HTTP-orchestration imports: only needed when the `fetch` feature is on.
|
||||
#[cfg(feature = "fetch")]
|
||||
use crate::http::{self, HttpResponse};
|
||||
#[cfg(feature = "fetch")]
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array};
|
||||
#[cfg(feature = "fetch")]
|
||||
use url::Url;
|
||||
#[cfg(feature = "fetch")]
|
||||
use wasm_bindgen::JsCast;
|
||||
#[cfg(feature = "fetch")]
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
/// Options extracted from a JS `RequestInit` object.
|
||||
#[cfg(feature = "fetch")]
|
||||
struct FetchInit {
|
||||
method: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Execute a fetch request through the mixnet tunnel.
|
||||
#[cfg(feature = "fetch")]
|
||||
pub async fn fetch(
|
||||
tunnel: &WasmTunnel,
|
||||
url_str: &str,
|
||||
init: &JsValue,
|
||||
) -> Result<JsValue, FetchError> {
|
||||
let mut opts = parse_init(init)?;
|
||||
let mut url = Url::parse(url_str)?;
|
||||
if !is_http_scheme(&url) {
|
||||
return Err(FetchError::Http(format!(
|
||||
"unsupported scheme '{}' (expected http or https)",
|
||||
url.scheme()
|
||||
)));
|
||||
}
|
||||
let mut method = opts.method.clone();
|
||||
let mut body = opts.body.clone();
|
||||
let max_redirects = tunnel.max_redirects();
|
||||
|
||||
for redirect_count in 0..=max_redirects {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| FetchError::Http("URL has no host".into()))?
|
||||
.to_string();
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| FetchError::Http("URL has no port and scheme is unknown".into()))?;
|
||||
let is_https = url.scheme() == "https";
|
||||
|
||||
if redirect_count == 0 {
|
||||
crate::util::debug_log!("[fetch] {} {} ({})", method, url.as_str(), url.scheme());
|
||||
} else {
|
||||
crate::util::debug_log!(
|
||||
"[fetch] redirect #{redirect_count} → {host}:{port} ({})",
|
||||
url.scheme()
|
||||
);
|
||||
}
|
||||
|
||||
// Per-origin lock: serialise TCP+TLS handshake, release before HTTP.
|
||||
let (conn, from_pool) = {
|
||||
let origin_lock = tunnel.origin_lock(&host, port);
|
||||
crate::util::debug_log!("[fetch] acquiring origin lock for {host}:{port}...");
|
||||
let _guard = origin_lock.lock().await;
|
||||
crate::util::debug_log!("[fetch] origin lock ACQUIRED for {host}:{port}");
|
||||
|
||||
let result = match tunnel.take_pooled(&host, port) {
|
||||
Some(c) => {
|
||||
crate::util::debug_log!("[fetch] pool HIT for {host}:{port}");
|
||||
(c, true)
|
||||
}
|
||||
None => {
|
||||
crate::util::debug_log!("[fetch] pool MISS for {host}:{port}, new connection");
|
||||
(new_connection(tunnel, &host, port, is_https).await?, false)
|
||||
}
|
||||
};
|
||||
|
||||
crate::util::debug_log!("[fetch] origin lock RELEASED for {host}:{port}");
|
||||
result
|
||||
};
|
||||
|
||||
// Retry pooled connections once on first-write error; fresh-conn
|
||||
// errors propagate. We only retry idempotent methods because hyper
|
||||
// can fail mid-body-write, and we have no reliable way to tell
|
||||
// whether the server already received and acted on the request. A
|
||||
// silent retry of POST/PUT/PATCH/DELETE could duplicate side-effects
|
||||
// (double payment, repeat resource creation, etc.).
|
||||
let http_result = http::request(conn, &method, &url, &opts.headers, body.as_deref()).await;
|
||||
|
||||
let (response, reusable, conn) = match http_result {
|
||||
Ok(result) => result,
|
||||
Err(stale_err) if from_pool && is_idempotent(&method) => {
|
||||
crate::util::debug_log!(
|
||||
"[fetch] pooled connection failed ({stale_err}), retrying with fresh connection"
|
||||
);
|
||||
let fresh = new_connection(tunnel, &host, port, is_https).await?;
|
||||
match http::request(fresh, &method, &url, &opts.headers, body.as_deref()).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
crate::util::debug_error!(
|
||||
"[fetch] fresh connection also failed: {e} (pooled failed with: {stale_err})"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
crate::util::debug_log!(
|
||||
"[fetch] {} {} ({} bytes, reusable={})",
|
||||
response.status,
|
||||
response.status_text,
|
||||
response.body.len(),
|
||||
reusable
|
||||
);
|
||||
|
||||
// 3. Return connection to pool if reusable
|
||||
if reusable {
|
||||
crate::util::debug_log!("[fetch] returning connection to pool for {host}:{port}");
|
||||
tunnel.return_to_pool(host, port, conn);
|
||||
}
|
||||
|
||||
// 4. Follow redirects (3xx with Location header). Any other status,
|
||||
// or a 3xx without Location, returns directly.
|
||||
if !(300..400).contains(&response.status) {
|
||||
return serialise_response(&response);
|
||||
}
|
||||
|
||||
let location = response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("location"))
|
||||
.map(|(_, v)| v.clone());
|
||||
|
||||
let Some(location) = location else {
|
||||
return serialise_response(&response);
|
||||
};
|
||||
|
||||
crate::util::debug_log!("[fetch] {} → Location: {location}", response.status);
|
||||
|
||||
let prev_url = url.clone();
|
||||
url = prev_url
|
||||
.join(&location)
|
||||
.map_err(|e| FetchError::Http(format!("invalid redirect URL '{location}': {e}")))?;
|
||||
|
||||
// Reject non-HTTP schemes (javascript:, file:, data:, …) and any
|
||||
// HTTPS to HTTP downgrade; classic redirect exfiltration shapes.
|
||||
if !is_http_scheme(&url) {
|
||||
return Err(FetchError::Http(format!(
|
||||
"redirect to unsupported scheme '{}' rejected",
|
||||
url.scheme()
|
||||
)));
|
||||
}
|
||||
if prev_url.scheme() == "https" && url.scheme() == "http" {
|
||||
return Err(FetchError::Http(
|
||||
"redirect from https to http rejected (would leak credentials)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Strip credential-bearing headers on cross-origin redirects so
|
||||
// Authorization / Cookie / Proxy-Authorization never follow a hop
|
||||
// to a different origin. Same-origin redirects keep them.
|
||||
if prev_url.origin() != url.origin() {
|
||||
strip_sensitive_headers(&mut opts.headers);
|
||||
}
|
||||
|
||||
// 301/302/303: switch to GET and drop body (RFC 7231).
|
||||
// 307/308: preserve method and body.
|
||||
if matches!(response.status, 301..=303) {
|
||||
method = "GET".into();
|
||||
body = None;
|
||||
}
|
||||
}
|
||||
|
||||
Err(FetchError::Http(format!(
|
||||
"too many redirects (>{max_redirects})"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Create a fresh connection: DNS resolve → TCP connect → optional TLS.
|
||||
pub(crate) async fn new_connection(
|
||||
tunnel: &WasmTunnel,
|
||||
host: &str,
|
||||
port: u16,
|
||||
is_https: bool,
|
||||
) -> Result<PooledConn, FetchError> {
|
||||
let ip = dns::resolve(tunnel, host).await?;
|
||||
let addr = SocketAddr::new(ip, port);
|
||||
|
||||
crate::util::debug_log!("[fetch] TCP connecting to {addr}...");
|
||||
let tcp = tunnel.tcp_connect(addr).await.map_err(FetchError::Io)?;
|
||||
crate::util::debug_log!("[fetch] TCP connected to {addr}");
|
||||
|
||||
if is_https {
|
||||
crate::util::debug_log!("[fetch] TLS handshake with '{host}'...");
|
||||
let tls_stream = tls::connect(tcp, host).await?;
|
||||
crate::util::debug_log!("[fetch] TLS handshake complete with '{host}'");
|
||||
Ok(PooledConn::Tls(tls_stream))
|
||||
} else {
|
||||
Ok(PooledConn::Plain(tcp))
|
||||
}
|
||||
}
|
||||
|
||||
// RequestInit extraction (via js_sys::Reflect, no serde): `fetch` feature only.
|
||||
|
||||
/// Extract method, headers, and body from a JS `RequestInit` object.
|
||||
#[cfg(feature = "fetch")]
|
||||
fn parse_init(init: &JsValue) -> Result<FetchInit, FetchError> {
|
||||
// Handle undefined/null init (bare GET request)
|
||||
if init.is_undefined() || init.is_null() {
|
||||
return Ok(FetchInit {
|
||||
method: "GET".into(),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
});
|
||||
}
|
||||
|
||||
let method = Reflect::get(init, &JsValue::from_str("method"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_else(|| "GET".into());
|
||||
|
||||
let headers = extract_headers(init)?;
|
||||
let body = extract_body(init)?;
|
||||
|
||||
Ok(FetchInit {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract headers from a plain JS object `{ "Header-Name": "value" }`.
|
||||
#[cfg(feature = "fetch")]
|
||||
fn extract_headers(init: &JsValue) -> Result<Vec<(String, String)>, FetchError> {
|
||||
let headers_val = match Reflect::get(init, &JsValue::from_str("headers")) {
|
||||
Ok(v) if !v.is_undefined() && !v.is_null() => v,
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
if let Some(obj) = headers_val.dyn_ref::<Object>() {
|
||||
let keys = Object::keys(obj);
|
||||
for i in 0..keys.length() {
|
||||
let key_val = keys.get(i);
|
||||
if let Some(key) = key_val.as_string() {
|
||||
if let Ok(val) = Reflect::get(obj, &key_val) {
|
||||
if let Some(val_str) = val.as_string() {
|
||||
result.push((key, val_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Extract the request body. Supports string, Uint8Array, and ArrayBuffer.
|
||||
#[cfg(feature = "fetch")]
|
||||
fn extract_body(init: &JsValue) -> Result<Option<Vec<u8>>, FetchError> {
|
||||
let body_val = match Reflect::get(init, &JsValue::from_str("body")) {
|
||||
Ok(v) if !v.is_undefined() && !v.is_null() => v,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
// String body → UTF-8 bytes
|
||||
if let Some(s) = body_val.as_string() {
|
||||
return Ok(Some(s.into_bytes()));
|
||||
}
|
||||
|
||||
// Uint8Array → copy to Vec<u8>
|
||||
if let Some(arr) = body_val.dyn_ref::<Uint8Array>() {
|
||||
return Ok(Some(arr.to_vec()));
|
||||
}
|
||||
|
||||
// ArrayBuffer → wrap in Uint8Array → copy to Vec<u8>
|
||||
if let Some(buf) = body_val.dyn_ref::<js_sys::ArrayBuffer>() {
|
||||
let arr = Uint8Array::new(buf);
|
||||
return Ok(Some(arr.to_vec()));
|
||||
}
|
||||
|
||||
Err(FetchError::Http(
|
||||
"unsupported body type (expected string, Uint8Array, or ArrayBuffer)".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// Response serialisation (Rust → JS plain object)
|
||||
|
||||
/// Serialise an `HttpResponse` into a plain JS object for Comlink transfer.
|
||||
///
|
||||
/// Shape: `{ body: Uint8Array, status: number, statusText: string, headers: Array<[string, string]> }`
|
||||
///
|
||||
/// `headers` is a sequence of `[name, value]` pairs (not a record) so that
|
||||
/// repeated names like `Set-Cookie`, `Vary`, `Link`, or `WWW-Authenticate`
|
||||
/// survive the hop. The JS layer feeds it straight into `new Headers(raw.headers)`,
|
||||
/// which accepts both shapes.
|
||||
///
|
||||
/// The TS layer reconstructs a native browser `Response` from this:
|
||||
/// ```js
|
||||
/// new Response(raw.body, {
|
||||
/// status: raw.status,
|
||||
/// statusText: raw.statusText,
|
||||
/// headers: new Headers(raw.headers),
|
||||
/// })
|
||||
/// ```
|
||||
#[cfg(feature = "fetch")]
|
||||
fn serialise_response(resp: &HttpResponse) -> Result<JsValue, FetchError> {
|
||||
let obj = Object::new();
|
||||
let body = Uint8Array::from(resp.body.as_slice());
|
||||
|
||||
set_prop(&obj, "body", &body)?;
|
||||
set_prop(&obj, "status", &JsValue::from(resp.status))?;
|
||||
set_prop(&obj, "statusText", &JsValue::from_str(&resp.status_text))?;
|
||||
|
||||
let headers_arr = Array::new();
|
||||
for (k, v) in &resp.headers {
|
||||
let pair = Array::new();
|
||||
pair.push(&JsValue::from_str(k));
|
||||
pair.push(&JsValue::from_str(v));
|
||||
headers_arr.push(&pair);
|
||||
}
|
||||
set_prop(&obj, "headers", &headers_arr)?;
|
||||
|
||||
Ok(obj.into())
|
||||
}
|
||||
|
||||
/// Helper: set a property on a JS object via `Reflect.set`.
|
||||
#[cfg(feature = "fetch")]
|
||||
fn set_prop(obj: &Object, key: &str, val: &JsValue) -> Result<(), FetchError> {
|
||||
Reflect::set(obj, &JsValue::from_str(key), val)
|
||||
.map(|_| ())
|
||||
.map_err(|e| FetchError::Js(format!("failed to set '{key}': {e:?}")))
|
||||
}
|
||||
|
||||
/// Whether the URL scheme is one we'll forward to.
|
||||
#[cfg(feature = "fetch")]
|
||||
fn is_http_scheme(url: &Url) -> bool {
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
}
|
||||
|
||||
/// HTTP methods we're willing to retry without operator-visible side-effects.
|
||||
/// Matches RFC 9110's idempotent set minus TRACE (which we'd never send).
|
||||
#[cfg(feature = "fetch")]
|
||||
fn is_idempotent(method: &str) -> bool {
|
||||
matches!(
|
||||
method.to_ascii_uppercase().as_str(),
|
||||
"GET" | "HEAD" | "OPTIONS" | "PUT" | "DELETE"
|
||||
)
|
||||
}
|
||||
|
||||
/// Drop credential-bearing headers when a redirect crosses origins.
|
||||
#[cfg(feature = "fetch")]
|
||||
fn strip_sensitive_headers(headers: &mut Vec<(String, String)>) {
|
||||
const SENSITIVE: &[&str] = &["authorization", "cookie", "proxy-authorization"];
|
||||
headers.retain(|(k, _)| !SENSITIVE.iter().any(|name| k.eq_ignore_ascii_case(name)));
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! HTTP/1.1 client on hyper 1.x.
|
||||
|
||||
use std::io;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::body::Bytes;
|
||||
use hyper::client::conn::http1;
|
||||
|
||||
use crate::error::FetchError;
|
||||
|
||||
/// Parsed HTTP response.
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub status_text: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `futures::io` to `hyper::rt` adapter. hyper hands us uninitialised memory;
|
||||
/// `futures::io::AsyncRead` needs `&mut [u8]`, so we zero before passing.
|
||||
struct HyperIoAdapter<T>(T);
|
||||
|
||||
impl<T: AsyncRead + Unpin> hyper::rt::Read for HyperIoAdapter<T> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
mut buf: hyper::rt::ReadBufCursor<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
// SAFETY: `init_slice` initialises every byte before the caller sees it.
|
||||
let uninit_slice = unsafe { buf.as_mut() };
|
||||
let slice = init_slice(uninit_slice);
|
||||
|
||||
match Pin::new(&mut self.get_mut().0).poll_read(cx, slice) {
|
||||
Poll::Ready(Ok(n)) => {
|
||||
// SAFETY: poll_read wrote `n` initialised bytes into `slice`,
|
||||
// which aliases the first `n` bytes of `buf`.
|
||||
unsafe { buf.advance(n) };
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsyncWrite + Unpin> hyper::rt::Write for HyperIoAdapter<T> {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.get_mut().0).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().0).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().0).poll_close(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero a `MaybeUninit<u8>` slice and return it as `&mut [u8]`.
|
||||
fn init_slice(buf: &mut [MaybeUninit<u8>]) -> &mut [u8] {
|
||||
for b in buf.iter_mut() {
|
||||
b.write(0);
|
||||
}
|
||||
// Safety: we just initialised every element.
|
||||
unsafe { &mut *(buf as *mut [MaybeUninit<u8>] as *mut [u8]) }
|
||||
}
|
||||
|
||||
/// Send an HTTP/1.1 request and read the complete response.
|
||||
/// The returned `bool` is whether the stream is poolable.
|
||||
pub async fn request<S>(
|
||||
stream: S,
|
||||
method: &str,
|
||||
url: &url::Url,
|
||||
headers: &[(String, String)],
|
||||
body: Option<&[u8]>,
|
||||
) -> Result<(HttpResponse, bool, S), FetchError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + 'static,
|
||||
{
|
||||
crate::util::debug_log!("[http] sending {method} request via hyper...");
|
||||
|
||||
let uri: http::Uri = url
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|e| FetchError::Http(format!("URI conversion: {e}")))?;
|
||||
let path = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
let host = uri
|
||||
.authority()
|
||||
.ok_or_else(|| FetchError::Http("URL has no authority for Host header".into()))?
|
||||
.as_str();
|
||||
|
||||
let body_bytes = body.map(Bytes::copy_from_slice).unwrap_or_default();
|
||||
let mut builder = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("Host", host)
|
||||
.header("Connection", "keep-alive");
|
||||
|
||||
let mut has_content_length = false;
|
||||
for (name, value) in headers {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
has_content_length = true;
|
||||
}
|
||||
}
|
||||
|
||||
if body.is_some() && !has_content_length {
|
||||
builder = builder.header("Content-Length", body_bytes.len().to_string());
|
||||
}
|
||||
|
||||
let req = builder
|
||||
.body(Full::new(body_bytes))
|
||||
.map_err(|e| FetchError::Http(format!("failed to build request: {e}")))?;
|
||||
|
||||
// Perform HTTP/1 handshake; hyper takes ownership of the IO
|
||||
let (mut sender, conn) = http1::handshake(HyperIoAdapter(stream))
|
||||
.await
|
||||
.map_err(FetchError::Hyper)?;
|
||||
|
||||
// Spawn the connection driver. The driver only completes once the
|
||||
// request/response exchange is over AND the sender is dropped, at which
|
||||
// point `without_shutdown()` returns the IO so we can pool it.
|
||||
let (parts_tx, parts_rx) = futures::channel::oneshot::channel();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = conn.without_shutdown().await;
|
||||
let _ = parts_tx.send(result);
|
||||
});
|
||||
|
||||
// Send the request
|
||||
let response = sender.send_request(req).await.map_err(FetchError::Hyper)?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let status_text = response
|
||||
.status()
|
||||
.canonical_reason()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Collect response headers
|
||||
let response_headers: Vec<(String, String)> = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
|
||||
// Reusable unless the server signals `Connection: close`.
|
||||
let server_close = response_headers
|
||||
.iter()
|
||||
.any(|(k, v)| k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close"));
|
||||
let reusable = !server_close;
|
||||
|
||||
// Log headers immediately so we know the server responded, even if
|
||||
// the body takes a long time to stream through the mixnet.
|
||||
let content_length = response_headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
|
||||
.and_then(|(_, v)| v.parse::<u64>().ok());
|
||||
|
||||
match content_length {
|
||||
Some(len) => crate::util::debug_log!(
|
||||
"[http] {status} {status_text}; collecting body ({len} bytes)..."
|
||||
),
|
||||
None => crate::util::debug_log!(
|
||||
"[http] {status} {status_text}; collecting body (chunked/unknown size)..."
|
||||
),
|
||||
}
|
||||
|
||||
// Dump response headers for diagnostics.
|
||||
for (k, v) in &response_headers {
|
||||
crate::util::debug_log!("[http] {k}: {v}");
|
||||
}
|
||||
|
||||
// Read body frame-by-frame to log progress (large mixnet downloads
|
||||
// can take 30s+ with no visible output otherwise).
|
||||
let mut body = response.into_body();
|
||||
let mut body_data = Vec::new();
|
||||
let expected = content_length.unwrap_or(0);
|
||||
let mut next_log_at: usize = 4096;
|
||||
|
||||
loop {
|
||||
match body.frame().await {
|
||||
Some(Ok(frame)) => {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
let chunk_len = data.len();
|
||||
body_data.extend_from_slice(&data);
|
||||
if body_data.len() >= next_log_at {
|
||||
crate::util::debug_log!(
|
||||
"[http] progress: {} / {expected} bytes (chunk={chunk_len})",
|
||||
body_data.len(),
|
||||
);
|
||||
next_log_at = body_data.len() + 4096;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => return Err(FetchError::Hyper(e)),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
crate::util::debug_log!(
|
||||
"[http] body complete: {} bytes, reusable={reusable}",
|
||||
body_data.len()
|
||||
);
|
||||
|
||||
// Drop sender to signal the connection driver to complete
|
||||
drop(sender);
|
||||
|
||||
// Recover the underlying stream from the connection driver
|
||||
let parts = parts_rx
|
||||
.await
|
||||
.map_err(|_| FetchError::Http("connection driver dropped".into()))?
|
||||
.map_err(FetchError::Hyper)?;
|
||||
let stream = parts.io.0;
|
||||
|
||||
Ok((
|
||||
HttpResponse {
|
||||
status,
|
||||
status_text,
|
||||
headers: response_headers,
|
||||
body: body_data,
|
||||
},
|
||||
reusable,
|
||||
stream,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! IPR (IP Packet Router) protocol layer for the WASM tunnel.
|
||||
//!
|
||||
//! Handles the v9 connect handshake and IP packet send/recv, using the
|
||||
//! upstream `nym_lp_data::packet::frame` wire format directly (no tokio deps).
|
||||
//!
|
||||
//! Data flow:
|
||||
//! ```text
|
||||
//! Outgoing: IP packet → bundle → DataRequest → to_bytes → LP frame → mixnet
|
||||
//! Incoming: mixnet → LP decode → IpPacketResponse → unbundle → IP packets
|
||||
//! ```
|
||||
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::StreamExt;
|
||||
use futures::channel::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use nym_ip_packet_requests::IpPair;
|
||||
use nym_ip_packet_requests::v9::{self, response::IpPacketResponse};
|
||||
use nym_lp_data::packet::frame::{
|
||||
LpFrame, LpFrameKind, SphinxStreamFrameAttributes, SphinxStreamMsgType,
|
||||
};
|
||||
use nym_wasm_client_core::Recipient;
|
||||
use nym_wasm_client_core::ReconstructedMessage;
|
||||
use nym_wasm_client_core::client::base_client::ClientInput;
|
||||
use nym_wasm_client_core::client::inbound_messages::InputMessage;
|
||||
use nym_wasm_client_core::nym_task::connections::TransmissionLane;
|
||||
|
||||
use crate::error::FetchError;
|
||||
|
||||
/// Reply-SURB counts for Open and Data frames. Defaults: `open=10, data=0`.
|
||||
///
|
||||
/// The Open frame seeds the IPR's SURB bucket; from there, the reply
|
||||
/// controller's pre-emptive topup refills it when the bucket dips below
|
||||
/// the `min_surbs_threshold` (10, per nym-client-core), so per-data-packet
|
||||
/// SURBs are unnecessary in steady state. Override `data` upwards for
|
||||
/// workloads that burst faster than topup round-trip can keep up.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SurbsConfig {
|
||||
pub open: u32,
|
||||
pub data: u32,
|
||||
}
|
||||
|
||||
impl Default for SurbsConfig {
|
||||
fn default() -> Self {
|
||||
Self { open: 10, data: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias for the channel receiving batches of reconstructed messages.
|
||||
pub type ReconstructedReceiver = mpsc::UnboundedReceiver<Vec<ReconstructedMessage>>;
|
||||
|
||||
/// Open an LP stream to the IPR and perform the v9 connect handshake.
|
||||
///
|
||||
/// Sends an LP Open frame (seq=0, empty payload), then a ConnectRequest
|
||||
/// (Data seq=0), and waits for a ConnectSuccess response with allocated IPs.
|
||||
pub async fn open_and_connect(
|
||||
client_input: &Arc<ClientInput>,
|
||||
receiver: &mut ReconstructedReceiver,
|
||||
ipr_address: &Recipient,
|
||||
stream_id: u64,
|
||||
surbs: SurbsConfig,
|
||||
connect_timeout: Duration,
|
||||
) -> Result<IpPair, FetchError> {
|
||||
nym_wasm_utils::console_log!("[ipr] sending connect handshake...");
|
||||
crate::util::debug_log!("[ipr] stream={stream_id:#018x}");
|
||||
|
||||
// 1. Send LP Open frame (empty payload, seq=0); establishes the stream
|
||||
let open_frame = encode_lp_frame(stream_id, SphinxStreamMsgType::Open, 0, &[]);
|
||||
send_to_ipr(client_input, ipr_address, open_frame, surbs.open).await?;
|
||||
|
||||
// 2. Send v9 ConnectRequest as LP Data frame (seq=0).
|
||||
// Data frames have their own seq space; Open's seq field is independent.
|
||||
let (request, request_id) = v9::new_connect_request(None);
|
||||
let request_bytes = request
|
||||
.to_bytes()
|
||||
.map_err(|e| FetchError::Tunnel(format!("failed to serialise connect request: {e}")))?;
|
||||
let data_frame = encode_lp_frame(stream_id, SphinxStreamMsgType::Data, 0, &request_bytes);
|
||||
send_to_ipr(client_input, ipr_address, data_frame, surbs.data).await?;
|
||||
|
||||
// 3. Wait for ConnectSuccess response
|
||||
let ip_pair = wasmtimer::tokio::timeout(connect_timeout, async {
|
||||
loop {
|
||||
let batch = receiver
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| FetchError::Tunnel("message channel closed".into()))?;
|
||||
|
||||
for msg in batch {
|
||||
// nym-client-core's received_buffer filters cover traffic
|
||||
// before delivery, so an outer LP-decode failure here is a
|
||||
// "shouldn't happen" signal: either a non-LP straggler or
|
||||
// garbage. We log and continue rather than bail (bailing
|
||||
// would open a single-spoofed-message DoS on the handshake);
|
||||
// tightening to fail-fast belongs with the IPR-auth design.
|
||||
let Some((attrs, content)) = decode_lp_stream(&msg.message) else {
|
||||
crate::util::debug_error!(
|
||||
"[ipr] non-LP-stream message received during handshake (dropped)"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if attrs.stream_id != stream_id || attrs.msg_type != SphinxStreamMsgType::Data {
|
||||
// Late straggler from a different stream/session — expected.
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = match IpPacketResponse::from_bytes(&content) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Decoded as LP for our stream + msg_type, but content
|
||||
// didn't parse as an IPR response. Logged for the same
|
||||
// reason as the outer decode failure above.
|
||||
crate::util::debug_error!(
|
||||
"[ipr] malformed IpPacketResponse on our stream (dropped): {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if response.id() != Some(request_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return nym_ip_packet_requests::response_helpers::parse_connect_response(response)
|
||||
.map_err(|e| FetchError::Tunnel(format!("IPR connect denied: {e}")));
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| FetchError::Tunnel("IPR connect timed out".into()))??;
|
||||
|
||||
Ok(ip_pair)
|
||||
}
|
||||
|
||||
/// Bundle an IP packet and send it to the IPR as an LP-framed DataRequest.
|
||||
///
|
||||
/// The bundling uses the `MultiIpPacketCodec` wire format: 2-byte BE length
|
||||
/// prefix followed by the raw packet. This is what the IPR expects.
|
||||
pub async fn send_ip_packet(
|
||||
client_input: &Arc<ClientInput>,
|
||||
ipr_address: &Recipient,
|
||||
stream_id: u64,
|
||||
seq: u32,
|
||||
packet: &[u8],
|
||||
data_surbs: u32,
|
||||
) -> Result<(), FetchError> {
|
||||
let bundled = nym_ip_packet_requests::codec::MultiIpPacketCodec::bundle_one_packet(
|
||||
Bytes::copy_from_slice(packet),
|
||||
);
|
||||
|
||||
// Wrap in v9 DataRequest
|
||||
let request = v9::new_data_request(bundled);
|
||||
let request_bytes = request
|
||||
.to_bytes()
|
||||
.map_err(|e| FetchError::Tunnel(format!("failed to serialise data request: {e}")))?;
|
||||
|
||||
// LP-frame and send
|
||||
let frame = encode_lp_frame(stream_id, SphinxStreamMsgType::Data, seq, &request_bytes);
|
||||
send_to_ipr(client_input, ipr_address, frame, data_surbs).await
|
||||
}
|
||||
|
||||
/// Parse an incoming ReconstructedMessage into individual IP packets.
|
||||
///
|
||||
/// LP-decodes the message, verifies the stream_id, deserialises the IPR
|
||||
/// response, and unbundles the contained IP packets.
|
||||
///
|
||||
/// Returns `Ok(None)` for non-data responses (control messages, wrong stream).
|
||||
/// Returns `Ok(Some(packets))` for data responses.
|
||||
/// Returns `Err` only for hard errors (disconnect).
|
||||
pub fn parse_incoming(
|
||||
msg: &ReconstructedMessage,
|
||||
expected_stream_id: u64,
|
||||
) -> Result<Option<Vec<Vec<u8>>>, FetchError> {
|
||||
let Some((attrs, content)) = decode_lp_stream(&msg.message) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if attrs.stream_id != expected_stream_id || attrs.msg_type != SphinxStreamMsgType::Data {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match nym_ip_packet_requests::response_helpers::handle_ipr_response(&content) {
|
||||
Some(nym_ip_packet_requests::response_helpers::MixnetMessageOutcome::IpPackets(
|
||||
packets,
|
||||
)) => Ok(Some(packets.into_iter().map(|b| b.to_vec()).collect())),
|
||||
Some(nym_ip_packet_requests::response_helpers::MixnetMessageOutcome::Disconnect) => {
|
||||
crate::util::debug_error!("[ipr] IPR sent DISCONNECT");
|
||||
Err(FetchError::Tunnel("IPR disconnected".into()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// LP frame helpers
|
||||
|
||||
/// Encode a SphinxStream LP frame into bytes.
|
||||
fn encode_lp_frame(
|
||||
stream_id: u64,
|
||||
msg_type: SphinxStreamMsgType,
|
||||
seq: u32,
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let frame = LpFrame::new_stream(
|
||||
SphinxStreamFrameAttributes {
|
||||
stream_id,
|
||||
msg_type,
|
||||
sequence_num: seq,
|
||||
},
|
||||
payload.to_vec(),
|
||||
);
|
||||
let mut buf = BytesMut::with_capacity(16 + payload.len());
|
||||
frame.encode(&mut buf);
|
||||
buf.to_vec()
|
||||
}
|
||||
|
||||
/// Decode a SphinxStream LP frame, returning (attributes, content).
|
||||
///
|
||||
/// Returns `None` if the data is too short, the frame kind isn't
|
||||
/// `SphinxStream`, or the attributes can't be parsed.
|
||||
fn decode_lp_stream(data: &[u8]) -> Option<(SphinxStreamFrameAttributes, Bytes)> {
|
||||
let frame = LpFrame::decode(data).ok()?;
|
||||
if frame.kind() != LpFrameKind::SphinxStream {
|
||||
return None;
|
||||
}
|
||||
let attrs = SphinxStreamFrameAttributes::parse(&frame.header.frame_attributes).ok()?;
|
||||
Some((attrs, frame.content))
|
||||
}
|
||||
|
||||
// Mixnet send helper
|
||||
|
||||
/// Send an anonymous mixnet message to the IPR with reply SURBs.
|
||||
async fn send_to_ipr(
|
||||
client_input: &Arc<ClientInput>,
|
||||
recipient: &Recipient,
|
||||
data: Vec<u8>,
|
||||
reply_surbs: u32,
|
||||
) -> Result<(), FetchError> {
|
||||
let msg = InputMessage::new_anonymous(
|
||||
*recipient,
|
||||
data,
|
||||
reply_surbs,
|
||||
TransmissionLane::General,
|
||||
None,
|
||||
);
|
||||
client_input
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|_| FetchError::Tunnel("mixnet input channel closed".into()))
|
||||
}
|
||||
|
||||
/// Performance-weighted random pick from v9-capable IPRs. Ported from
|
||||
/// `nym_sdk::ip_packet_client::discovery::get_best_ipr` to keep the
|
||||
/// SDK out of the wasm dep graph.
|
||||
pub(crate) async fn discover_ipr(nym_api_urls: &[url::Url]) -> Result<Recipient, FetchError> {
|
||||
use nym_validator_client::nym_api::NymApiClientExt;
|
||||
use rand::seq::SliceRandom;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let url = nym_api_urls
|
||||
.first()
|
||||
.ok_or_else(|| FetchError::Tunnel("no nym-api URLs for IPR discovery".into()))?;
|
||||
let client = nym_wasm_client_core::ApiClient::builder(url.clone())
|
||||
.map_err(|e| FetchError::Tunnel(format!("nym-api builder failed: {e}")))?
|
||||
.build()
|
||||
.map_err(|e| FetchError::Tunnel(format!("nym-api build failed: {e}")))?;
|
||||
|
||||
let all_nodes: HashMap<_, _> = client
|
||||
.get_all_described_nodes_v2()
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("describe nodes failed: {e}")))?
|
||||
.into_iter()
|
||||
.map(|d| (d.ed25519_identity_key(), d))
|
||||
.collect();
|
||||
|
||||
let exits = client
|
||||
.get_all_basic_nodes_with_metadata()
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("list nodes failed: {e}")))?
|
||||
.nodes;
|
||||
|
||||
let mut candidates: Vec<(Recipient, u8)> = Vec::new();
|
||||
for exit in exits {
|
||||
let Some(node) = all_nodes.get(&exit.ed25519_identity_pubkey) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(version) = semver::Version::parse(node.version()) else {
|
||||
continue;
|
||||
};
|
||||
if version < nym_ip_packet_requests::v9::MIN_RELEASE_VERSION {
|
||||
continue;
|
||||
}
|
||||
let Some(ipr_info) = node.description.ip_packet_router.clone() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(addr) = ipr_info.address.parse::<Recipient>() else {
|
||||
continue;
|
||||
};
|
||||
candidates.push((addr, exit.performance.round_to_integer()));
|
||||
}
|
||||
|
||||
let picked = candidates
|
||||
.choose_weighted(&mut rand::thread_rng(), |c| c.1 as f64)
|
||||
.map_err(|_| FetchError::Tunnel("no v9-capable IPRs available".into()))?;
|
||||
nym_wasm_utils::console_log!(
|
||||
"[smolmix] auto-discovered IPR (perf={}): {}",
|
||||
picked.1,
|
||||
picked.0
|
||||
);
|
||||
Ok(picked.0)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! smolmix-wasm: drop-in browser networking over the Nym mixnet.
|
||||
//!
|
||||
//! Exposes three APIs that mirror the browser's native networking surface:
|
||||
//!
|
||||
//! - **`mixFetch(url, init)`**: drop-in `fetch()` replacement (HTTP/HTTPS)
|
||||
//! - **`mixSocket(url, protocols, onEvent)`**: drop-in `WebSocket` replacement (WS/WSS)
|
||||
//! - **`mixResolve(hostname)`**: DNS-only hostname lookup (UDP / IPR path, no TCP/TLS)
|
||||
//!
|
||||
//! All three share the same mixnet tunnel (DNS, TCP, TLS), initialised once
|
||||
//! via `setupMixTunnel(opts)` and torn down with `disconnectMixTunnel()`.
|
||||
|
||||
// All modules gated on wasm32 so `cargo check` on the host triple sees an empty crate.
|
||||
// Cargo features (`dns` / `fetch` / `socket`) further gate the entry-point modules
|
||||
// and their heavy deps; see [features] in Cargo.toml.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod bridge;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod device;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod dns;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod error;
|
||||
#[cfg(all(target_arch = "wasm32", any(feature = "fetch", feature = "socket")))]
|
||||
mod fetch;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "fetch"))]
|
||||
mod http;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod ipr;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "dns"))]
|
||||
mod mixdns;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "fetch"))]
|
||||
mod mixfetch;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "socket"))]
|
||||
mod mixsocket;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod reactor;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod state;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod stream;
|
||||
#[cfg(all(target_arch = "wasm32", any(feature = "fetch", feature = "socket")))]
|
||||
mod tls;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod tunnel;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod util;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use error::FetchError;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use tunnel::WasmTunnel;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use serde::Deserialize;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::OnceLock;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use tsify::Tsify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::future_to_promise;
|
||||
|
||||
/// Global tunnel singleton, set once by `setupMixTunnel`, stays in the OnceLock after shutdown.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) static TUNNEL: OnceLock<WasmTunnel> = OnceLock::new();
|
||||
|
||||
/// Resolve the tunnel and gate on readiness. Used by every JS entry point.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn ready_tunnel() -> Result<&'static WasmTunnel, FetchError> {
|
||||
let tunnel = TUNNEL.get().ok_or(FetchError::NotConnected)?;
|
||||
if !tunnel.is_ready() {
|
||||
return Err(FetchError::Tunnel(format!(
|
||||
"tunnel not ready: {:?}",
|
||||
tunnel.tunnel_state()
|
||||
)));
|
||||
}
|
||||
Ok(tunnel)
|
||||
}
|
||||
|
||||
/// Options accepted by `setupMixTunnel`. Deserialised from the JS object via
|
||||
/// `serde-wasm-bindgen` + `tsify`, which gives us typed access without manual
|
||||
/// `Reflect::get` plumbing and emits a matching `.d.ts` for the TS side.
|
||||
#[derive(Tsify, Deserialize)]
|
||||
#[tsify(from_wasm_abi)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub struct SetupOpts {
|
||||
/// Nym address of the IPR exit node. Omit (or pass `null`) to let
|
||||
/// smolmix auto-discover a performance-weighted random IPR via the
|
||||
/// Nym API directory.
|
||||
#[serde(default)]
|
||||
pub preferred_ipr: Option<String>,
|
||||
/// Client storage namespace; randomise per session for clean state.
|
||||
#[serde(default)]
|
||||
pub client_id: Option<String>,
|
||||
/// Use `wss://` for gateway connections (default: `true`).
|
||||
#[serde(default = "default_force_tls")]
|
||||
pub force_tls: bool,
|
||||
/// Disable Poisson-distributed dummy traffic (default: `false`).
|
||||
#[serde(default)]
|
||||
pub disable_poisson_traffic: bool,
|
||||
/// Disable cover traffic loop (default: `false`).
|
||||
#[serde(default)]
|
||||
pub disable_cover_traffic: bool,
|
||||
/// SURBs attached to the LP Open frame and the v9 ConnectRequest sent
|
||||
/// during the IPR handshake. `None` falls back to [`ipr::SurbsConfig::default`].
|
||||
#[serde(default)]
|
||||
pub open_reply_surbs: Option<u32>,
|
||||
/// SURBs attached to each LP Data frame the bridge sends. Higher values
|
||||
/// raise download throughput at the cost of outgoing-packet overhead.
|
||||
#[serde(default)]
|
||||
pub data_reply_surbs: Option<u32>,
|
||||
/// Primary DNS resolver (e.g. `"1.1.1.1:53"`). Defaults to `8.8.8.8:53`.
|
||||
#[serde(default)]
|
||||
pub primary_dns: Option<String>,
|
||||
/// Fallback DNS resolver used if the primary times out. Defaults to `1.1.1.1:53`.
|
||||
#[serde(default)]
|
||||
pub fallback_dns: Option<String>,
|
||||
/// Passphrase used to encrypt persistent client storage (identity keys,
|
||||
/// gateway details). Omit for plaintext storage. The same passphrase
|
||||
/// must be supplied on subsequent page loads to read the same keys.
|
||||
#[serde(default)]
|
||||
pub storage_passphrase: Option<String>,
|
||||
/// IPR connect handshake timeout in milliseconds. Defaults to 60000.
|
||||
#[serde(default)]
|
||||
pub connect_timeout_ms: Option<u32>,
|
||||
/// DNS query timeout in milliseconds (per primary/fallback attempt).
|
||||
/// Defaults to 30000.
|
||||
#[serde(default)]
|
||||
pub dns_timeout_ms: Option<u32>,
|
||||
/// TCP keepalive interval in milliseconds. Defaults to 10000.
|
||||
#[serde(default)]
|
||||
pub tcp_keepalive_ms: Option<u32>,
|
||||
/// Per-TCP-stream RX/TX buffer size in bytes (capped at 65535).
|
||||
/// Defaults to 65535.
|
||||
#[serde(default)]
|
||||
pub tcp_buffer_size: Option<u32>,
|
||||
/// Maximum HTTP redirect chain depth before `mixFetch` gives up.
|
||||
/// Defaults to 5.
|
||||
#[serde(default)]
|
||||
pub max_redirects: Option<u8>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn default_force_tls() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// WASM entry point. Installs the panic hook + state-machine recorder,
|
||||
/// and flips the runtime debug-log switch on when smolmix's `debug`
|
||||
/// feature is enabled.
|
||||
#[wasm_bindgen(start)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn main() {
|
||||
nym_wasm_utils::set_panic_hook();
|
||||
#[cfg(feature = "debug")]
|
||||
nym_wasm_utils::set_debug_logging(true);
|
||||
state::install_panic_recorder();
|
||||
}
|
||||
|
||||
/// Initialise the mixnet tunnel. See [`SetupOpts`] for the JS-side shape.
|
||||
#[wasm_bindgen(js_name = "setupMixTunnel")]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn setup_mix_tunnel(opts: SetupOpts) -> js_sys::Promise {
|
||||
future_to_promise(async move {
|
||||
let result: Result<JsValue, FetchError> = async move {
|
||||
// One-shot: `TUNNEL` is a `OnceLock` so consumers can hold
|
||||
// `&'static WasmTunnel` refs without lifetime gymnastics.
|
||||
if TUNNEL.get().is_some() {
|
||||
return Err(FetchError::Tunnel(
|
||||
"tunnel already initialised; setupMixTunnel can only be called \
|
||||
once per WASM module instance"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ipr_address: Option<nym_wasm_client_core::Recipient> = opts
|
||||
.preferred_ipr
|
||||
.map(|s| {
|
||||
s.parse::<nym_wasm_client_core::Recipient>()
|
||||
.map_err(|e| FetchError::Tunnel(format!("invalid IPR address: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let defaults = ipr::SurbsConfig::default();
|
||||
let surbs = ipr::SurbsConfig {
|
||||
open: opts.open_reply_surbs.unwrap_or(defaults.open),
|
||||
data: opts.data_reply_surbs.unwrap_or(defaults.data),
|
||||
};
|
||||
|
||||
let parse_dns =
|
||||
|raw: Option<String>| -> Result<Option<std::net::SocketAddr>, FetchError> {
|
||||
raw.map(|s| {
|
||||
s.parse().map_err(|e| {
|
||||
FetchError::Tunnel(format!("invalid DNS resolver '{s}': {e}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
|
||||
let mut builder = tunnel::TunnelOpts::builder()
|
||||
.client_id(opts.client_id.unwrap_or_else(|| "smolmix-wasm".to_string()))
|
||||
.force_tls(opts.force_tls)
|
||||
.disable_poisson_traffic(opts.disable_poisson_traffic)
|
||||
.disable_cover_traffic(opts.disable_cover_traffic)
|
||||
.surbs(surbs);
|
||||
|
||||
if let Some(ipr) = ipr_address {
|
||||
builder = builder.ipr_address(ipr);
|
||||
}
|
||||
if let Some(addr) = parse_dns(opts.primary_dns)? {
|
||||
builder = builder.primary_dns(addr);
|
||||
}
|
||||
if let Some(addr) = parse_dns(opts.fallback_dns)? {
|
||||
builder = builder.fallback_dns(addr);
|
||||
}
|
||||
if let Some(p) = opts.storage_passphrase {
|
||||
builder = builder.storage_passphrase(p);
|
||||
}
|
||||
if let Some(ms) = opts.connect_timeout_ms {
|
||||
builder = builder.connect_timeout(std::time::Duration::from_millis(ms as u64));
|
||||
}
|
||||
if let Some(ms) = opts.dns_timeout_ms {
|
||||
builder = builder.dns_timeout(std::time::Duration::from_millis(ms as u64));
|
||||
}
|
||||
if let Some(ms) = opts.tcp_keepalive_ms {
|
||||
builder =
|
||||
builder.tcp_keepalive_interval(std::time::Duration::from_millis(ms as u64));
|
||||
}
|
||||
if let Some(n) = opts.tcp_buffer_size {
|
||||
builder = builder.tcp_buffer_size(n as usize);
|
||||
}
|
||||
if let Some(n) = opts.max_redirects {
|
||||
builder = builder.max_redirects(n);
|
||||
}
|
||||
|
||||
let tunnel_opts = builder.build();
|
||||
|
||||
let tun = WasmTunnel::new(tunnel_opts).await?;
|
||||
|
||||
TUNNEL.set(tun).map_err(|_| {
|
||||
FetchError::Tunnel(
|
||||
"tunnel already initialised by a concurrent setupMixTunnel call".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(JsValue::UNDEFINED)
|
||||
}
|
||||
.await;
|
||||
result.map_err(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
/// Disconnect from the mixnet. The tunnel becomes unusable until page reload.
|
||||
#[wasm_bindgen(js_name = "disconnectMixTunnel")]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn disconnect_mix_tunnel() -> js_sys::Promise {
|
||||
future_to_promise(async {
|
||||
if let Some(tunnel) = TUNNEL.get() {
|
||||
tunnel.shutdown().await;
|
||||
}
|
||||
Ok(JsValue::UNDEFINED)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `{state, reason?}`. See [`state::TunnelState`] serde tags
|
||||
/// for the exact shape. Pre-`setupMixTunnel` reads as `connecting`.
|
||||
#[wasm_bindgen(js_name = "getTunnelState")]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn get_tunnel_state() -> JsValue {
|
||||
let s = match TUNNEL.get() {
|
||||
Some(tunnel) => tunnel.tunnel_state(),
|
||||
None => state::TunnelState::Connecting,
|
||||
};
|
||||
serde_wasm_bindgen::to_value(&s).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! `mixResolve`: hostname lookup over the mixnet tunnel (UDP / IPR path only).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::future_to_promise;
|
||||
|
||||
use crate::dns;
|
||||
|
||||
/// Resolve a hostname to an IP address through the mixnet tunnel.
|
||||
///
|
||||
/// Returns the IP as a string (e.g. `"93.184.216.34"`).
|
||||
#[wasm_bindgen(js_name = "mixResolve")]
|
||||
pub fn mix_resolve(hostname: String) -> js_sys::Promise {
|
||||
future_to_promise(async move {
|
||||
let tunnel = crate::ready_tunnel()?;
|
||||
let ip = dns::resolve(tunnel, &hostname).await?;
|
||||
Ok(JsValue::from_str(&ip.to_string()))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! `mixFetch`: WASM export, delegates to [`crate::fetch::fetch`].
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::future_to_promise;
|
||||
|
||||
use crate::fetch;
|
||||
|
||||
/// Execute an HTTP request through the mixnet tunnel.
|
||||
#[wasm_bindgen(js_name = "mixFetch")]
|
||||
pub fn mix_fetch(url: String, init: JsValue) -> js_sys::Promise {
|
||||
future_to_promise(async move {
|
||||
let tunnel = crate::ready_tunnel()?;
|
||||
fetch::fetch(tunnel, &url, &init).await.map_err(Into::into)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! WebSocket over the mixnet tunnel; JS holds an integer handle.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{future_to_promise, spawn_local};
|
||||
|
||||
use crate::error::FetchError;
|
||||
use crate::fetch;
|
||||
use crate::stream::PooledConn;
|
||||
use crate::util;
|
||||
|
||||
/// Active WebSocket handles: sender half of each connection's command channel.
|
||||
static WS_HANDLES: OnceLock<Mutex<HashMap<u32, WsHandle>>> = OnceLock::new();
|
||||
static WS_NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
struct WsHandle {
|
||||
tx: futures::channel::mpsc::UnboundedSender<WsCommand>,
|
||||
}
|
||||
|
||||
enum WsCommand {
|
||||
Send(async_tungstenite::tungstenite::Message),
|
||||
Close(u16, String),
|
||||
}
|
||||
|
||||
/// Open a WebSocket through the tunnel; resolves to a u32 handle.
|
||||
/// Events fire on `on_event(handleId, type, data)` with type one of
|
||||
/// `"open" | "text" | "binary" | "close" | "error"`.
|
||||
#[wasm_bindgen(js_name = "mixSocket")]
|
||||
pub fn mix_socket(url: String, protocols: JsValue, on_event: js_sys::Function) -> js_sys::Promise {
|
||||
future_to_promise(async move {
|
||||
let result: Result<JsValue, FetchError> = async {
|
||||
use async_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
|
||||
let tunnel = crate::ready_tunnel()?;
|
||||
|
||||
let parsed = url::Url::parse(&url)
|
||||
.map_err(|e| FetchError::Http(format!("invalid WebSocket URL: {e}")))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| FetchError::Http("URL has no host".into()))?;
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| FetchError::Http("URL has no port and scheme is unknown".into()))?;
|
||||
let is_tls = parsed.scheme() == "wss";
|
||||
let protocol_list = parse_protocols(&protocols);
|
||||
|
||||
util::debug_log!("[ws] connecting to {url}");
|
||||
|
||||
let conn = fetch::new_connection(tunnel, host, port, is_tls).await?;
|
||||
|
||||
let mut request = url.into_client_request()?;
|
||||
if !protocol_list.is_empty() {
|
||||
let header_value = protocol_list.join(", ").parse().map_err(|e| {
|
||||
FetchError::Http(format!("invalid Sec-WebSocket-Protocol value: {e}"))
|
||||
})?;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Protocol", header_value);
|
||||
}
|
||||
|
||||
// HTTP 101 upgrade; tungstenite handles key gen + accept verification
|
||||
let (ws_stream, response) = async_tungstenite::client_async(request, conn).await?;
|
||||
|
||||
let negotiated = response
|
||||
.headers()
|
||||
.get("sec-websocket-protocol")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
util::debug_log!("[ws] upgrade complete (protocol={negotiated:?})");
|
||||
|
||||
let (tx, rx) = futures::channel::mpsc::unbounded();
|
||||
let handle_id = WS_NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let handles = WS_HANDLES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
handles.lock().unwrap().insert(handle_id, WsHandle { tx });
|
||||
|
||||
// Fire "open" before spawning the recv loop so JS sees it first.
|
||||
fire_ws_event(
|
||||
&on_event,
|
||||
handle_id,
|
||||
"open",
|
||||
&JsValue::from_str(&negotiated),
|
||||
);
|
||||
|
||||
spawn_local(ws_task(handle_id, ws_stream, rx, on_event));
|
||||
|
||||
Ok(JsValue::from(handle_id))
|
||||
}
|
||||
.await;
|
||||
|
||||
result.map_err(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
/// Send data over an open WebSocket (string → text, Uint8Array/ArrayBuffer → binary).
|
||||
#[wasm_bindgen(js_name = "wsSend")]
|
||||
pub fn ws_send(handle_id: u32, data: JsValue) -> Result<(), JsValue> {
|
||||
use async_tungstenite::tungstenite::Message;
|
||||
|
||||
let msg = if let Some(s) = data.as_string() {
|
||||
let preview = if s.len() <= 120 {
|
||||
&s
|
||||
} else {
|
||||
&s[..util::floor_char_boundary(&s, 120)]
|
||||
};
|
||||
util::debug_log!("[ws:{handle_id}] send text ({} bytes): {preview}", s.len());
|
||||
Message::Text(s)
|
||||
} else if let Some(arr) = data.dyn_ref::<js_sys::Uint8Array>() {
|
||||
let v = arr.to_vec();
|
||||
util::debug_log!(
|
||||
"[ws:{handle_id}] send binary ({} bytes): {}",
|
||||
v.len(),
|
||||
util::hex_preview(&v, 32)
|
||||
);
|
||||
Message::Binary(v)
|
||||
} else if let Some(buf) = data.dyn_ref::<js_sys::ArrayBuffer>() {
|
||||
let v = js_sys::Uint8Array::new(buf).to_vec();
|
||||
util::debug_log!(
|
||||
"[ws:{handle_id}] send binary ({} bytes): {}",
|
||||
v.len(),
|
||||
util::hex_preview(&v, 32)
|
||||
);
|
||||
Message::Binary(v)
|
||||
} else {
|
||||
return Err(JsValue::from_str(
|
||||
"unsupported data type (expected string, Uint8Array, or ArrayBuffer)",
|
||||
));
|
||||
};
|
||||
|
||||
send_ws_command(handle_id, WsCommand::Send(msg))
|
||||
}
|
||||
|
||||
/// Close an open WebSocket with a status code and reason.
|
||||
#[wasm_bindgen(js_name = "wsClose")]
|
||||
pub fn ws_close(handle_id: u32, code: u16, reason: String) -> Result<(), JsValue> {
|
||||
send_ws_command(handle_id, WsCommand::Close(code, reason))
|
||||
}
|
||||
|
||||
fn send_ws_command(handle_id: u32, cmd: WsCommand) -> Result<(), JsValue> {
|
||||
let handles = WS_HANDLES
|
||||
.get()
|
||||
.ok_or_else(|| JsValue::from_str("no active WebSocket connections"))?;
|
||||
let guard = handles.lock().unwrap();
|
||||
let handle = guard
|
||||
.get(&handle_id)
|
||||
.ok_or_else(|| JsValue::from_str(&format!("WebSocket handle {handle_id} not found")))?;
|
||||
|
||||
handle
|
||||
.tx
|
||||
.unbounded_send(cmd)
|
||||
.map_err(|_| JsValue::from_str("WebSocket background task has stopped"))
|
||||
}
|
||||
|
||||
/// Background task: reads from the WebSocket and dispatches JS commands.
|
||||
/// Ping/pong is handled automatically by tungstenite.
|
||||
async fn ws_task(
|
||||
handle_id: u32,
|
||||
ws: async_tungstenite::WebSocketStream<PooledConn>,
|
||||
rx: futures::channel::mpsc::UnboundedReceiver<WsCommand>,
|
||||
on_event: js_sys::Function,
|
||||
) {
|
||||
use async_tungstenite::tungstenite::Message;
|
||||
use futures::{SinkExt, StreamExt, select};
|
||||
|
||||
util::debug_log!("[ws:{handle_id}] background task started");
|
||||
|
||||
let (mut sink, stream) = ws.split();
|
||||
let mut stream = stream.fuse();
|
||||
let mut rx = rx.fuse();
|
||||
|
||||
loop {
|
||||
select! {
|
||||
msg = stream.next() => match msg {
|
||||
Some(Ok(Message::Text(s))) => {
|
||||
let preview = if s.len() <= 120 { &s } else { &s[..util::floor_char_boundary(&s, 120)] };
|
||||
util::debug_log!("[ws:{handle_id}] recv text ({} bytes): {preview}", s.len());
|
||||
fire_ws_event(&on_event, handle_id, "text", &JsValue::from_str(&s));
|
||||
}
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
util::debug_log!("[ws:{handle_id}] recv binary ({} bytes): {}", b.len(), util::hex_preview(&b, 32));
|
||||
fire_ws_event(
|
||||
&on_event,
|
||||
handle_id,
|
||||
"binary",
|
||||
&js_sys::Uint8Array::from(b.as_slice()).into(),
|
||||
);
|
||||
}
|
||||
Some(Ok(Message::Close(frame))) => {
|
||||
let info = frame
|
||||
.map(|f| format!("{} {}", f.code, f.reason))
|
||||
.unwrap_or_else(|| "1005".into());
|
||||
util::debug_log!("[ws:{handle_id}] recv close ({info})");
|
||||
fire_ws_event(&on_event, handle_id, "close", &JsValue::from_str(&info));
|
||||
ws_cleanup(handle_id);
|
||||
return;
|
||||
}
|
||||
Some(Ok(_)) => continue, // Ping/Pong handled internally
|
||||
Some(Err(e)) => {
|
||||
util::debug_error!("[ws:{handle_id}] error: {e}");
|
||||
fire_ws_event(&on_event, handle_id, "error", &JsValue::from_str(&e.to_string()));
|
||||
ws_cleanup(handle_id);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
util::debug_log!("[ws:{handle_id}] connection lost");
|
||||
fire_ws_event(&on_event, handle_id, "close", &JsValue::from_str("1006 connection lost"));
|
||||
ws_cleanup(handle_id);
|
||||
return;
|
||||
}
|
||||
},
|
||||
cmd = rx.next() => match cmd {
|
||||
Some(WsCommand::Send(msg)) => {
|
||||
if let Err(e) = sink.send(msg).await {
|
||||
util::debug_error!("[ws:{handle_id}] send error: {e}");
|
||||
fire_ws_event(&on_event, handle_id, "error", &JsValue::from_str(&e.to_string()));
|
||||
ws_cleanup(handle_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(WsCommand::Close(code, reason)) => {
|
||||
util::debug_log!("[ws:{handle_id}] closing ({code} {reason})");
|
||||
let info = format!("{code} {reason}");
|
||||
let frame = async_tungstenite::tungstenite::protocol::CloseFrame {
|
||||
code: code.into(),
|
||||
reason: reason.into(),
|
||||
};
|
||||
let _ = sink.send(Message::Close(Some(frame))).await;
|
||||
fire_ws_event(&on_event, handle_id, "close", &JsValue::from_str(&info));
|
||||
ws_cleanup(handle_id);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
util::debug_log!("[ws:{handle_id}] command channel dropped, closing");
|
||||
let _ = sink.close().await;
|
||||
ws_cleanup(handle_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a JS value into WebSocket sub-protocol strings.
|
||||
fn parse_protocols(val: &JsValue) -> Vec<String> {
|
||||
if val.is_undefined() || val.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(s) = val.as_string() {
|
||||
return vec![s];
|
||||
}
|
||||
if let Some(arr) = val.dyn_ref::<js_sys::Array>() {
|
||||
return (0..arr.length())
|
||||
.filter_map(|i| arr.get(i).as_string())
|
||||
.collect();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Fire a WebSocket event: `onEvent(handleId, type, data)`.
|
||||
fn fire_ws_event(on_event: &js_sys::Function, handle_id: u32, event_type: &str, data: &JsValue) {
|
||||
if let Err(e) = on_event.call3(
|
||||
&JsValue::NULL,
|
||||
&JsValue::from(handle_id),
|
||||
&JsValue::from_str(event_type),
|
||||
data,
|
||||
) {
|
||||
util::debug_error!("[ws:{handle_id}] callback error: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a WebSocket handle from the global map.
|
||||
fn ws_cleanup(handle_id: u32) {
|
||||
util::debug_log!("[ws:{handle_id}] cleanup");
|
||||
if let Some(handles) = WS_HANDLES.get() {
|
||||
handles.lock().unwrap().remove(&handle_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! smoltcp poll loop for the WASM tunnel.
|
||||
//!
|
||||
//! Drives `Interface::poll()` in a single `spawn_local` task. The cadence is
|
||||
//! adaptive: `poll_delay()` reports smoltcp's next soft deadline, the loop
|
||||
//! sleeps until that deadline (capped by [`MAX_IDLE`]) or until a notification
|
||||
//! arrives. smoltcp's per-socket `register_recv_waker`/`register_send_waker`
|
||||
//! fire automatically on every state change.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::FutureExt;
|
||||
use nym_wasm_client_core::nym_task::ShutdownTracker;
|
||||
use smoltcp::iface::{Interface, SocketHandle, SocketSet};
|
||||
use smoltcp::socket::tcp as smoltcp_tcp;
|
||||
use smoltcp::time::Instant;
|
||||
use tokio::sync::Notify;
|
||||
use wasmtimer::std::Instant as MonotonicInstant;
|
||||
|
||||
use crate::device::WasmDevice;
|
||||
use crate::state::{State, TaskName};
|
||||
|
||||
/// Maximum idle sleep when smoltcp has no pending work. Bounds the latency of
|
||||
/// TCP retransmit and keepalive timers if `poll_delay` ever returns `None`; on
|
||||
/// an active connection the wake source is the bridge or a socket write.
|
||||
const MAX_IDLE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Shared smoltcp network stack, accessed by the reactor, bridge, and sockets.
|
||||
///
|
||||
/// Inner is reached only via [`SmoltcpStack::with`], which scopes lock
|
||||
/// acquisition to a single closure and prevents callers from holding the
|
||||
/// guard across `.await` points.
|
||||
///
|
||||
/// `Arc<Mutex<>>` so that `WasmTunnel` can live in a
|
||||
/// `OnceLock` which requires `Send + Sync`. On wasm32 (single-threaded),
|
||||
/// `Mutex` is essentially a no-op lock, zero overhead vs `RefCell`.
|
||||
#[derive(Clone)]
|
||||
pub struct SmoltcpStack {
|
||||
inner: Arc<Mutex<SmoltcpStackInner>>,
|
||||
}
|
||||
|
||||
/// Inner state of the smoltcp stack. Only reachable inside a `with` closure.
|
||||
pub(crate) struct SmoltcpStackInner {
|
||||
pub(crate) iface: Interface,
|
||||
pub(crate) sockets: SocketSet<'static>,
|
||||
pub(crate) device: WasmDevice,
|
||||
/// TCP handles awaiting clean removal: their `Drop` queued a FIN via
|
||||
/// `socket.close()` but smoltcp hasn't transitioned to `State::Closed`
|
||||
/// yet. Swept after each `iface.poll()`.
|
||||
pub(crate) pending_removal: Vec<SocketHandle>,
|
||||
}
|
||||
|
||||
impl SmoltcpStack {
|
||||
/// Construct a fresh stack around the given interface + device.
|
||||
pub fn new(iface: Interface, device: WasmDevice) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(SmoltcpStackInner {
|
||||
iface,
|
||||
sockets: SocketSet::new(Vec::new()),
|
||||
device,
|
||||
pending_removal: Vec::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the lock for a single bounded scope of work.
|
||||
///
|
||||
/// The lock is held for the duration of the closure only; callers
|
||||
/// physically cannot hold it across `.await` because the closure is
|
||||
/// synchronous.
|
||||
pub(crate) fn with<R>(&self, f: impl FnOnce(&mut SmoltcpStackInner) -> R) -> R {
|
||||
let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
|
||||
f(&mut g)
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic epoch anchor for `smoltcp_now`. Lazily initialised on first call.
|
||||
static EPOCH: OnceLock<MonotonicInstant> = OnceLock::new();
|
||||
|
||||
/// Yield once to the JS microtask queue, then resume.
|
||||
///
|
||||
/// `wasm_bindgen_futures` doesn't expose a `yield_now` directly, so we wake the
|
||||
/// current task from `poll_fn` to give the executor a chance to process other
|
||||
/// ready tasks (notify channel, socket wakers) before we re-poll smoltcp.
|
||||
/// Cheaper than `wasmtimer::sleep(1ms)`, which goes through `setTimeout`
|
||||
/// (browsers clamp to a ~4ms minimum).
|
||||
async fn yield_now() {
|
||||
let mut yielded = false;
|
||||
futures::future::poll_fn(|cx| {
|
||||
if yielded {
|
||||
std::task::Poll::Ready(())
|
||||
} else {
|
||||
yielded = true;
|
||||
cx.waker().wake_by_ref();
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the current smoltcp timestamp from a monotonic clock.
|
||||
///
|
||||
/// smoltcp's `Instant` is an `i64` of microseconds relative to some epoch.
|
||||
/// We anchor to the first call and report offsets from that. `wasmtimer::std::Instant`
|
||||
/// is backed by `performance.now()` on wasm32, which is monotonic within the
|
||||
/// current Worker agent per the W3C HR-Time spec — unlike `Date::now()`, which
|
||||
/// can step backwards on NTP correction or user clock changes and would corrupt
|
||||
/// smoltcp's retransmit/timeout maths.
|
||||
pub fn smoltcp_now() -> Instant {
|
||||
let epoch = *EPOCH.get_or_init(MonotonicInstant::now);
|
||||
let elapsed_us = MonotonicInstant::now().duration_since(epoch).as_micros() as i64;
|
||||
Instant::from_micros(elapsed_us)
|
||||
}
|
||||
|
||||
/// Wake source for the reactor. Multiple holders call `notify_one()` to ask
|
||||
/// the reactor to re-poll smoltcp; coalescing is intrinsic to `tokio::sync::Notify`
|
||||
/// (10 calls before the next iteration are equivalent to 1).
|
||||
pub type ReactorNotify = Arc<Notify>;
|
||||
|
||||
/// Start the smoltcp reactor as a `spawn_local` background task.
|
||||
///
|
||||
/// Each iteration:
|
||||
/// 1. Lock the stack, call `iface.poll()` (which fires socket wakers internally).
|
||||
/// 2. Ask smoltcp how long it can wait before the next poll (`poll_delay`).
|
||||
/// 3. Sleep for that duration (capped at [`MAX_IDLE`]) or until a notification.
|
||||
///
|
||||
/// Notifications come from the bridge (new rx packets in the device, needing
|
||||
/// `iface.poll()` to ingest them) and from socket writes (data queued in
|
||||
/// smoltcp's tx buffer, needing `iface.poll()` to dispatch it to the device).
|
||||
pub fn start_reactor(
|
||||
stack: SmoltcpStack,
|
||||
notify: Arc<Notify>,
|
||||
tracker: &ShutdownTracker,
|
||||
state: State,
|
||||
) {
|
||||
// Cloned so finalise_task can check is_cancelled() on the way out.
|
||||
let token = tracker.clone_shutdown_token();
|
||||
tracker.try_spawn_named_with_shutdown(
|
||||
async move {
|
||||
loop {
|
||||
// Poll smoltcp; built-in socket wakers fire on any state change.
|
||||
let delay = stack.with(|s| {
|
||||
let now = smoltcp_now();
|
||||
let SmoltcpStackInner {
|
||||
iface,
|
||||
sockets,
|
||||
device,
|
||||
pending_removal,
|
||||
} = s;
|
||||
iface.poll(now, device, sockets);
|
||||
|
||||
// Sweep handles whose FIN/ACK exchange just completed.
|
||||
pending_removal.retain(|&handle| {
|
||||
if sockets.get::<smoltcp_tcp::Socket>(handle).state()
|
||||
== smoltcp_tcp::State::Closed
|
||||
{
|
||||
sockets.remove(handle);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
iface.poll_delay(now, sockets)
|
||||
});
|
||||
|
||||
// Translate smoltcp's deadline into a wait. A zero delay means
|
||||
// "poll again immediately"; yield to the JS event loop via
|
||||
// `yield_now()` rather than a 1ms `wasmtimer::sleep`, which
|
||||
// schedules a `setTimeout` and is hit by browsers' ~4ms minimum
|
||||
// clamp.
|
||||
match delay {
|
||||
Some(d) if d.total_micros() == 0 => {
|
||||
yield_now().await;
|
||||
}
|
||||
other => {
|
||||
let sleep_for = match other {
|
||||
Some(d) => Duration::from_micros(d.total_micros()).min(MAX_IDLE),
|
||||
None => MAX_IDLE,
|
||||
};
|
||||
// `Notify` coalesces multiple `notify_one()` calls into
|
||||
// one pending wake, so no drain loop needed. The
|
||||
// explicit `token.cancelled()` arm lets us exit the
|
||||
// loop cleanly into `finalise_task` on shutdown rather
|
||||
// than being aborted mid-select by the supervisor wrapper.
|
||||
futures::select! {
|
||||
_ = wasmtimer::tokio::sleep(sleep_for).fuse() => {},
|
||||
_ = notify.notified().fuse() => {},
|
||||
_ = token.cancelled().fuse() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.finalise_task(TaskName::Reactor, &token);
|
||||
},
|
||||
"smolmix-reactor",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Tunnel lifecycle state. wasm32 builds with `panic = "abort"`, so
|
||||
//! panic detection uses a chained hook + static atomic (see
|
||||
//! [`install_panic_recorder`]) rather than `catch_unwind`.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use nym_wasm_client_core::nym_task::ShutdownToken;
|
||||
|
||||
static WASM_PANICKED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Chain a recorder onto the existing panic hook. Call once after
|
||||
/// `nym_wasm_utils::set_panic_hook()`.
|
||||
pub(crate) fn install_panic_recorder() {
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
WASM_PANICKED.store(true, Ordering::SeqCst);
|
||||
prev(info);
|
||||
}));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum TaskName {
|
||||
Bridge,
|
||||
Reactor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub(crate) enum FailureReason {
|
||||
TaskExited { task: TaskName },
|
||||
TaskPanicked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub(crate) enum TunnelState {
|
||||
Connecting,
|
||||
Ready,
|
||||
ShuttingDown,
|
||||
Shutdown,
|
||||
Failed { reason: FailureReason },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct State {
|
||||
inner: Arc<Mutex<TunnelState>>,
|
||||
/// Cancelled by [`State::fail`]; should point at smolmix_tracker, not base.
|
||||
cascade: ShutdownToken,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn new(cascade: ShutdownToken) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(TunnelState::Connecting)),
|
||||
cascade,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the panic flag first; post-panic always returns Failed.
|
||||
pub(crate) fn get(&self) -> TunnelState {
|
||||
if WASM_PANICKED.load(Ordering::SeqCst) {
|
||||
return TunnelState::Failed {
|
||||
reason: FailureReason::TaskPanicked,
|
||||
};
|
||||
}
|
||||
self.inner.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn is_ready(&self) -> bool {
|
||||
matches!(self.get(), TunnelState::Ready)
|
||||
}
|
||||
|
||||
pub(crate) fn set(&self, new: TunnelState) {
|
||||
*self.inner.lock().unwrap() = new;
|
||||
}
|
||||
|
||||
/// Call at the end of each task body; no-op if the token was cancelled.
|
||||
pub(crate) fn finalise_task(&self, task: TaskName, token: &ShutdownToken) {
|
||||
if token.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.fail(FailureReason::TaskExited { task });
|
||||
}
|
||||
|
||||
/// First-failure-wins. Reads inner state directly (not via `get()`)
|
||||
/// so the panic short-circuit doesn't suppress the cascade.
|
||||
pub(crate) fn fail(&self, reason: FailureReason) {
|
||||
use TunnelState::*;
|
||||
{
|
||||
let mut state = self.inner.lock().unwrap();
|
||||
if matches!(*state, Shutdown | ShuttingDown | Failed { .. }) {
|
||||
return;
|
||||
}
|
||||
*state = Failed { reason };
|
||||
}
|
||||
self.cascade.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! `futures::io` socket adapters over the smoltcp stack.
|
||||
|
||||
use std::io;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use smoltcp::iface::SocketHandle;
|
||||
use smoltcp::socket::tcp as smoltcp_tcp;
|
||||
use smoltcp::socket::udp as smoltcp_udp;
|
||||
use smoltcp::wire::{IpAddress, IpEndpoint};
|
||||
|
||||
use crate::reactor::{ReactorNotify, SmoltcpStack};
|
||||
|
||||
/// First port in the ephemeral range. Per IANA, 49152-65535 is the dynamic /
|
||||
/// private range with no IANA-assigned services, safe for client sockets.
|
||||
pub(crate) const EPHEMERAL_PORT_START: u16 = 49152;
|
||||
|
||||
/// A pooled connection (TLS or plain TCP). Delegates `AsyncRead + AsyncWrite`.
|
||||
/// The `Tls` variant compiles in only when `fetch` or `socket` features are
|
||||
/// enabled, since plaintext-only builds (the `dns`-only TS SDK package) don't
|
||||
/// need a TLS stack at all.
|
||||
///
|
||||
/// `Tls` is intentionally inlined (~744 B) rather than boxed: the pool holds
|
||||
/// at most one entry per (host, port), so total memory is bounded by distinct
|
||||
/// origins visited over the tunnel's lifetime. Boxing would force every match
|
||||
/// arm into a `Box`-deref dance for no real benefit at typical usage.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub(crate) enum PooledConn {
|
||||
#[cfg(any(feature = "fetch", feature = "socket"))]
|
||||
Tls(crate::tls::MaybeCloseNotify<futures_rustls::client::TlsStream<WasmTcpStream>>),
|
||||
Plain(WasmTcpStream),
|
||||
}
|
||||
|
||||
/// TCP stream over the WASM tunnel. Implements `futures::io::{AsyncRead, AsyncWrite}`.
|
||||
pub struct WasmTcpStream {
|
||||
pub(crate) stack: SmoltcpStack,
|
||||
pub(crate) handle: SocketHandle,
|
||||
pub(crate) notify: ReactorNotify,
|
||||
/// Set once `socket.close()` has been called (via `poll_close` or
|
||||
/// `Drop`). Makes the close path idempotent.
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// UDP socket over the WASM tunnel. Used for DNS queries.
|
||||
pub struct WasmUdpSocket {
|
||||
pub(crate) stack: SmoltcpStack,
|
||||
pub(crate) handle: SocketHandle,
|
||||
pub(crate) notify: ReactorNotify,
|
||||
}
|
||||
|
||||
impl AsyncRead for WasmTcpStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut [u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let handle = self.handle;
|
||||
let notify = &self.notify;
|
||||
self.stack.with(|s| {
|
||||
let socket = s.sockets.get_mut::<smoltcp_tcp::Socket>(handle);
|
||||
|
||||
if socket.can_recv() {
|
||||
let n = socket
|
||||
.recv_slice(buf)
|
||||
.map_err(|e| io::Error::other(format!("{e}")))?;
|
||||
crate::util::debug_log!("[tcp:read] Ready({n})");
|
||||
// Notify reactor: recv_slice() frees rx buffer, needs a
|
||||
// prompt window update ACK to keep the sender flowing.
|
||||
notify.notify_one();
|
||||
Poll::Ready(Ok(n))
|
||||
} else if !socket.may_recv() {
|
||||
// Remote sent FIN (EOF). `may_recv()` is false for CloseWait,
|
||||
// LastAck, Closed, TimeWait (unlike `is_open()` which misses CloseWait).
|
||||
Poll::Ready(Ok(0))
|
||||
} else {
|
||||
crate::util::debug_log!(
|
||||
"[tcp:read] Pending (state={:?}, buf={})",
|
||||
socket.state(),
|
||||
buf.len(),
|
||||
);
|
||||
// smoltcp wakes this waker on any state change affecting `recv`,
|
||||
// including FIN/CloseWait transitions that produce EOF.
|
||||
socket.register_recv_waker(cx.waker());
|
||||
Poll::Pending
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for WasmTcpStream {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let handle = self.handle;
|
||||
let notify = &self.notify;
|
||||
self.stack.with(|s| {
|
||||
let socket = s.sockets.get_mut::<smoltcp_tcp::Socket>(handle);
|
||||
|
||||
if socket.can_send() {
|
||||
let n = socket
|
||||
.send_slice(buf)
|
||||
.map_err(|e| io::Error::other(format!("{e}")))?;
|
||||
notify.notify_one();
|
||||
Poll::Ready(Ok(n))
|
||||
} else if !socket.is_open() {
|
||||
Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"socket closed",
|
||||
)))
|
||||
} else {
|
||||
socket.register_send_waker(cx.waker());
|
||||
Poll::Pending
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
// Nudge the reactor so any queued tx data dispatches promptly rather
|
||||
// than waiting for the next `poll_delay` deadline.
|
||||
self.notify.notify_one();
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
let handle = this.handle;
|
||||
let notify = &this.notify;
|
||||
let closed = &mut this.closed;
|
||||
this.stack.with(|s| {
|
||||
let socket = s.sockets.get_mut::<smoltcp_tcp::Socket>(handle);
|
||||
if !*closed {
|
||||
socket.close();
|
||||
*closed = true;
|
||||
notify.notify_one();
|
||||
}
|
||||
if socket.state() == smoltcp_tcp::State::Closed {
|
||||
Poll::Ready(Ok(()))
|
||||
} else {
|
||||
// Wake on state-change progress through the FIN/ACK exchange.
|
||||
socket.register_send_waker(cx.waker());
|
||||
Poll::Pending
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Unpin for WasmTcpStream {}
|
||||
|
||||
impl Drop for WasmTcpStream {
|
||||
fn drop(&mut self) {
|
||||
// Queue a FIN (vs `abort()` which sends RST). The
|
||||
// reactor's pending_removal sweep removes the handle once smoltcp
|
||||
// transitions through the FIN/ACK exchange to State::Closed.
|
||||
let handle = self.handle;
|
||||
self.stack.with(|s| {
|
||||
s.sockets.get_mut::<smoltcp_tcp::Socket>(handle).close();
|
||||
s.pending_removal.push(handle);
|
||||
});
|
||||
self.notify.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for PooledConn {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut [u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
#[cfg(any(feature = "fetch", feature = "socket"))]
|
||||
PooledConn::Tls(s) => Pin::new(s).poll_read(cx, buf),
|
||||
PooledConn::Plain(s) => Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for PooledConn {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
#[cfg(any(feature = "fetch", feature = "socket"))]
|
||||
PooledConn::Tls(s) => Pin::new(s).poll_write(cx, buf),
|
||||
PooledConn::Plain(s) => Pin::new(s).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
#[cfg(any(feature = "fetch", feature = "socket"))]
|
||||
PooledConn::Tls(s) => Pin::new(s).poll_flush(cx),
|
||||
PooledConn::Plain(s) => Pin::new(s).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
#[cfg(any(feature = "fetch", feature = "socket"))]
|
||||
PooledConn::Tls(s) => Pin::new(s).poll_close(cx),
|
||||
PooledConn::Plain(s) => Pin::new(s).poll_close(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Unpin for PooledConn {}
|
||||
|
||||
impl WasmUdpSocket {
|
||||
/// Send a datagram to the given address.
|
||||
pub async fn send_to(&self, buf: &[u8], target: SocketAddr) -> io::Result<usize> {
|
||||
let endpoint = to_smoltcp_endpoint(target);
|
||||
let stack = self.stack.clone();
|
||||
let handle = self.handle;
|
||||
let notify = self.notify.clone();
|
||||
|
||||
futures::future::poll_fn(move |cx| {
|
||||
stack.with(|s| {
|
||||
let socket = s.sockets.get_mut::<smoltcp_udp::Socket>(handle);
|
||||
|
||||
if socket.can_send() {
|
||||
socket
|
||||
.send_slice(buf, endpoint)
|
||||
.map_err(|e| io::Error::other(format!("{e}")))?;
|
||||
notify.notify_one();
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
} else {
|
||||
socket.register_send_waker(cx.waker());
|
||||
Poll::Pending
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Receive a datagram, returning (bytes_read, source_address).
|
||||
pub async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
|
||||
let stack = self.stack.clone();
|
||||
let handle = self.handle;
|
||||
|
||||
futures::future::poll_fn(move |cx| {
|
||||
stack.with(|s| {
|
||||
let socket = s.sockets.get_mut::<smoltcp_udp::Socket>(handle);
|
||||
|
||||
if socket.can_recv() {
|
||||
let (n, meta) = socket
|
||||
.recv_slice(buf)
|
||||
.map_err(|e| io::Error::other(format!("{e}")))?;
|
||||
let src = from_smoltcp_endpoint(meta.endpoint);
|
||||
Poll::Ready(Ok((n, src)))
|
||||
} else {
|
||||
socket.register_recv_waker(cx.waker());
|
||||
Poll::Pending
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WasmUdpSocket {
|
||||
fn drop(&mut self) {
|
||||
let handle = self.handle;
|
||||
self.stack.with(|s| {
|
||||
s.sockets.remove(handle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide ephemeral port counter, seeded at [`EPHEMERAL_PORT_START`].
|
||||
static EPHEMERAL_PORT: AtomicU16 = AtomicU16::new(EPHEMERAL_PORT_START);
|
||||
|
||||
/// Allocate the next ephemeral port (wraps at `u16::MAX` back to
|
||||
/// [`EPHEMERAL_PORT_START`]). Single-threaded wasm32 means a plain
|
||||
/// load/store is race-free; the atomic exists for `Sync`.
|
||||
pub(crate) fn allocate_port() -> u16 {
|
||||
let current = EPHEMERAL_PORT.load(Ordering::Relaxed);
|
||||
let next = if current == u16::MAX {
|
||||
EPHEMERAL_PORT_START
|
||||
} else {
|
||||
current + 1
|
||||
};
|
||||
EPHEMERAL_PORT.store(next, Ordering::Relaxed);
|
||||
current
|
||||
}
|
||||
|
||||
/// Drop-bomb for a `SocketHandle` mid-flight in `tcp_connect`. If the caller
|
||||
/// errors out before producing a `WasmTcpStream`, `Drop` removes the handle
|
||||
/// from the `SocketSet`. On success, `defuse()` disarms the guard and hands
|
||||
/// back the handle for ownership transfer into `WasmTcpStream`.
|
||||
struct InflightSocket {
|
||||
stack: Option<SmoltcpStack>,
|
||||
handle: SocketHandle,
|
||||
}
|
||||
|
||||
impl InflightSocket {
|
||||
fn defuse(mut self) -> SocketHandle {
|
||||
self.stack = None;
|
||||
self.handle
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InflightSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(stack) = self.stack.take() {
|
||||
let handle = self.handle;
|
||||
stack.with(|s| {
|
||||
s.sockets.remove(handle);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a TCP connection through the tunnel and wait for `Established`.
|
||||
///
|
||||
/// Used by `WasmTunnel::tcp_connect` and by the DNS resolver provider; both
|
||||
/// draw from the process-wide [`EPHEMERAL_PORT`] counter so allocations
|
||||
/// don't collide.
|
||||
pub(crate) async fn tcp_connect(
|
||||
stack: SmoltcpStack,
|
||||
notify: ReactorNotify,
|
||||
addr: SocketAddr,
|
||||
keepalive: Duration,
|
||||
buffer_size: usize,
|
||||
) -> io::Result<WasmTcpStream> {
|
||||
let remote = to_smoltcp_endpoint(addr);
|
||||
let local_port = allocate_port();
|
||||
// Caller-supplied buffer size, capped at u16::MAX (TCP window field width).
|
||||
let buf_size = buffer_size.min(u16::MAX as usize);
|
||||
let tcp_rx = smoltcp_tcp::SocketBuffer::new(vec![0; buf_size]);
|
||||
let tcp_tx = smoltcp_tcp::SocketBuffer::new(vec![0; buf_size]);
|
||||
let mut socket = smoltcp_tcp::Socket::new(tcp_rx, tcp_tx);
|
||||
socket.set_keep_alive(Some(smoltcp::time::Duration::from_millis(
|
||||
keepalive.as_millis() as u64,
|
||||
)));
|
||||
|
||||
// Synchronous phase: add + connect under one lock. If `connect()` errors,
|
||||
// clean up immediately while we still hold the lock.
|
||||
let handle = stack.with(|s| -> io::Result<SocketHandle> {
|
||||
let handle = s.sockets.add(socket);
|
||||
let crate::reactor::SmoltcpStackInner { iface, sockets, .. } = s;
|
||||
if let Err(e) = sockets.get_mut::<smoltcp_tcp::Socket>(handle).connect(
|
||||
iface.context(),
|
||||
remote,
|
||||
local_port,
|
||||
) {
|
||||
sockets.remove(handle);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("{e:?}"),
|
||||
));
|
||||
}
|
||||
Ok(handle)
|
||||
})?;
|
||||
|
||||
notify.notify_one();
|
||||
|
||||
// Async phase: any error past this point must remove the socket. The
|
||||
// guard removes it on drop; we defuse it once the stream is constructed.
|
||||
let guard = InflightSocket {
|
||||
stack: Some(stack.clone()),
|
||||
handle,
|
||||
};
|
||||
|
||||
{
|
||||
let stack = stack.clone();
|
||||
futures::future::poll_fn(move |cx| {
|
||||
stack.with(|s| {
|
||||
let socket = s.sockets.get_mut::<smoltcp_tcp::Socket>(handle);
|
||||
match socket.state() {
|
||||
smoltcp_tcp::State::Established | smoltcp_tcp::State::CloseWait => {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
smoltcp_tcp::State::Closed => {
|
||||
crate::util::debug_error!("[stream] TCP state: Closed, connection failed");
|
||||
Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionRefused,
|
||||
"TCP connection failed",
|
||||
)))
|
||||
}
|
||||
_ => {
|
||||
socket.register_recv_waker(cx.waker());
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(WasmTcpStream {
|
||||
stack,
|
||||
handle: guard.defuse(),
|
||||
notify,
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a UDP socket bound to a fresh ephemeral port.
|
||||
pub(crate) fn create_udp_socket(
|
||||
stack: SmoltcpStack,
|
||||
notify: ReactorNotify,
|
||||
) -> io::Result<WasmUdpSocket> {
|
||||
let local_port = allocate_port();
|
||||
let udp_rx = smoltcp_udp::PacketBuffer::new(
|
||||
vec![smoltcp_udp::PacketMetadata::EMPTY; 16],
|
||||
vec![0; 65535],
|
||||
);
|
||||
let udp_tx = smoltcp_udp::PacketBuffer::new(
|
||||
vec![smoltcp_udp::PacketMetadata::EMPTY; 16],
|
||||
vec![0; 65535],
|
||||
);
|
||||
let mut socket = smoltcp_udp::Socket::new(udp_rx, udp_tx);
|
||||
socket
|
||||
.bind(local_port)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::AddrInUse, "UDP bind failed"))?;
|
||||
|
||||
let handle = stack.with(|s| s.sockets.add(socket));
|
||||
|
||||
Ok(WasmUdpSocket {
|
||||
stack,
|
||||
handle,
|
||||
notify,
|
||||
})
|
||||
}
|
||||
|
||||
// Address conversion helpers
|
||||
|
||||
pub(crate) fn to_smoltcp_endpoint(addr: SocketAddr) -> IpEndpoint {
|
||||
let ip = match addr.ip() {
|
||||
IpAddr::V4(v4) => IpAddress::Ipv4(v4),
|
||||
IpAddr::V6(v6) => IpAddress::Ipv6(v6),
|
||||
};
|
||||
IpEndpoint::new(ip, addr.port())
|
||||
}
|
||||
|
||||
pub(crate) fn from_smoltcp_endpoint(ep: IpEndpoint) -> SocketAddr {
|
||||
let ip = match ep.addr {
|
||||
IpAddress::Ipv4(v4) => IpAddr::V4(v4),
|
||||
IpAddress::Ipv6(v6) => IpAddr::V6(v6),
|
||||
};
|
||||
SocketAddr::new(ip, ep.port)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! TLS connector using futures-rustls (futures::io traits, NOT tokio).
|
||||
//!
|
||||
//! Crypto provider: rustls-rustcrypto (RustCrypto-backed pure-Rust primitives).
|
||||
//!
|
||||
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use futures_rustls::TlsConnector;
|
||||
use rustls::pki_types::ServerName;
|
||||
use rustls::{CipherSuite, ClientConfig};
|
||||
|
||||
use crate::error::FetchError;
|
||||
|
||||
/// Cached TLS client config: built once, reused for all connections.
|
||||
static TLS_CONFIG: OnceLock<Arc<ClientConfig>> = OnceLock::new();
|
||||
|
||||
/// TLS stream wrapper that tolerates a missing `close_notify` from the peer.
|
||||
///
|
||||
/// rustls reports a peer closing the underlying TCP connection without sending
|
||||
/// the TLS `close_notify` alert as `io::ErrorKind::UnexpectedEof`. Many CDNs
|
||||
/// (and older servers) do this routinely, and hyper then surfaces it as a body
|
||||
/// framing error even when the HTTP response was completely received per its
|
||||
/// Content-Length / chunked terminator.
|
||||
///
|
||||
/// This wrapper translates `UnexpectedEof` on `poll_read` to a clean `Ok(0)`
|
||||
/// (EOF). hyper's body framing is authoritative for whether the message is
|
||||
/// complete — if it isn't, hyper will report truncation on its own terms. The
|
||||
/// only attack surface this opens is for HTTP/1.0-style "read to EOF" bodies,
|
||||
/// which were already truncatable and which modern frameworks don't use.
|
||||
///
|
||||
/// Writes pass through unchanged.
|
||||
pub struct MaybeCloseNotify<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> MaybeCloseNotify<S> {
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for MaybeCloseNotify<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut [u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
match Pin::new(&mut self.inner).poll_read(cx, buf) {
|
||||
Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||
crate::util::debug_log!("[tls] peer closed without close_notify, treating as EOF");
|
||||
Poll::Ready(Ok(0))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for MaybeCloseNotify<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.inner).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_close(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a TLS handshake over the given stream.
|
||||
///
|
||||
/// Returns a `MaybeCloseNotify`-wrapped TLS stream so that peers omitting
|
||||
/// the TLS `close_notify` shutdown alert don't cause spurious body-framing
|
||||
/// errors at the hyper layer.
|
||||
pub async fn connect<S>(
|
||||
stream: S,
|
||||
hostname: &str,
|
||||
) -> Result<MaybeCloseNotify<futures_rustls::client::TlsStream<S>>, FetchError>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let config = make_client_config()?;
|
||||
let connector = TlsConnector::from(config);
|
||||
|
||||
// ServerName::try_from(String) gives ServerName<'static> (owned),
|
||||
// which is what futures-rustls::TlsConnector::connect requires.
|
||||
let server_name = ServerName::try_from(hostname.to_string())
|
||||
.map_err(|e| FetchError::Dns(format!("invalid TLS server name '{hostname}': {e}")))?;
|
||||
|
||||
let result = connector
|
||||
.connect(server_name, stream)
|
||||
.await
|
||||
.map(MaybeCloseNotify::new)
|
||||
.map_err(FetchError::Io);
|
||||
|
||||
if let Err(e) = &result {
|
||||
crate::util::debug_error!("[tls] handshake FAILED with '{hostname}': {e}");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get or build the cached rustls ClientConfig with the webpki-roots CA bundle.
|
||||
///
|
||||
/// The config (crypto provider, root CA store, protocol versions) is identical
|
||||
/// for every connection, so we build it once and reuse the `Arc<ClientConfig>`.
|
||||
fn make_client_config() -> Result<Arc<ClientConfig>, FetchError> {
|
||||
if let Some(config) = TLS_CONFIG.get() {
|
||||
return Ok(config.clone());
|
||||
}
|
||||
|
||||
// Restrict cipher suites to only what is explicity implemented as
|
||||
// per https://github.com/RustCrypto/rustls-rustcrypto#rustls-rustcrypto.
|
||||
let mut provider = rustls_rustcrypto::provider();
|
||||
provider.cipher_suites.retain(|s| {
|
||||
matches!(
|
||||
s.suite(),
|
||||
CipherSuite::TLS13_AES_128_GCM_SHA256
|
||||
| CipherSuite::TLS13_AES_256_GCM_SHA384
|
||||
| CipherSuite::TLS13_CHACHA20_POLY1305_SHA256
|
||||
| CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
| CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
| CipherSuite::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
| CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
| CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
| CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
)
|
||||
});
|
||||
let provider = Arc::new(provider);
|
||||
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let mut config = ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| FetchError::Http(format!("TLS config error: {e}")))?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
// ALPN: advertise HTTP/1.1 so CDNs (GitHub, Cloudflare) that require
|
||||
// protocol negotiation don't abort the handshake with an EOF.
|
||||
config.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||
|
||||
// Disable session resumption: TLS session tickets and PSK identities are
|
||||
// long-lived correlators a server can use to link separate mixnet circuits
|
||||
// back to the same client, defeating per-request unlinkability.
|
||||
config.resumption = rustls::client::Resumption::disabled();
|
||||
|
||||
let config = Arc::new(config);
|
||||
Ok(TLS_CONFIG.get_or_init(|| config.clone()).clone())
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! WASM mixnet tunnel. Manages a smoltcp TCP/IP stack connected to the Nym
|
||||
//! mixnet via an IPR (IP Packet Router), running in a browser Web Worker.
|
||||
//!
|
||||
//! Data flow:
|
||||
//! ```text
|
||||
//! poll_write → smoltcp → device tx → bridge → LP frame → mixnet → IPR → internet
|
||||
//! internet → IPR → mixnet → bridge → LP decode → device rx → smoltcp → poll_read
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::mpsc;
|
||||
use smoltcp::iface::Config;
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address, Ipv6Address};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use nym_ip_packet_requests::IpPair;
|
||||
use nym_wasm_client_core::client::base_client::{BaseClientBuilder, ClientInput};
|
||||
use nym_wasm_client_core::client::received_buffer::ReceivedBufferMessage;
|
||||
use nym_wasm_client_core::config::new_base_client_config;
|
||||
use nym_wasm_client_core::helpers::{add_gateway, generate_new_client_keys};
|
||||
use nym_wasm_client_core::nym_task::ShutdownTracker;
|
||||
use nym_wasm_client_core::storage::ClientStorage;
|
||||
use nym_wasm_client_core::storage::core_client_traits::FullWasmClientStorage;
|
||||
use nym_wasm_client_core::storage::wasm_client_traits::WasmClientStorage;
|
||||
use nym_wasm_client_core::{QueryReqwestRpcNyxdClient, Recipient};
|
||||
|
||||
use crate::bridge;
|
||||
use crate::device::WasmDevice;
|
||||
use crate::error::FetchError;
|
||||
use crate::ipr;
|
||||
use crate::reactor::{self, ReactorNotify, SmoltcpStack, smoltcp_now};
|
||||
use crate::state;
|
||||
use crate::stream::{self, PooledConn, WasmTcpStream, WasmUdpSocket};
|
||||
|
||||
/// Configuration for `setupMixTunnel(opts)`.
|
||||
///
|
||||
/// Construct directly or via [`TunnelOpts::builder`] for chainable configuration.
|
||||
/// Performance/timeout tuning lives in [`TuningOpts`] under the `tuning` field.
|
||||
pub struct TunnelOpts {
|
||||
/// `None` triggers performance-weighted auto-discovery via `ipr::discover_ipr`.
|
||||
pub ipr_address: Option<Recipient>,
|
||||
/// Client storage ID. Randomise per session to get a clean client.
|
||||
pub client_id: String,
|
||||
/// Use `wss://` for gateway connections (default: `true`).
|
||||
pub force_tls: bool,
|
||||
/// Disable Poisson-distributed dummy traffic (default: `false`).
|
||||
pub disable_poisson_traffic: bool,
|
||||
/// Disable cover traffic loop (default: `false`).
|
||||
pub disable_cover_traffic: bool,
|
||||
/// Reply-SURB counts for the LP Open frame and each Data frame the
|
||||
/// bridge sends. See [`ipr::SurbsConfig`]. Defaults to open=5, data=2.
|
||||
pub surbs: ipr::SurbsConfig,
|
||||
/// Primary DNS resolver. `None` falls back to [`dns::DEFAULT_PRIMARY_DNS`].
|
||||
pub primary_dns: Option<SocketAddr>,
|
||||
/// Fallback DNS resolver. `None` falls back to [`dns::DEFAULT_FALLBACK_DNS`].
|
||||
pub fallback_dns: Option<SocketAddr>,
|
||||
/// Passphrase used to encrypt the client's persistent storage (identity
|
||||
/// keys, gateway details, etc). `None` means plaintext storage. The same
|
||||
/// passphrase must be supplied on subsequent loads to read the same keys.
|
||||
pub storage_passphrase: Option<String>,
|
||||
/// Timeouts + buffer sizes + redirect limits. See [`TuningOpts`].
|
||||
pub tuning: TuningOpts,
|
||||
}
|
||||
|
||||
/// Performance + protocol-limit tuning knobs.
|
||||
///
|
||||
/// All fields have sensible defaults via [`TuningOpts::default`]; consumers
|
||||
/// override via the chainable builder methods on [`TunnelOptsBuilder`].
|
||||
pub struct TuningOpts {
|
||||
/// IPR connect handshake timeout.
|
||||
pub connect_timeout: Duration,
|
||||
/// DNS query timeout (per attempt, primary or fallback).
|
||||
pub dns_timeout: Duration,
|
||||
/// TCP keepalive interval; smoltcp probes the peer at this cadence.
|
||||
pub tcp_keepalive_interval: Duration,
|
||||
/// Per-TCP-stream RX/TX buffer size in bytes. Trades memory for throughput.
|
||||
/// Capped to `u16::MAX` (65535) to fit the TCP window field width.
|
||||
pub tcp_buffer_size: usize,
|
||||
/// Maximum HTTP redirect chain depth before `mixFetch` gives up.
|
||||
pub max_redirects: u8,
|
||||
}
|
||||
|
||||
impl Default for TuningOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout: Duration::from_secs(60),
|
||||
dns_timeout: Duration::from_secs(30),
|
||||
tcp_keepalive_interval: Duration::from_secs(10),
|
||||
tcp_buffer_size: 65535,
|
||||
max_redirects: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelOpts {
|
||||
/// Start a chainable builder.
|
||||
pub fn builder() -> TunnelOptsBuilder {
|
||||
TunnelOptsBuilder::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Chainable builder for [`TunnelOpts`]. Setters for tuning fields delegate
|
||||
/// into the nested [`TuningOpts`] at `build()` time, so callers see a flat API.
|
||||
#[derive(Default)]
|
||||
pub struct TunnelOptsBuilder {
|
||||
ipr_address: Option<Recipient>,
|
||||
client_id: Option<String>,
|
||||
force_tls: Option<bool>,
|
||||
disable_poisson_traffic: Option<bool>,
|
||||
disable_cover_traffic: Option<bool>,
|
||||
surbs: Option<ipr::SurbsConfig>,
|
||||
primary_dns: Option<SocketAddr>,
|
||||
fallback_dns: Option<SocketAddr>,
|
||||
storage_passphrase: Option<String>,
|
||||
connect_timeout: Option<Duration>,
|
||||
dns_timeout: Option<Duration>,
|
||||
tcp_keepalive_interval: Option<Duration>,
|
||||
tcp_buffer_size: Option<usize>,
|
||||
max_redirects: Option<u8>,
|
||||
}
|
||||
|
||||
impl TunnelOptsBuilder {
|
||||
pub fn ipr_address(mut self, v: Recipient) -> Self {
|
||||
self.ipr_address = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn client_id(mut self, v: impl Into<String>) -> Self {
|
||||
self.client_id = Some(v.into());
|
||||
self
|
||||
}
|
||||
pub fn force_tls(mut self, v: bool) -> Self {
|
||||
self.force_tls = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn disable_poisson_traffic(mut self, v: bool) -> Self {
|
||||
self.disable_poisson_traffic = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn disable_cover_traffic(mut self, v: bool) -> Self {
|
||||
self.disable_cover_traffic = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn surbs(mut self, v: ipr::SurbsConfig) -> Self {
|
||||
self.surbs = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn primary_dns(mut self, v: SocketAddr) -> Self {
|
||||
self.primary_dns = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn fallback_dns(mut self, v: SocketAddr) -> Self {
|
||||
self.fallback_dns = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn storage_passphrase(mut self, v: impl Into<String>) -> Self {
|
||||
self.storage_passphrase = Some(v.into());
|
||||
self
|
||||
}
|
||||
pub fn connect_timeout(mut self, v: Duration) -> Self {
|
||||
self.connect_timeout = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn dns_timeout(mut self, v: Duration) -> Self {
|
||||
self.dns_timeout = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn tcp_keepalive_interval(mut self, v: Duration) -> Self {
|
||||
self.tcp_keepalive_interval = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn tcp_buffer_size(mut self, v: usize) -> Self {
|
||||
self.tcp_buffer_size = Some(v);
|
||||
self
|
||||
}
|
||||
pub fn max_redirects(mut self, v: u8) -> Self {
|
||||
self.max_redirects = Some(v);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> TunnelOpts {
|
||||
let defaults = TuningOpts::default();
|
||||
TunnelOpts {
|
||||
ipr_address: self.ipr_address,
|
||||
client_id: self.client_id.unwrap_or_else(|| "smolmix-wasm".to_string()),
|
||||
force_tls: self.force_tls.unwrap_or(true),
|
||||
disable_poisson_traffic: self.disable_poisson_traffic.unwrap_or(false),
|
||||
disable_cover_traffic: self.disable_cover_traffic.unwrap_or(false),
|
||||
surbs: self.surbs.unwrap_or_default(),
|
||||
primary_dns: self.primary_dns,
|
||||
fallback_dns: self.fallback_dns,
|
||||
storage_passphrase: self.storage_passphrase,
|
||||
tuning: TuningOpts {
|
||||
connect_timeout: self.connect_timeout.unwrap_or(defaults.connect_timeout),
|
||||
dns_timeout: self.dns_timeout.unwrap_or(defaults.dns_timeout),
|
||||
tcp_keepalive_interval: self
|
||||
.tcp_keepalive_interval
|
||||
.unwrap_or(defaults.tcp_keepalive_interval),
|
||||
tcp_buffer_size: self.tcp_buffer_size.unwrap_or(defaults.tcp_buffer_size),
|
||||
max_redirects: self.max_redirects.unwrap_or(defaults.max_redirects),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mixnet tunnel. Owns the smoltcp stack, base client, and connection pool.
|
||||
pub struct WasmTunnel {
|
||||
stack: SmoltcpStack,
|
||||
notify: ReactorNotify,
|
||||
allocated_ips: IpPair,
|
||||
/// Resolved per-tunnel DNS endpoints (primary, fallback). Either falls
|
||||
/// back to the constants in [`dns`] when the caller didn't override.
|
||||
dns_primary: SocketAddr,
|
||||
dns_fallback: SocketAddr,
|
||||
/// All timeouts + buffer sizes + redirect limits; populated from
|
||||
/// [`TunnelOpts::tuning`] at construction.
|
||||
tuning: TuningOpts,
|
||||
/// Plain per-session DNS cache. No TTL respect (cache lives until tunnel
|
||||
/// shutdown). See [`dns::resolve`] for usage.
|
||||
dns_cache: Mutex<HashMap<String, IpAddr>>,
|
||||
/// Serialises DNS lookups so concurrent callers coalesce on the cache.
|
||||
dns_lock: futures::lock::Mutex<()>,
|
||||
/// One idle connection per (host, port).
|
||||
conn_pool: Mutex<HashMap<(String, u16), PooledConn>>,
|
||||
/// Per-origin locks to avoid stampeding parallel TCP+TLS handshakes.
|
||||
#[allow(clippy::type_complexity)]
|
||||
origin_locks: Mutex<HashMap<(String, u16), Arc<futures::lock::Mutex<()>>>>,
|
||||
/// `Mutex<Option<_>>` because `ShutdownTracker::shutdown(self).await`
|
||||
/// takes ownership, but `WasmTunnel` lives in a `OnceLock`.
|
||||
base_tracker: Mutex<Option<ShutdownTracker>>,
|
||||
/// Child of `base_tracker`; bridge + reactor spawn through it.
|
||||
smolmix_tracker: Mutex<Option<ShutdownTracker>>,
|
||||
state: state::State,
|
||||
}
|
||||
|
||||
/// Handles the Nym base client hands back after `start_base()`.
|
||||
struct ClientHandles {
|
||||
client_input: Arc<ClientInput>,
|
||||
reconstructed_receiver: ipr::ReconstructedReceiver,
|
||||
shutdown_handle: ShutdownTracker,
|
||||
/// Lifted out so `ipr::discover_ipr` reuses the same URLs the base client did.
|
||||
nym_api_urls: Vec<url::Url>,
|
||||
}
|
||||
|
||||
/// smoltcp handles returned by `init_network_stack` (reactor + bridge already spawned).
|
||||
struct NetworkStack {
|
||||
stack: SmoltcpStack,
|
||||
notify: ReactorNotify,
|
||||
}
|
||||
|
||||
impl WasmTunnel {
|
||||
/// Connect to the mixnet and establish an IPR tunnel.
|
||||
pub async fn new(opts: TunnelOpts) -> Result<Self, FetchError> {
|
||||
nym_wasm_utils::console_log!("[smolmix] starting tunnel...");
|
||||
|
||||
let ClientHandles {
|
||||
client_input,
|
||||
mut reconstructed_receiver,
|
||||
shutdown_handle,
|
||||
nym_api_urls,
|
||||
} = Self::start_nym_client(&opts).await?;
|
||||
|
||||
// Cascade points at smolmix_tracker so state.fail() only kills
|
||||
// smolmix tasks; shutdown() handles the base client.
|
||||
let smolmix_tracker = shutdown_handle.child_tracker();
|
||||
let state = state::State::new(smolmix_tracker.clone_shutdown_token());
|
||||
|
||||
let ipr_address = match opts.ipr_address {
|
||||
Some(addr) => addr,
|
||||
None => {
|
||||
nym_wasm_utils::console_log!("[smolmix] no IPR specified, auto-discovering...");
|
||||
ipr::discover_ipr(&nym_api_urls).await?
|
||||
}
|
||||
};
|
||||
|
||||
let stream_id: u64 = rand::random();
|
||||
let allocated_ips = Self::ipr_handshake(
|
||||
&client_input,
|
||||
&mut reconstructed_receiver,
|
||||
&ipr_address,
|
||||
stream_id,
|
||||
opts.surbs,
|
||||
opts.tuning.connect_timeout,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let NetworkStack { stack, notify } = Self::init_network_stack(
|
||||
allocated_ips,
|
||||
client_input.clone(),
|
||||
reconstructed_receiver,
|
||||
ipr_address,
|
||||
stream_id,
|
||||
&smolmix_tracker,
|
||||
&state,
|
||||
opts.surbs.data,
|
||||
);
|
||||
|
||||
state.set(state::TunnelState::Ready);
|
||||
nym_wasm_utils::console_log!("[smolmix] tunnel ready");
|
||||
|
||||
Ok(Self {
|
||||
stack,
|
||||
notify,
|
||||
allocated_ips,
|
||||
dns_primary: opts.primary_dns.unwrap_or(crate::dns::DEFAULT_PRIMARY_DNS),
|
||||
dns_fallback: opts
|
||||
.fallback_dns
|
||||
.unwrap_or(crate::dns::DEFAULT_FALLBACK_DNS),
|
||||
tuning: opts.tuning,
|
||||
dns_cache: Mutex::new(HashMap::new()),
|
||||
dns_lock: futures::lock::Mutex::new(()),
|
||||
conn_pool: Mutex::new(HashMap::new()),
|
||||
origin_locks: Mutex::new(HashMap::new()),
|
||||
base_tracker: Mutex::new(Some(shutdown_handle)),
|
||||
smolmix_tracker: Mutex::new(Some(smolmix_tracker)),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure storage, generate identity keys if needed, register a gateway,
|
||||
/// and start the Nym base client. Returns the producer/consumer channels.
|
||||
async fn start_nym_client(opts: &TunnelOpts) -> Result<ClientHandles, FetchError> {
|
||||
let mut config = new_base_client_config(
|
||||
opts.client_id.clone(),
|
||||
env!("CARGO_PKG_VERSION").to_string(),
|
||||
None, // nym_api: use default
|
||||
None, // nyxd: use default
|
||||
None, // debug: use default
|
||||
)
|
||||
.map_err(|e| FetchError::Tunnel(format!("config error: {e}")))?;
|
||||
|
||||
config.debug.topology.ignore_egress_epoch_role = true;
|
||||
config
|
||||
.debug
|
||||
.traffic
|
||||
.disable_main_poisson_packet_distribution = opts.disable_poisson_traffic;
|
||||
config.debug.cover_traffic.disable_loop_cover_traffic_stream = opts.disable_cover_traffic;
|
||||
|
||||
let client_store =
|
||||
ClientStorage::new_async(&opts.client_id, opts.storage_passphrase.clone())
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("storage error: {e}")))?;
|
||||
|
||||
if !client_store
|
||||
.has_identity_key()
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("storage error: {e}")))?
|
||||
{
|
||||
generate_new_client_keys(&client_store)
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("keygen error: {e}")))?;
|
||||
}
|
||||
|
||||
let has_gateway = client_store
|
||||
.get_active_gateway_id()
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("gateway-storage error: {e}")))?
|
||||
.active_gateway_id_bs58
|
||||
.is_some();
|
||||
|
||||
if !has_gateway {
|
||||
let user_agent = nym_bin_common::bin_info!().into();
|
||||
add_gateway(
|
||||
None, // preferred_gateway
|
||||
None, // latency_based_selection
|
||||
opts.force_tls,
|
||||
&config.client.nym_api_urls,
|
||||
user_agent,
|
||||
config.debug.topology.minimum_gateway_performance,
|
||||
config.debug.topology.ignore_ingress_epoch_role,
|
||||
&client_store,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("gateway selection error: {e}")))?;
|
||||
}
|
||||
|
||||
let storage = FullWasmClientStorage::new(&config, client_store);
|
||||
let base_builder =
|
||||
BaseClientBuilder::<QueryReqwestRpcNyxdClient, _>::new(config.clone(), storage, None);
|
||||
|
||||
let mut started_client = base_builder
|
||||
.start_base()
|
||||
.await
|
||||
.map_err(|e| FetchError::Tunnel(format!("client start error: {e}")))?;
|
||||
|
||||
let client_input = Arc::new(started_client.client_input.register_producer());
|
||||
let client_output = started_client.client_output.register_consumer();
|
||||
|
||||
let (reconstructed_sender, reconstructed_receiver) = mpsc::unbounded();
|
||||
client_output
|
||||
.received_buffer_request_sender
|
||||
.unbounded_send(ReceivedBufferMessage::ReceiverAnnounce(
|
||||
reconstructed_sender,
|
||||
))
|
||||
.map_err(|_| FetchError::Tunnel("failed to register message receiver".into()))?;
|
||||
|
||||
Ok(ClientHandles {
|
||||
client_input,
|
||||
reconstructed_receiver,
|
||||
shutdown_handle: started_client.shutdown_handle,
|
||||
nym_api_urls: config.client.nym_api_urls.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the LP stream + run the IPR v9 connect handshake. Returns the
|
||||
/// IPs the IPR allocated for this tunnel.
|
||||
async fn ipr_handshake(
|
||||
client_input: &Arc<ClientInput>,
|
||||
receiver: &mut ipr::ReconstructedReceiver,
|
||||
ipr_address: &Recipient,
|
||||
stream_id: u64,
|
||||
surbs: ipr::SurbsConfig,
|
||||
connect_timeout: Duration,
|
||||
) -> Result<IpPair, FetchError> {
|
||||
nym_wasm_utils::console_log!("[smolmix] connecting to IPR...");
|
||||
let allocated_ips = ipr::open_and_connect(
|
||||
client_input,
|
||||
receiver,
|
||||
ipr_address,
|
||||
stream_id,
|
||||
surbs,
|
||||
connect_timeout,
|
||||
)
|
||||
.await?;
|
||||
nym_wasm_utils::console_log!("[smolmix] IPR connected");
|
||||
crate::util::debug_log!(
|
||||
"[smolmix] allocated IPv4: {}, IPv6: {}",
|
||||
allocated_ips.ipv4,
|
||||
allocated_ips.ipv6,
|
||||
);
|
||||
Ok(allocated_ips)
|
||||
}
|
||||
|
||||
/// Build the smoltcp interface, spawn the reactor + bridge, and return
|
||||
/// the shared handles the tunnel keeps to drive the stack.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn init_network_stack(
|
||||
allocated_ips: IpPair,
|
||||
client_input: Arc<ClientInput>,
|
||||
reconstructed_receiver: ipr::ReconstructedReceiver,
|
||||
ipr_address: Recipient,
|
||||
stream_id: u64,
|
||||
tracker: &ShutdownTracker,
|
||||
state: &state::State,
|
||||
data_surbs: u32,
|
||||
) -> NetworkStack {
|
||||
let mut device = WasmDevice::new();
|
||||
let iface_config = Config::new(HardwareAddress::Ip);
|
||||
let mut iface = smoltcp::iface::Interface::new(iface_config, &mut device, smoltcp_now());
|
||||
|
||||
// smoltcp's address + route tables are heapless vecs with capacity
|
||||
// IFACE_MAX_ADDR_COUNT / IFACE_MAX_ROUTE_COUNT (default 8 each).
|
||||
// We add 2 of each on a fresh interface; capacity is the only failure
|
||||
// mode, so an .expect is fine here.
|
||||
iface.update_ip_addrs(|addrs| {
|
||||
addrs
|
||||
.push(IpCidr::new(IpAddress::from(allocated_ips.ipv4), 32))
|
||||
.expect("smoltcp address vec full");
|
||||
addrs
|
||||
.push(IpCidr::new(IpAddress::from(allocated_ips.ipv6), 128))
|
||||
.expect("smoltcp address vec full");
|
||||
});
|
||||
iface
|
||||
.routes_mut()
|
||||
.add_default_ipv4_route(Ipv4Address::UNSPECIFIED)
|
||||
.expect("smoltcp routes table full");
|
||||
iface
|
||||
.routes_mut()
|
||||
.add_default_ipv6_route(Ipv6Address::UNSPECIFIED)
|
||||
.expect("smoltcp routes table full");
|
||||
|
||||
let stack = SmoltcpStack::new(iface, device);
|
||||
let notify = Arc::new(Notify::new());
|
||||
|
||||
reactor::start_reactor(stack.clone(), notify.clone(), tracker, state.clone());
|
||||
bridge::start_bridge(
|
||||
stack.clone(),
|
||||
client_input,
|
||||
reconstructed_receiver,
|
||||
ipr_address,
|
||||
stream_id,
|
||||
notify.clone(),
|
||||
tracker,
|
||||
state.clone(),
|
||||
data_surbs,
|
||||
);
|
||||
|
||||
NetworkStack { stack, notify }
|
||||
}
|
||||
|
||||
/// Open a TCP connection through the tunnel (SYN -> established).
|
||||
pub async fn tcp_connect(&self, addr: SocketAddr) -> io::Result<WasmTcpStream> {
|
||||
stream::tcp_connect(
|
||||
self.stack.clone(),
|
||||
self.notify.clone(),
|
||||
addr,
|
||||
self.tcp_keepalive_interval(),
|
||||
self.tcp_buffer_size(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a UDP socket bound to an ephemeral port.
|
||||
pub async fn udp_socket(&self) -> io::Result<WasmUdpSocket> {
|
||||
stream::create_udp_socket(self.stack.clone(), self.notify.clone())
|
||||
}
|
||||
|
||||
/// Gracefully disconnect from the Nym mixnet.
|
||||
///
|
||||
/// Signals the bridge and reactor to stop, then drops the base-client
|
||||
/// handles so the Nym client stops consuming cover/Poisson traffic and
|
||||
/// closes its gateway WebSocket. `WasmTunnel` itself lives in a
|
||||
/// `OnceLock` for the lifetime of the worker, so dropping `self` is not
|
||||
/// an option; we drop the inner handles instead.
|
||||
pub async fn shutdown(&self) {
|
||||
use state::TunnelState;
|
||||
if matches!(
|
||||
self.state.get(),
|
||||
TunnelState::ShuttingDown | TunnelState::Shutdown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.state.set(TunnelState::ShuttingDown);
|
||||
|
||||
// Cancel + wait, child first. The base token cancels the whole
|
||||
// subtree, but each level's TaskTracker only waits on its own
|
||||
// tasks, so both need an explicit `.shutdown().await`.
|
||||
// Take the trackers out of their Mutexes first so the sync guards drop
|
||||
// before the async `.shutdown().await` (clippy::await_holding_lock).
|
||||
let smolmix_tracker = self.smolmix_tracker.lock().unwrap().take();
|
||||
let base_tracker = self.base_tracker.lock().unwrap().take();
|
||||
if let Some(tracker) = smolmix_tracker {
|
||||
tracker.shutdown().await;
|
||||
}
|
||||
if let Some(tracker) = base_tracker {
|
||||
tracker.shutdown().await;
|
||||
}
|
||||
|
||||
// Don't overwrite a Failed state that was set during teardown.
|
||||
if !matches!(self.state.get(), TunnelState::Failed { .. }) {
|
||||
self.state.set(TunnelState::Shutdown);
|
||||
}
|
||||
nym_wasm_utils::console_log!("[smolmix] tunnel shut down");
|
||||
}
|
||||
|
||||
/// The IP addresses allocated to this tunnel by the IPR.
|
||||
pub fn allocated_ips(&self) -> IpPair {
|
||||
self.allocated_ips
|
||||
}
|
||||
|
||||
/// Panic-aware via `State::get`'s short-circuit.
|
||||
pub(crate) fn is_ready(&self) -> bool {
|
||||
self.state.is_ready()
|
||||
}
|
||||
|
||||
pub(crate) fn tunnel_state(&self) -> state::TunnelState {
|
||||
self.state.get()
|
||||
}
|
||||
|
||||
/// DNS resolution cache, checked by `dns::resolve` before querying.
|
||||
pub(crate) fn dns_cache(&self) -> &Mutex<HashMap<String, IpAddr>> {
|
||||
&self.dns_cache
|
||||
}
|
||||
|
||||
/// Async lock that serialises DNS lookups for request coalescing.
|
||||
pub(crate) fn dns_lock(&self) -> &futures::lock::Mutex<()> {
|
||||
&self.dns_lock
|
||||
}
|
||||
|
||||
/// Resolver endpoints used by `dns::resolve` (primary tried first).
|
||||
pub(crate) fn dns_primary(&self) -> SocketAddr {
|
||||
self.dns_primary
|
||||
}
|
||||
pub(crate) fn dns_fallback(&self) -> SocketAddr {
|
||||
self.dns_fallback
|
||||
}
|
||||
/// Per-query DNS timeout (used in `dns::resolve_with`).
|
||||
pub(crate) fn dns_timeout(&self) -> Duration {
|
||||
self.tuning.dns_timeout
|
||||
}
|
||||
/// TCP keepalive interval applied to every new `WasmTcpStream`.
|
||||
pub(crate) fn tcp_keepalive_interval(&self) -> Duration {
|
||||
self.tuning.tcp_keepalive_interval
|
||||
}
|
||||
/// TCP RX/TX buffer size in bytes applied to every new `WasmTcpStream`.
|
||||
pub(crate) fn tcp_buffer_size(&self) -> usize {
|
||||
self.tuning.tcp_buffer_size
|
||||
}
|
||||
/// Maximum HTTP redirect chain depth before `mixFetch` gives up.
|
||||
pub(crate) fn max_redirects(&self) -> u8 {
|
||||
self.tuning.max_redirects
|
||||
}
|
||||
|
||||
/// Get (or create) the per-origin lock for serialising concurrent requests.
|
||||
pub(crate) fn origin_lock(&self, host: &str, port: u16) -> Arc<futures::lock::Mutex<()>> {
|
||||
self.origin_locks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry((host.to_string(), port))
|
||||
.or_insert_with(|| Arc::new(futures::lock::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Take an idle connection from the pool (if one exists for this origin).
|
||||
pub(crate) fn take_pooled(&self, host: &str, port: u16) -> Option<PooledConn> {
|
||||
self.conn_pool
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(host.to_string(), port))
|
||||
}
|
||||
|
||||
/// Return a reusable connection to the pool for later use.
|
||||
pub(crate) fn return_to_pool(&self, host: String, port: u16, conn: PooledConn) {
|
||||
self.conn_pool.lock().unwrap().insert((host, port), conn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Re-exports of the shared debug-logging helpers from `nym-wasm-utils`.
|
||||
//!
|
||||
//! Both macros gate on a runtime flag in `nym-wasm-utils`, which smolmix
|
||||
//! flips on in `lib.rs::main()` when its own `debug` feature is enabled
|
||||
//! (see `nym_wasm_utils::set_debug_logging`). `hex_preview` is the
|
||||
//! binary-buffer formatter used inside those macros.
|
||||
|
||||
pub(crate) use nym_wasm_utils::debug_error;
|
||||
pub(crate) use nym_wasm_utils::debug_log;
|
||||
pub(crate) use nym_wasm_utils::hex_preview;
|
||||
|
||||
/// MSRV-safe equivalent of `str::floor_char_boundary` (stable in 1.91; workspace
|
||||
/// MSRV is 1.87). Returns the largest byte index `≤ target` where `s.is_char_boundary(i)`
|
||||
/// holds; useful for truncating a string at a safe UTF-8 boundary.
|
||||
pub(crate) fn floor_char_boundary(s: &str, target: usize) -> usize {
|
||||
let mut i = target.min(s.len());
|
||||
while !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
@@ -0,0 +1,130 @@
|
||||
# smolmix-wasm Playwright Tests
|
||||
|
||||
Automated browser tests for the smolmix-wasm mixnet tunnel. Runs smoke tests and a full test suite (HTTPS cold/warm, stress httpbin) across Chromium, Firefox, and WebKit.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Build the WASM package and internal-dev harness:
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
make build-debug
|
||||
cd wasm/smolmix/internal-dev && pnpm run build
|
||||
```
|
||||
|
||||
2. Install test dependencies and browser engines (first time only):
|
||||
|
||||
```bash
|
||||
cd wasm/smolmix/tests
|
||||
pnpm install
|
||||
pnpm exec playwright install
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
Both suites use a hardcoded default IPR (see `internal-dev/index.html` and
|
||||
`internal-dev/headless.js`); no env var is required to run them. Override
|
||||
the default by exporting `IPR_ADDRESS` if you want to point at a different
|
||||
exit node:
|
||||
|
||||
```bash
|
||||
export IPR_ADDRESS="6B6iuWX4bQP4GVA4Yq7XmZencaaGw6BaPY6xJWYSwsbF.6g6LRx1fgU2Q2A4ZPKonYHtfBARh1GPMe1LtXk6vpRR8@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1"
|
||||
```
|
||||
|
||||
Pick any combination of projects to run:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=smoke-chromium
|
||||
pnpm exec playwright test --project=suite-firefox
|
||||
pnpm exec playwright test --project=smoke-webkit --project=suite-webkit
|
||||
pnpm exec playwright test # all 6 projects
|
||||
```
|
||||
|
||||
Available projects: `smoke-chromium`, `smoke-firefox`, `smoke-webkit`, `suite-chromium`, `suite-firefox`, `suite-webkit`.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Smoke
|
||||
|
||||
Loads the internal-dev page, fills in the IPR address, clicks setup, and verifies the tunnel connects without errors. Quick connectivity check (~30s).
|
||||
|
||||
### Suite
|
||||
|
||||
Loads `headless.html` which auto-runs three tests in sequence:
|
||||
|
||||
| Test | What it measures |
|
||||
|------|-----------------|
|
||||
| Smoke (cold HTTPS) | Full pipeline: DNS + TCP + TLS + HTTP |
|
||||
| HTTPS GET (warm) | Pooled connection reuse (HTTP only) |
|
||||
| Stress httpbin | Mixed-size concurrent requests (serialised per-origin) |
|
||||
|
||||
Runs twice — once per traffic configuration:
|
||||
|
||||
1. **No cover traffic, no Poisson** — baseline performance
|
||||
2. **With cover traffic + Poisson distribution** — realistic mixnet conditions
|
||||
|
||||
Pass criteria:
|
||||
- Smoke and HTTPS warm must pass
|
||||
- Stress httpbin >= 80% success rate
|
||||
|
||||
## Manual Headless Testing
|
||||
|
||||
Run the headless test runner directly in a browser without Playwright:
|
||||
|
||||
```bash
|
||||
cd wasm/smolmix/internal-dev && pnpm start
|
||||
```
|
||||
|
||||
Then open:
|
||||
- `http://localhost:9000/headless.html` — no cover, no Poisson (default)
|
||||
- `http://localhost:9000/headless.html?cover=true&poisson=true` — with cover + Poisson
|
||||
|
||||
URL parameters:
|
||||
|
||||
| Param | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `ipr` | hardcoded default | IPR exit node address |
|
||||
| `cover` | `false` | Enable cover traffic |
|
||||
| `poisson` | `false` | Enable Poisson dummy traffic |
|
||||
| `count` | `10` | Stress test request count |
|
||||
|
||||
## Timeouts
|
||||
|
||||
- Smoke: 3 minutes (tunnel setup ~10s, connectivity check ~20s)
|
||||
- Suite: 10 minutes per config (mixnet round-trips are ~1-2s each)
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Playwright Firefox hangs at IPR connect on Arch/Manjaro
|
||||
|
||||
Playwright ships a forked Firefox build (Mozilla's "Juggler" patches) to enable
|
||||
remote control. On Arch-family hosts (Manjaro confirmed) this bundled Firefox
|
||||
hangs indefinitely at the IPR connect handshake step, in both headed and
|
||||
headless modes. The bug is unique to the playwright Firefox build; the same
|
||||
URL loads fine in the system Firefox installation.
|
||||
|
||||
The smoke and suite tests reach `[ipr] sending connect handshake` and then
|
||||
stall until the playwright timeout fires. Topology fetches against
|
||||
`validator.nymtech.net` succeed; the gateway WSS connection or its message
|
||||
flow is where it dies. Adding `firefoxUserPrefs` for timer throttling, DoH,
|
||||
captive portal probes, and IndexedDB persistence does not help.
|
||||
|
||||
You cannot point `executablePath` at the system Firefox; playwright's Firefox
|
||||
binary must speak the Juggler protocol, which mainline Firefox does not.
|
||||
|
||||
**Workaround:** run chromium locally; skip firefox, or run it from a CI image
|
||||
whose playwright Firefox binary is built for that platform.
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=smoke-chromium
|
||||
```
|
||||
|
||||
### Playwright Webkit missing libraries on Arch/Manjaro
|
||||
|
||||
Playwright bundles `libwebkit2gtk` and a chain of GTK/glib/icu/freetype deps
|
||||
expecting Ubuntu library layouts. On Arch-family hosts those library versions
|
||||
or paths differ and webkit fails to launch. Same class of bug as the Firefox
|
||||
hang, different symptom.
|
||||
|
||||
**Workaround:** run webkit tests from a CI image (or container) with the
|
||||
Ubuntu-shaped library layout playwright expects.
|
||||
Generated
+1119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "smolmix-wasm-playwright-tests",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:smoke": "playwright test --project=smoke-chromium --project=smoke-firefox --project=smoke-webkit",
|
||||
"test:suite": "playwright test --project=suite-chromium --project=suite-firefox --project=suite-webkit",
|
||||
"test:chromium": "playwright test --project=smoke-chromium --project=suite-chromium",
|
||||
"test:firefox": "playwright test --project=smoke-firefox --project=suite-firefox",
|
||||
"test:webkit": "playwright test --project=smoke-webkit --project=suite-webkit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"serve": "^14.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Headless Firefox on non-Ubuntu hosts (Arch/Manjaro fallback build) needs
|
||||
// these prefs to behave like headed Firefox: keep IndexedDB persistent across
|
||||
// the test session, raise the wss:// open timeout above the 20 s default
|
||||
// (mixnet handshake + WSS upgrade can take longer on a cold load), and skip
|
||||
// the captive-portal probe that adds spurious DNS noise.
|
||||
const firefoxUserPrefs = {
|
||||
"dom.indexedDB.enabled": true,
|
||||
"dom.storage.next_gen": true,
|
||||
"browser.privatebrowsing.autostart": false,
|
||||
"network.websocket.timeout.open": 60,
|
||||
"network.captive-portal-service.enabled": false,
|
||||
"network.connectivity-service.enabled": false,
|
||||
// Force the OS resolver rather than Mozilla's DoH. Headless Firefox on
|
||||
// non-Ubuntu hosts sometimes can't reach mozilla.cloudflare-dns.com from
|
||||
// the bundled NSS context, which stalls gateway hostname resolution.
|
||||
"network.trr.mode": 0,
|
||||
"network.dns.disablePrefetch": true,
|
||||
// Headless Firefox has no visible window so it treats itself as backgrounded
|
||||
// and clamps timers (default 1 s minimum via dom.min_background_timeout_value).
|
||||
// Our reactor ticks every 5 ms and the Nym base client uses many short
|
||||
// timers in its WS keepalive/retry logic; clamping those to 1 s stalls the
|
||||
// gateway handshake. Disable every timer-throttling knob.
|
||||
"dom.min_background_timeout_value": 4,
|
||||
"dom.workers.timeoutThrottling": false,
|
||||
"dom.timeout.foreground_budget_regeneration_rate": -1,
|
||||
"dom.timeout.background_budget_regeneration_rate": -1,
|
||||
};
|
||||
|
||||
const firefoxUse = {
|
||||
browserName: "firefox",
|
||||
launchOptions: { firefoxUserPrefs },
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 180_000,
|
||||
retries: 1,
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: "npx serve ../internal-dev/dist -l 9001 --no-clipboard",
|
||||
port: 9001,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
projects: [
|
||||
// Smoke: all three browsers
|
||||
{
|
||||
name: "smoke-chromium",
|
||||
testMatch: "smoke.spec.mjs",
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
{
|
||||
name: "smoke-firefox",
|
||||
testMatch: "smoke.spec.mjs",
|
||||
use: firefoxUse,
|
||||
},
|
||||
{
|
||||
name: "smoke-webkit",
|
||||
testMatch: "smoke.spec.mjs",
|
||||
use: { browserName: "webkit" },
|
||||
},
|
||||
// Suite: all three browsers
|
||||
{
|
||||
name: "suite-chromium",
|
||||
testMatch: "suite.spec.mjs",
|
||||
timeout: 600_000,
|
||||
retries: 0,
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
{
|
||||
name: "suite-firefox",
|
||||
testMatch: "suite.spec.mjs",
|
||||
timeout: 600_000,
|
||||
retries: 0,
|
||||
use: firefoxUse,
|
||||
},
|
||||
{
|
||||
name: "suite-webkit",
|
||||
testMatch: "suite.spec.mjs",
|
||||
timeout: 600_000,
|
||||
retries: 0,
|
||||
use: { browserName: "webkit" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Smoke test: verify the internal-dev harness loads WASM and connects
|
||||
// to the mixnet via an IPR.
|
||||
//
|
||||
// Env: IPR_ADDRESS (optional). Without it, the page's pre-filled default
|
||||
// (internal-dev/index.html `#ipr-address` value attribute) is used.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const IPR_ADDRESS = process.env.IPR_ADDRESS;
|
||||
|
||||
function waitForConsole(page, predicate, timeoutMs = 120_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function handler(msg) {
|
||||
if (predicate(msg.text())) {
|
||||
clearTimeout(timer);
|
||||
page.removeListener("console", handler);
|
||||
resolve(msg.text());
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
page.removeListener("console", handler);
|
||||
reject(
|
||||
new Error(`Timed out waiting for console message (${timeoutMs}ms)`)
|
||||
);
|
||||
}, timeoutMs);
|
||||
page.on("console", handler);
|
||||
});
|
||||
}
|
||||
|
||||
test("WASM loads and tunnel connects to IPR", async ({ page }) => {
|
||||
const errors = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
const text = msg.text();
|
||||
if (text.startsWith("[")) {
|
||||
console.log(text);
|
||||
}
|
||||
if (msg.type() === "error" && !text.includes("favicon.ico")) {
|
||||
errors.push(text);
|
||||
console.log(`[ERROR] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
page.on("pageerror", (err) => {
|
||||
errors.push(`pageerror: ${err.message}`);
|
||||
});
|
||||
|
||||
await page.goto("http://localhost:9001");
|
||||
await page.waitForSelector("#btn-setup");
|
||||
|
||||
// The input is pre-filled with a working default; only override when the
|
||||
// caller passed an explicit IPR_ADDRESS env var.
|
||||
if (IPR_ADDRESS) {
|
||||
await page.fill("#ipr-address", IPR_ADDRESS);
|
||||
}
|
||||
|
||||
// Race: tunnel ready OR fatal error — whichever comes first.
|
||||
const tunnelReady = waitForConsole(
|
||||
page,
|
||||
(text) =>
|
||||
text.includes("setupMixTunnel OK") || text.includes("tunnel ready"),
|
||||
120_000
|
||||
);
|
||||
const fatalError = waitForConsole(
|
||||
page,
|
||||
(text) => text.includes("FATAL") || text.includes("tunnel error"),
|
||||
120_000
|
||||
);
|
||||
await page.click("#btn-setup");
|
||||
|
||||
const result = await Promise.race([
|
||||
tunnelReady.then((msg) => ({ ok: true, msg })),
|
||||
fatalError.then((msg) => ({ ok: false, msg })),
|
||||
]);
|
||||
expect(result.ok, `Tunnel setup failed: ${result.msg}`).toBeTruthy();
|
||||
|
||||
const hardErrors = errors.filter(
|
||||
(e) => !e.includes("favicon") && !e.includes("DevTools")
|
||||
);
|
||||
expect(hardErrors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// Full test suite: runs the headless test runner for both traffic configs.
|
||||
//
|
||||
// Each config gets its own page load (OnceLock prevents tunnel re-init).
|
||||
// The headless.js runner auto-executes: smoke, HTTPS warm, stress httpbin,
|
||||
// stress drip — then outputs RESULTS_JSON for parsing.
|
||||
//
|
||||
// Env: IPR_ADDRESS (optional, uses default if omitted)
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const BASE_URL = "http://localhost:9001/headless.html";
|
||||
const IPR_ADDRESS = process.env.IPR_ADDRESS;
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
name: "no cover, no Poisson",
|
||||
params: "count=5",
|
||||
},
|
||||
{
|
||||
name: "with cover + Poisson",
|
||||
params: "cover=true&poisson=true&count=5",
|
||||
},
|
||||
];
|
||||
|
||||
function waitForConsole(page, predicate, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function handler(msg) {
|
||||
if (predicate(msg.text())) {
|
||||
clearTimeout(timer);
|
||||
page.removeListener("console", handler);
|
||||
resolve(msg.text());
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
page.removeListener("console", handler);
|
||||
reject(
|
||||
new Error(`Timed out waiting for console message (${timeoutMs}ms)`)
|
||||
);
|
||||
}, timeoutMs);
|
||||
page.on("console", handler);
|
||||
});
|
||||
}
|
||||
|
||||
for (const cfg of CONFIGS) {
|
||||
test.describe(cfg.name, () => {
|
||||
test("full suite", async ({ page }) => {
|
||||
// Forward all logs to test output for debugging.
|
||||
page.on("console", (msg) => {
|
||||
const text = msg.text();
|
||||
if (
|
||||
text.startsWith("[") ||
|
||||
text.includes("===") ||
|
||||
text.includes("Config:") ||
|
||||
text.includes("FATAL") ||
|
||||
text.includes("RESULTS_JSON")
|
||||
) {
|
||||
console.log(text);
|
||||
}
|
||||
});
|
||||
|
||||
page.on("pageerror", (err) => {
|
||||
console.log(`[PAGEERROR] ${err.message}`);
|
||||
});
|
||||
|
||||
// Build URL with config params + optional IPR override.
|
||||
let url = `${BASE_URL}?${cfg.params}`;
|
||||
if (IPR_ADDRESS) {
|
||||
url += `&ipr=${encodeURIComponent(IPR_ADDRESS)}`;
|
||||
}
|
||||
|
||||
// Wait for the RESULTS_JSON console message (the suite auto-runs).
|
||||
const jsonPromise = waitForConsole(
|
||||
page,
|
||||
(text) => text.startsWith("RESULTS_JSON:"),
|
||||
540_000 // 9 minutes — generous for mixnet latency
|
||||
);
|
||||
|
||||
await page.goto(url);
|
||||
const resultLine = await jsonPromise;
|
||||
const json = JSON.parse(resultLine.replace("RESULTS_JSON:", ""));
|
||||
|
||||
// --- Timing summary ---
|
||||
console.log("");
|
||||
console.log("================================================================");
|
||||
console.log(` Config: ${cfg.name}`);
|
||||
console.log(` Date: ${json.date}`);
|
||||
console.log("================================================================");
|
||||
console.log("");
|
||||
console.log(
|
||||
` ${"Test".padEnd(28)}${"Result".padEnd(10)}${"Time".padEnd(10)}Details`
|
||||
);
|
||||
console.log(` ${"".padEnd(28, "-")}${"".padEnd(10, "-")}${"".padEnd(10, "-")}${"".padEnd(20, "-")}`);
|
||||
|
||||
for (const r of json.results) {
|
||||
let resultStr =
|
||||
r.total !== undefined ? `${r.okCount}/${r.total}` : r.ok ? "PASS" : "FAIL";
|
||||
let timeStr = r.ms ? `${(r.ms / 1000).toFixed(2)}s` : "N/A";
|
||||
let details = "";
|
||||
if (r.avgMs !== undefined) {
|
||||
details = `avg ${(r.avgMs / 1000).toFixed(2)}s/req`;
|
||||
}
|
||||
if (r.error) {
|
||||
details = r.error.slice(0, 60);
|
||||
}
|
||||
console.log(
|
||||
` ${r.name.padEnd(28)}${resultStr.padEnd(10)}${timeStr.padEnd(10)}${details}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("================================================================");
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
// Check for fatal setup errors first.
|
||||
const fatal = json.results.find((r) => r.error && !r.ok && r.ms === 0);
|
||||
if (fatal) {
|
||||
expect.soft(false, `Fatal error: ${fatal.error}`).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Smoke must pass.
|
||||
const smoke = json.results.find((r) => r.name.includes("Smoke"));
|
||||
expect(smoke, "Smoke test result should exist").toBeTruthy();
|
||||
expect(smoke.ok, "Smoke test (cold HTTPS GET) should pass").toBeTruthy();
|
||||
|
||||
// HTTPS warm must pass.
|
||||
const warm = json.results.find((r) => r.name.includes("warm"));
|
||||
expect(warm, "HTTPS warm result should exist").toBeTruthy();
|
||||
expect(warm.ok, "HTTPS GET (warm) should pass").toBeTruthy();
|
||||
|
||||
// Warm should be significantly faster than cold.
|
||||
if (smoke.ok && warm.ok) {
|
||||
console.log(
|
||||
` Cold vs warm: ${(smoke.ms / 1000).toFixed(1)}s -> ${(warm.ms / 1000).toFixed(1)}s ` +
|
||||
`(${(smoke.ms / warm.ms).toFixed(1)}x speedup from connection pooling)`
|
||||
);
|
||||
}
|
||||
|
||||
// Stress httpbin: >= 80% success.
|
||||
const stress = json.results.find((r) => r.name.includes("httpbin"));
|
||||
if (stress?.total) {
|
||||
const rate = stress.okCount / stress.total;
|
||||
console.log(
|
||||
` Stress httpbin success rate: ${(rate * 100).toFixed(0)}%`
|
||||
);
|
||||
expect(rate).toBeGreaterThanOrEqual(0.8);
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user