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:
mfahampshire
2026-05-28 15:57:10 +00:00
committed by GitHub
parent f28b1e2077
commit 43a1bd38e8
57 changed files with 10844 additions and 641 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
Generated
+154 -64
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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
+3 -2
View File
@@ -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 }
+21 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }
+1 -1
View File
@@ -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 }
+65
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 }
+13 -556
View File
@@ -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
+2 -2
View File
@@ -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 = []
+2 -2
View File
@@ -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 }
+6
View File
@@ -0,0 +1,6 @@
[build]
target = "wasm32-unknown-unknown"
rustflags = ["--cfg=getrandom_backend=\"wasm_js\""]
[target.wasm32-unknown-unknown]
runner = 'wasm-bindgen-test-runner'
+1
View File
@@ -0,0 +1 @@
connection-notes.md
+118
View File
@@ -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
+50
View File
@@ -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
+192
View File
@@ -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.
+13
View File
@@ -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
View File
@@ -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)
);
+11
View File
@@ -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>
+316
View File
@@ -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();
+239
View File
@@ -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>&ndash;</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>
+858
View File
@@ -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.",
);
+230
View File
@@ -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;
}
}
}
}
+19
View File
@@ -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"
}
}
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',
};
+156
View File
@@ -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" });
+153
View File
@@ -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, &notify_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, &notify_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();
}
}
+162
View File
@@ -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);
}
}
+210
View File
@@ -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, &current_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}"
)))
}
+42
View File
@@ -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);
+372
View File
@@ -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)));
}
+237
View File
@@ -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,
))
}
+314
View File
@@ -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)
}
+280
View File
@@ -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)
}
+21
View File
@@ -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()))
})
}
+18
View File
@@ -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)
})
}
+286
View File
@@ -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);
}
}
+202
View File
@@ -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",
);
}
+105
View File
@@ -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();
}
}
+452
View File
@@ -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)
}
+164
View File
@@ -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())
}
+623
View File
@@ -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);
}
}
+24
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
test-results/
playwright-report/
blob-report/
+130
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+88
View File
@@ -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" },
},
],
});
+81
View File
@@ -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([]);
});
+152
View File
@@ -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);
}
});
});
}