From 43a1bd38e81cec6652f1e6bec0097880d600364d Mon Sep 17 00:00:00 2001 From: mfahampshire Date: Thu, 28 May 2026 15:57:10 +0000 Subject: [PATCH] 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 --- .cargo/config.toml | 2 + Cargo.lock | 218 +- Cargo.toml | 21 +- Makefile | 2 + common/ip-packet-requests/Cargo.toml | 5 +- common/ip-packet-requests/src/codec.rs | 22 +- .../src/response_helpers.rs | 3 +- common/nym-connection-monitor/Cargo.toml | 2 +- common/registration/Cargo.toml | 2 +- common/wasm/storage/Cargo.toml | 2 +- common/wasm/utils/src/lib.rs | 65 + nym-gateway-probe/Cargo.toml | 2 +- nym-node/Cargo.toml | 4 +- pnpm-lock.yaml | 569 +-- sdk/rust/nym-sdk/Cargo.toml | 4 +- smolmix/core/Cargo.toml | 4 +- wasm/smolmix/.cargo/config.toml | 6 + wasm/smolmix/.gitignore | 1 + wasm/smolmix/Cargo.toml | 118 + wasm/smolmix/Makefile | 50 + wasm/smolmix/README.md | 192 ++ wasm/smolmix/internal-dev/.npmrc | 13 + wasm/smolmix/internal-dev/bootstrap.js | 4 + .../internal-dev/headless-bootstrap.js | 4 + wasm/smolmix/internal-dev/headless.html | 11 + wasm/smolmix/internal-dev/headless.js | 316 ++ wasm/smolmix/internal-dev/index.html | 239 ++ wasm/smolmix/internal-dev/index.js | 858 +++++ wasm/smolmix/internal-dev/mix-socket.js | 230 ++ wasm/smolmix/internal-dev/package.json | 19 + wasm/smolmix/internal-dev/pnpm-lock.yaml | 3051 +++++++++++++++++ wasm/smolmix/internal-dev/webpack.config.js | 35 + wasm/smolmix/internal-dev/worker.js | 156 + wasm/smolmix/src/bridge.rs | 153 + wasm/smolmix/src/device.rs | 162 + wasm/smolmix/src/dns.rs | 210 ++ wasm/smolmix/src/error.rs | 42 + wasm/smolmix/src/fetch.rs | 372 ++ wasm/smolmix/src/http.rs | 237 ++ wasm/smolmix/src/ipr.rs | 314 ++ wasm/smolmix/src/lib.rs | 280 ++ wasm/smolmix/src/mixdns.rs | 21 + wasm/smolmix/src/mixfetch.rs | 18 + wasm/smolmix/src/mixsocket.rs | 286 ++ wasm/smolmix/src/reactor.rs | 202 ++ wasm/smolmix/src/state.rs | 105 + wasm/smolmix/src/stream.rs | 452 +++ wasm/smolmix/src/tls.rs | 164 + wasm/smolmix/src/tunnel.rs | 623 ++++ wasm/smolmix/src/util.rs | 24 + wasm/smolmix/tests/.gitignore | 4 + wasm/smolmix/tests/README.md | 130 + wasm/smolmix/tests/package-lock.json | 1119 ++++++ wasm/smolmix/tests/package.json | 16 + wasm/smolmix/tests/playwright.config.mjs | 88 + wasm/smolmix/tests/tests/smoke.spec.mjs | 81 + wasm/smolmix/tests/tests/suite.spec.mjs | 152 + 57 files changed, 10844 insertions(+), 641 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 wasm/smolmix/.cargo/config.toml create mode 100644 wasm/smolmix/.gitignore create mode 100644 wasm/smolmix/Cargo.toml create mode 100644 wasm/smolmix/Makefile create mode 100644 wasm/smolmix/README.md create mode 100644 wasm/smolmix/internal-dev/.npmrc create mode 100644 wasm/smolmix/internal-dev/bootstrap.js create mode 100644 wasm/smolmix/internal-dev/headless-bootstrap.js create mode 100644 wasm/smolmix/internal-dev/headless.html create mode 100644 wasm/smolmix/internal-dev/headless.js create mode 100644 wasm/smolmix/internal-dev/index.html create mode 100644 wasm/smolmix/internal-dev/index.js create mode 100644 wasm/smolmix/internal-dev/mix-socket.js create mode 100644 wasm/smolmix/internal-dev/package.json create mode 100644 wasm/smolmix/internal-dev/pnpm-lock.yaml create mode 100644 wasm/smolmix/internal-dev/webpack.config.js create mode 100644 wasm/smolmix/internal-dev/worker.js create mode 100644 wasm/smolmix/src/bridge.rs create mode 100644 wasm/smolmix/src/device.rs create mode 100644 wasm/smolmix/src/dns.rs create mode 100644 wasm/smolmix/src/error.rs create mode 100644 wasm/smolmix/src/fetch.rs create mode 100644 wasm/smolmix/src/http.rs create mode 100644 wasm/smolmix/src/ipr.rs create mode 100644 wasm/smolmix/src/lib.rs create mode 100644 wasm/smolmix/src/mixdns.rs create mode 100644 wasm/smolmix/src/mixfetch.rs create mode 100644 wasm/smolmix/src/mixsocket.rs create mode 100644 wasm/smolmix/src/reactor.rs create mode 100644 wasm/smolmix/src/state.rs create mode 100644 wasm/smolmix/src/stream.rs create mode 100644 wasm/smolmix/src/tls.rs create mode 100644 wasm/smolmix/src/tunnel.rs create mode 100644 wasm/smolmix/src/util.rs create mode 100644 wasm/smolmix/tests/.gitignore create mode 100644 wasm/smolmix/tests/README.md create mode 100644 wasm/smolmix/tests/package-lock.json create mode 100644 wasm/smolmix/tests/package.json create mode 100644 wasm/smolmix/tests/playwright.config.mjs create mode 100644 wasm/smolmix/tests/tests/smoke.spec.mjs create mode 100644 wasm/smolmix/tests/tests/suite.spec.mjs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000000..861cc80f86 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ["--cfg=getrandom_backend=\"wasm_js\""] diff --git a/Cargo.lock b/Cargo.lock index d3e0c6b5df..c606bed85c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index e17ffb790a..72e8d59a1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" - diff --git a/Makefile b/Makefile index 3ed8a5babc..faef4a4473 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/common/ip-packet-requests/Cargo.toml b/common/ip-packet-requests/Cargo.toml index bd95393c99..a45477a916 100644 --- a/common/ip-packet-requests/Cargo.toml +++ b/common/ip-packet-requests/Cargo.toml @@ -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 } diff --git a/common/ip-packet-requests/src/codec.rs b/common/ip-packet-requests/src/codec.rs index 8bd297de8c..a7c5fc915a 100644 --- a/common/ip-packet-requests/src/codec.rs +++ b/common/ip-packet-requests/src/codec.rs @@ -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, 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> for IprPacket { } } +#[cfg(feature = "codec")] impl Encoder for MultiIpPacketCodec { type Error = Error; @@ -125,6 +144,7 @@ impl Encoder 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::*; diff --git a/common/ip-packet-requests/src/response_helpers.rs b/common/ip-packet-requests/src/response_helpers.rs index 5caa81ab0d..29c7375026 100644 --- a/common/ip-packet-requests/src/response_helpers.rs +++ b/common/ip-packet-requests/src/response_helpers.rs @@ -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 { 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) => { diff --git a/common/nym-connection-monitor/Cargo.toml b/common/nym-connection-monitor/Cargo.toml index c7ce2c445e..f6dd76954e 100644 --- a/common/nym-connection-monitor/Cargo.toml +++ b/common/nym-connection-monitor/Cargo.toml @@ -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 diff --git a/common/registration/Cargo.toml b/common/registration/Cargo.toml index 75f882a4f5..910f7059db 100644 --- a/common/registration/Cargo.toml +++ b/common/registration/Cargo.toml @@ -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 } diff --git a/common/wasm/storage/Cargo.toml b/common/wasm/storage/Cargo.toml index 2e4f4430c4..d9d2fb1188 100644 --- a/common/wasm/storage/Cargo.toml +++ b/common/wasm/storage/Cargo.toml @@ -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 } diff --git a/common/wasm/utils/src/lib.rs b/common/wasm/utils/src/lib.rs index 60f7a53052..d16ffe6e90 100644 --- a/common/wasm/utils/src/lib.rs +++ b/common/wasm/utils/src/lib.rs @@ -1,6 +1,7 @@ // Copyright 2021 - Nym Technologies SA // 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::>() + .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 diff --git a/nym-gateway-probe/Cargo.toml b/nym-gateway-probe/Cargo.toml index 18be3c9577..e50f61cf9d 100644 --- a/nym-gateway-probe/Cargo.toml +++ b/nym-gateway-probe/Cargo.toml @@ -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 diff --git a/nym-node/Cargo.toml b/nym-node/Cargo.toml index ca56cec4bf..c57cdcc278 100644 --- a/nym-node/Cargo.toml +++ b/nym-node/Cargo.toml @@ -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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 995a3591b3..2460be7d41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/sdk/rust/nym-sdk/Cargo.toml b/sdk/rust/nym-sdk/Cargo.toml index 1391873b51..fd691d0b9f 100644 --- a/sdk/rust/nym-sdk/Cargo.toml +++ b/sdk/rust/nym-sdk/Cargo.toml @@ -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 = [] diff --git a/smolmix/core/Cargo.toml b/smolmix/core/Cargo.toml index b8f18e2ff1..9cdd275474 100644 --- a/smolmix/core/Cargo.toml +++ b/smolmix/core/Cargo.toml @@ -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 } diff --git a/wasm/smolmix/.cargo/config.toml b/wasm/smolmix/.cargo/config.toml new file mode 100644 index 0000000000..ed9cba9707 --- /dev/null +++ b/wasm/smolmix/.cargo/config.toml @@ -0,0 +1,6 @@ +[build] +target = "wasm32-unknown-unknown" +rustflags = ["--cfg=getrandom_backend=\"wasm_js\""] + +[target.wasm32-unknown-unknown] +runner = 'wasm-bindgen-test-runner' diff --git a/wasm/smolmix/.gitignore b/wasm/smolmix/.gitignore new file mode 100644 index 0000000000..787c5dd1e9 --- /dev/null +++ b/wasm/smolmix/.gitignore @@ -0,0 +1 @@ +connection-notes.md diff --git a/wasm/smolmix/Cargo.toml b/wasm/smolmix/Cargo.toml new file mode 100644 index 0000000000..4042f32fe0 --- /dev/null +++ b/wasm/smolmix/Cargo.toml @@ -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 diff --git a/wasm/smolmix/Makefile b/wasm/smolmix/Makefile new file mode 100644 index 0000000000..0bd2416456 --- /dev/null +++ b/wasm/smolmix/Makefile @@ -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 diff --git a/wasm/smolmix/README.md b/wasm/smolmix/README.md new file mode 100644 index 0000000000..5c060822a1 --- /dev/null +++ b/wasm/smolmix/README.md @@ -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. diff --git a/wasm/smolmix/internal-dev/.npmrc b/wasm/smolmix/internal-dev/.npmrc new file mode 100644 index 0000000000..f60243936e --- /dev/null +++ b/wasm/smolmix/internal-dev/.npmrc @@ -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 + + +

+  
+
diff --git a/wasm/smolmix/internal-dev/headless.js b/wasm/smolmix/internal-dev/headless.js
new file mode 100644
index 0000000000..89cb04bbc2
--- /dev/null
+++ b/wasm/smolmix/internal-dev/headless.js
@@ -0,0 +1,316 @@
+// smolmix-wasm headless test runner
+//
+// Auto-runs a battery of tests on page load. Config via URL params:
+//
+//   ?ipr=
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(); diff --git a/wasm/smolmix/internal-dev/index.html b/wasm/smolmix/internal-dev/index.html new file mode 100644 index 0000000000..a9181b980d --- /dev/null +++ b/wasm/smolmix/internal-dev/index.html @@ -0,0 +1,239 @@ + + + + + + smolmix-wasm dev + + + + + +

smolmix-wasm dev

+ + +
+ Connection +
+ + + +
+
+ Advanced Options +
+
+ +
+
+ + (randomised on load for clean state) +
+
+ +
+
+ +
+
+ + +
+ Optional. Larger pools raise inbound throughput; each outgoing Sphinx packet then carries more reply blocks. +
+
+
+ + +
+ Optional. `host:port`. Leave blank to use the defaults shown. +
+
+
+
+
+ + + + Not started +
+
+ + +
+ DNS Resolve +
+ + + +
+

+    
+ + +
+ GET +
+ + + +
+

+    
+ + +
+ WebSocket +
+ + + + Not connected +
+
+ + +
+
+ + + + + + + bytes + +
+

+    
+ + +
+ Stress Test +
+ + + + +
+ + + +
+ + + + + + +
tiny128 B
small1 KB
medium10 KB
large100 KB
xlarge1 MB
+
+ + + +
+ + +
+

+    
+ + +
+ File Download +
+
+ UTF-8 Demo +
Unicode text (Cambridge CS)
+ + + +
+
+ File Download +
+ +
+ + + + +
+
+
+ + +
+

+    
+ + +
+ Output (master timeline) +

+    
+ + diff --git a/wasm/smolmix/internal-dev/index.js b/wasm/smolmix/internal-dev/index.js new file mode 100644 index 0000000000..97474b34dd --- /dev/null +++ b/wasm/smolmix/internal-dev/index.js @@ -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.", +); diff --git a/wasm/smolmix/internal-dev/mix-socket.js b/wasm/smolmix/internal-dev/mix-socket.js new file mode 100644 index 0000000000..b91fca6f16 --- /dev/null +++ b/wasm/smolmix/internal-dev/mix-socket.js @@ -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; + } + } + } +} diff --git a/wasm/smolmix/internal-dev/package.json b/wasm/smolmix/internal-dev/package.json new file mode 100644 index 0000000000..65498313e9 --- /dev/null +++ b/wasm/smolmix/internal-dev/package.json @@ -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" + } +} diff --git a/wasm/smolmix/internal-dev/pnpm-lock.yaml b/wasm/smolmix/internal-dev/pnpm-lock.yaml new file mode 100644 index 0000000000..901ddbfd27 --- /dev/null +++ b/wasm/smolmix/internal-dev/pnpm-lock.yaml @@ -0,0 +1,3051 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + comlink: + specifier: ^4.3.1 + version: 4.4.2 + smolmix-wasm: + specifier: file:../pkg + version: '@nymproject/smolmix-wasm@file:../pkg' + devDependencies: + copy-webpack-plugin: + specifier: ^11.0.0 + version: 11.0.0(webpack@5.107.2) + webpack: + specifier: ^5.98.0 + version: 5.107.2(webpack-cli@5.1.4) + webpack-cli: + specifier: ^5.1.4 + version: 5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2) + webpack-dev-server: + specifier: ^5.2.1 + version: 5.2.4(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.107.2) + +packages: + + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.57.2': + resolution: {integrity: sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.57.2': + resolution: {integrity: sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.57.2': + resolution: {integrity: sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.57.2': + resolution: {integrity: sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.57.2': + resolution: {integrity: sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.57.2': + resolution: {integrity: sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.57.2': + resolution: {integrity: sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.57.2': + resolution: {integrity: sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nymproject/smolmix-wasm@file:../pkg': + resolution: {directory: ../pkg, type: directory} + + '@peculiar/asn1-cms@2.7.0': + resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==} + + '@peculiar/asn1-csr@2.7.0': + resolution: {integrity: sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==} + + '@peculiar/asn1-ecc@2.7.0': + resolution: {integrity: sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==} + + '@peculiar/asn1-pfx@2.7.0': + resolution: {integrity: sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==} + + '@peculiar/asn1-pkcs8@2.7.0': + resolution: {integrity: sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==} + + '@peculiar/asn1-pkcs9@2.7.0': + resolution: {integrity: sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==} + + '@peculiar/asn1-rsa@2.7.0': + resolution: {integrity: sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==} + + '@peculiar/asn1-schema@2.7.0': + resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==} + + '@peculiar/asn1-x509-attr@2.7.0': + resolution: {integrity: sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==} + + '@peculiar/asn1-x509@2.7.0': + resolution: {integrity: sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/bonjour@3.5.13': + resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + + '@types/connect-history-api-fallback@1.5.4': + resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/http-proxy@1.17.17': + resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/retry@0.12.2': + resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-index@1.9.4': + resolution: {integrity: sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/sockjs@0.3.36': + resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@webpack-cli/configtest@2.1.1': + resolution: {integrity: sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/info@2.0.2': + resolution: {integrity: sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/serve@2.0.5': + resolution: {integrity: sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-html-community@0.0.8: + resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} + engines: {'0': node >= 0.8.0} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + batch@0.6.1: + resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bonjour-service@1.4.0: + resolution: {integrity: sha512-fGQtj1qdR9vIKjFiWPQd52qIqwjaYqhcI40JEiDuvlZ86E7ZBPBwY9fPgHy9r2rYGIjiRfctNPYz6OQU73ww2w==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + comlink@4.4.2: + resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + connect-history-api-fallback@2.0.0: + resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} + engines: {node: '>=0.8'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-webpack-plugin@11.0.0: + resolution: {integrity: sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==} + engines: {node: '>= 14.15.0'} + peerDependencies: + webpack: ^5.1.0 + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dns-packet@5.6.1: + resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} + engines: {node: '>=6'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.362: + resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + engines: {node: '>=10.13.0'} + + envinfo@7.21.0: + resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} + engines: {node: '>=4'} + hasBin: true + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handle-thing@2.0.1: + resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hpack.js@2.1.6: + resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + + http-deceiver@1.2.7: + resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-middleware@2.0.9: + resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/express': ^4.17.13 + peerDependenciesMeta: + '@types/express': + optional: true + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + launch-editor@2.13.2: + resolution: {integrity: sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@4.57.2: + resolution: {integrity: sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==} + peerDependencies: + tslib: '2' + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multicast-dns@7.2.5: + resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-retry@6.2.1: + resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} + engines: {node: '>=16.17'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + select-hose@2.0.0: + resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} + + selfsigned@5.5.0: + resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==} + engines: {node: '>=18'} + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-index@1.9.2: + resolution: {integrity: sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + sockjs@0.3.24: + resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spdy-transport@3.0.0: + resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} + + spdy@4.0.2: + resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} + engines: {node: '>=6.0.0'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.6.0: + resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + thingies@2.6.0: + resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wbuf@1.7.3: + resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} + + webpack-cli@5.1.4: + resolution: {integrity: sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==} + engines: {node: '>=14.15.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + webpack: 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + + webpack-dev-middleware@7.4.5: + resolution: {integrity: sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.0.0 + peerDependenciesMeta: + webpack: + optional: true + + webpack-dev-server@5.2.4: + resolution: {integrity: sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==} + engines: {node: '>= 18.12.0'} + hasBin: true + peerDependencies: + webpack: ^5.0.0 + webpack-cli: '*' + peerDependenciesMeta: + webpack: + optional: true + webpack-cli: + optional: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack@5.107.2: + resolution: {integrity: sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + +snapshots: + + '@discoveryjs/json-ext@0.5.7': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.57.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@leichtgewicht/ip-codec@2.0.5': {} + + '@noble/hashes@1.4.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nymproject/smolmix-wasm@file:../pkg': {} + + '@peculiar/asn1-cms@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/asn1-x509-attr': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.7.0': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-pkcs8': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.7.0': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-pfx': 2.7.0 + '@peculiar/asn1-pkcs8': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/asn1-x509-attr': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.7.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-csr': 2.7.0 + '@peculiar/asn1-ecc': 2.7.0 + '@peculiar/asn1-pkcs9': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.9.1 + + '@types/bonjour@3.5.13': + dependencies: + '@types/node': 25.9.1 + + '@types/connect-history-api-fallback@1.5.4': + dependencies: + '@types/express-serve-static-core': 4.19.8 + '@types/node': 25.9.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.9.1 + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 25.9.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/http-proxy@1.17.17': + dependencies: + '@types/node': 25.9.1 + + '@types/json-schema@7.0.15': {} + + '@types/mime@1.3.5': {} + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/retry@0.12.2': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 25.9.1 + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.9.1 + + '@types/serve-index@1.9.4': + dependencies: + '@types/express': 4.17.25 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.9.1 + '@types/send': 0.17.6 + + '@types/sockjs@0.3.36': + dependencies: + '@types/node': 25.9.1 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.1 + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.107.2)': + dependencies: + webpack: 5.107.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2) + + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.107.2)': + dependencies: + webpack: 5.107.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2) + + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.2.4)(webpack@5.107.2)': + dependencies: + webpack: 5.107.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2) + optionalDependencies: + webpack-dev-server: 5.2.4(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.107.2) + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-import-phases@1.0.4(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-html-community@0.0.8: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + array-flatten@1.1.1: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + baseline-browser-mapping@2.10.32: {} + + batch@0.6.1: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bonjour-service@1.4.0: + dependencies: + fast-deep-equal: 3.1.3 + multicast-dns: 7.2.5 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.362 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-from@1.1.2: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + bytestreamjs@2.0.1: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001793: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chrome-trace-event@1.0.4: {} + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + colorette@2.0.20: {} + + comlink@4.4.2: {} + + commander@10.0.1: {} + + commander@2.20.3: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + connect-history-api-fallback@2.0.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + copy-webpack-plugin@11.0.0(webpack@5.107.2): + dependencies: + fast-glob: 3.3.3 + glob-parent: 6.0.2 + globby: 13.2.2 + normalize-path: 3.0.0 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + webpack: 5.107.2(webpack-cli@5.1.4) + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + depd@1.1.2: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-node@2.1.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dns-packet@5.6.1: + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.362: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.22.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + envinfo@7.21.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + etag@1.8.1: {} + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.2: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + flat@5.0.2: {} + + follow-redirects@1.16.0: {} + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + glob-to-regexp@0.4.1: {} + + globby@13.2.2: + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 4.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handle-thing@2.0.1: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hpack.js@2.1.6: + dependencies: + inherits: 2.0.4 + obuf: 1.1.2 + readable-stream: 2.3.8 + wbuf: 1.7.3 + + http-deceiver@1.2.7: {} + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-parser-js@0.5.10: {} + + http-proxy-middleware@2.0.9(@types/express@4.17.25): + dependencies: + '@types/http-proxy': 1.17.17 + http-proxy: 1.18.1 + is-glob: 4.0.3 + is-plain-obj: 3.0.0 + micromatch: 4.0.8 + optionalDependencies: + '@types/express': 4.17.25 + transitivePeerDependencies: + - debug + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.16.0 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + hyperdyperid@1.2.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + inherits@2.0.4: {} + + interpret@3.1.1: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.4.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-network-error@1.3.2: {} + + is-number@7.0.0: {} + + is-plain-obj@3.0.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + jest-worker@27.5.1: + dependencies: + '@types/node': 25.9.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + json-schema-traverse@1.0.0: {} + + kind-of@6.0.3: {} + + launch-editor@2.13.2: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.8.4 + + loader-runner@4.3.2: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + memfs@4.57.2(tslib@2.8.1): + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + minimalistic-assert@1.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multicast-dns@7.2.5: + dependencies: + dns-packet: 5.6.1 + thunky: 1.1.0 + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + neo-async@2.6.2: {} + + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + + object-inspect@1.13.4: {} + + obuf@1.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-retry@6.2.1: + dependencies: + '@types/retry': 0.12.2 + is-network-error: 1.3.2 + retry: 0.13.1 + + p-try@2.2.0: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.13: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + process-nextick-args@2.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + reflect-metadata@0.2.2: {} + + require-from-string@2.0.2: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retry@0.13.1: {} + + reusify@1.1.0: {} + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + select-hose@2.0.0: {} + + selfsigned@5.5.0: + dependencies: + '@peculiar/x509': 1.14.3 + pkijs: 3.4.0 + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-index@1.9.2: + dependencies: + accepts: 1.3.8 + batch: 0.6.1 + debug: 2.6.9 + escape-html: 1.0.3 + http-errors: 1.8.1 + mime-types: 2.1.35 + parseurl: 1.3.3 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + slash@4.0.0: {} + + sockjs@0.3.24: + dependencies: + faye-websocket: 0.11.4 + uuid: 8.3.2 + websocket-driver: 0.7.4 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + spdy-transport@3.0.0: + dependencies: + debug: 4.4.3 + detect-node: 2.1.0 + hpack.js: 2.1.6 + obuf: 1.1.2 + readable-stream: 3.6.2 + wbuf: 1.7.3 + transitivePeerDependencies: + - supports-color + + spdy@4.0.2: + dependencies: + debug: 4.4.3 + handle-thing: 2.0.1 + http-deceiver: 1.2.7 + select-hose: 2.0.0 + spdy-transport: 3.0.0 + transitivePeerDependencies: + - supports-color + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tapable@2.3.3: {} + + terser-webpack-plugin@5.6.0(webpack@5.107.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.107.2(webpack-cli@5.1.4) + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thingies@2.6.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + thunky@1.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + undici-types@7.24.6: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@8.3.2: {} + + vary@1.1.2: {} + + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wbuf@1.7.3: + dependencies: + minimalistic-assert: 1.0.1 + + webpack-cli@5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2): + dependencies: + '@discoveryjs/json-ext': 0.5.7 + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.107.2) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.107.2) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.2.4)(webpack@5.107.2) + colorette: 2.0.20 + commander: 10.0.1 + cross-spawn: 7.0.6 + envinfo: 7.21.0 + fastest-levenshtein: 1.0.16 + import-local: 3.2.0 + interpret: 3.1.1 + rechoir: 0.8.0 + webpack: 5.107.2(webpack-cli@5.1.4) + webpack-merge: 5.10.0 + optionalDependencies: + webpack-dev-server: 5.2.4(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.107.2) + + webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.107.2): + dependencies: + colorette: 2.0.20 + memfs: 4.57.2(tslib@2.8.1) + mime-types: 3.0.2 + on-finished: 2.4.1 + range-parser: 1.2.1 + schema-utils: 4.3.3 + optionalDependencies: + webpack: 5.107.2(webpack-cli@5.1.4) + transitivePeerDependencies: + - tslib + + webpack-dev-server@5.2.4(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.107.2): + dependencies: + '@types/bonjour': 3.5.13 + '@types/connect-history-api-fallback': 1.5.4 + '@types/express': 4.17.25 + '@types/express-serve-static-core': 4.19.8 + '@types/serve-index': 1.9.4 + '@types/serve-static': 1.15.10 + '@types/sockjs': 0.3.36 + '@types/ws': 8.18.1 + ansi-html-community: 0.0.8 + bonjour-service: 1.4.0 + chokidar: 3.6.0 + colorette: 2.0.20 + compression: 1.8.1 + connect-history-api-fallback: 2.0.0 + express: 4.22.2 + graceful-fs: 4.2.11 + http-proxy-middleware: 2.0.9(@types/express@4.17.25) + ipaddr.js: 2.4.0 + launch-editor: 2.13.2 + open: 10.2.0 + p-retry: 6.2.1 + schema-utils: 4.3.3 + selfsigned: 5.5.0 + serve-index: 1.9.2 + sockjs: 0.3.24 + spdy: 4.0.2 + webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.107.2) + ws: 8.21.0 + optionalDependencies: + webpack: 5.107.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2) + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - tslib + - utf-8-validate + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-sources@3.5.0: {} + + webpack@5.107.2(webpack-cli@5.1.4): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.22.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.0(webpack@5.107.2) + watchpack: 2.5.1 + webpack-sources: 3.5.0 + optionalDependencies: + webpack-cli: 5.1.4(webpack-dev-server@5.2.4)(webpack@5.107.2) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wildcard@2.0.1: {} + + ws@8.21.0: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 diff --git a/wasm/smolmix/internal-dev/webpack.config.js b/wasm/smolmix/internal-dev/webpack.config.js new file mode 100644 index 0000000000..f7552c5091 --- /dev/null +++ b/wasm/smolmix/internal-dev/webpack.config.js @@ -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', +}; diff --git a/wasm/smolmix/internal-dev/worker.js b/wasm/smolmix/internal-dev/worker.js new file mode 100644 index 0000000000..e3f5a673aa --- /dev/null +++ b/wasm/smolmix/internal-dev/worker.js @@ -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" }); diff --git a/wasm/smolmix/src/bridge.rs b/wasm/smolmix/src/bridge.rs new file mode 100644 index 0000000000..6b89796a96 --- /dev/null +++ b/wasm/smolmix/src/bridge.rs @@ -0,0 +1,153 @@ +// Copyright 2026 - Nym Technologies SA +// 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, + mut msg_receiver: ReconstructedReceiver, + ipr_address: Recipient, + stream_id: u64, + notify_reactor: ReactorNotify, + tracker: &ShutdownTracker, + state: State, + data_surbs: u32, +) { + // Cloned so finalise_task can check is_cancelled() on the way out. + let token = tracker.clone_shutdown_token(); + tracker.try_spawn_named_with_shutdown( + async move { + let mut tx_interval = wasmtimer::tokio::interval(Duration::from_millis(5)); + // Outbound seq starts at 1; seq=0 is reserved for handshake + // frames (see `ipr::open_and_connect`). + let mut seq: u32 = 1; + + loop { + // Block until something happens (incoming message or timer tick). + // `futures::select!` polls pseudo-randomly when both branches are + // ready, so textual order is not a priority guarantee. + // TODO: consider `futures::select_biased!` if a sustained timer + // burst ever shows up as incoming-side latency. + futures::select! { + batch = msg_receiver.next().fuse() => { + let Some(messages) = batch else { + crate::util::debug_error!( + "[bridge] message channel closed" + ); + break; + }; + process_incoming( + &stack, &messages, stream_id, ¬ify_reactor, + ); + } + _ = tx_interval.tick().fuse() => {} + } + + // Non-blockingly drain any remaining incoming messages so we + // never let them queue up while we're sending outgoing packets. + while let Some(Some(messages)) = msg_receiver.next().now_or_never() { + process_incoming(&stack, &messages, stream_id, ¬ify_reactor); + } + + // Drain outgoing packets (capped to avoid starving incoming). + let packets: Vec> = + 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(); + } +} diff --git a/wasm/smolmix/src/device.rs b/wasm/smolmix/src/device.rs new file mode 100644 index 0000000000..ea8dfc80ca --- /dev/null +++ b/wasm/smolmix/src/device.rs @@ -0,0 +1,162 @@ +// Copyright 2026 - Nym Technologies SA +// 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>, + tx_queue: VecDeque>, + 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) { + 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> + '_ { + 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> { + 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, +} + +impl RxToken for WasmRxToken { + fn consume(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>, +} + +impl<'a> TxToken for WasmTxToken<'a> { + fn consume(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); + } +} diff --git a/wasm/smolmix/src/dns.rs b/wasm/smolmix/src/dns.rs new file mode 100644 index 0000000000..bb13b17999 --- /dev/null +++ b/wasm/smolmix/src/dns.rs @@ -0,0 +1,210 @@ +// Copyright 2026 - Nym Technologies SA +// 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 { + if let Ok(ip) = hostname.parse::() { + 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 { + 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 { + let mut current_name = hostname.to_string(); + + for _ in 0..MAX_CNAME_HOPS { + match query_record(udp, ¤t_name, record_type, server, timeout).await? { + DnsResult::Ip(ip) => return Ok(ip), + DnsResult::Cname(target) => current_name = target, + } + } + + Err(FetchError::Dns(format!( + "CNAME chain too long (>{MAX_CNAME_HOPS} hops) for {hostname}" + ))) +} + +enum DnsResult { + Ip(IpAddr), + Cname(String), +} + +/// Send a single DNS query and parse the response. +/// +/// The `WasmUdpSocket` is shared across PRIMARY → FALLBACK, A → AAAA, and +/// every CNAME hop in one resolve, so leftover datagrams from a prior query +/// can be sitting in the receive buffer. We loop on `recv_from`, dropping +/// any datagram whose transaction ID doesn't match the query we just sent, +/// until either a match arrives or `timeout` elapses. +async fn query_record( + udp: &WasmUdpSocket, + hostname: &str, + record_type: RecordType, + server: SocketAddr, + timeout: Duration, +) -> Result { + 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, 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 { + let mut cname_target: Option = 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}" + ))) +} diff --git a/wasm/smolmix/src/error.rs b/wasm/smolmix/src/error.rs new file mode 100644 index 0000000000..045535832e --- /dev/null +++ b/wasm/smolmix/src/error.rs @@ -0,0 +1,42 @@ +// Copyright 2026 - Nym Technologies SA +// 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); diff --git a/wasm/smolmix/src/fetch.rs b/wasm/smolmix/src/fetch.rs new file mode 100644 index 0000000000..05f93d4e85 --- /dev/null +++ b/wasm/smolmix/src/fetch.rs @@ -0,0 +1,372 @@ +// Copyright 2026 - Nym Technologies SA +// 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>, +} + +/// Execute a fetch request through the mixnet tunnel. +#[cfg(feature = "fetch")] +pub async fn fetch( + tunnel: &WasmTunnel, + url_str: &str, + init: &JsValue, +) -> Result { + 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 { + 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 { + // 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, 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::() { + 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>, 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 + if let Some(arr) = body_val.dyn_ref::() { + return Ok(Some(arr.to_vec())); + } + + // ArrayBuffer → wrap in Uint8Array → copy to Vec + if let Some(buf) = body_val.dyn_ref::() { + 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 { + 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))); +} diff --git a/wasm/smolmix/src/http.rs b/wasm/smolmix/src/http.rs new file mode 100644 index 0000000000..f3476b2b0c --- /dev/null +++ b/wasm/smolmix/src/http.rs @@ -0,0 +1,237 @@ +// Copyright 2026 - Nym Technologies SA +// 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, +} + +/// `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); + +impl hyper::rt::Read for HyperIoAdapter { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + mut buf: hyper::rt::ReadBufCursor<'_>, + ) -> Poll> { + // 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 hyper::rt::Write for HyperIoAdapter { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().0).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().0).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().0).poll_close(cx) + } +} + +/// Zero a `MaybeUninit` slice and return it as `&mut [u8]`. +fn init_slice(buf: &mut [MaybeUninit]) -> &mut [u8] { + for b in buf.iter_mut() { + b.write(0); + } + // Safety: we just initialised every element. + unsafe { &mut *(buf as *mut [MaybeUninit] 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( + 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::().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, + )) +} diff --git a/wasm/smolmix/src/ipr.rs b/wasm/smolmix/src/ipr.rs new file mode 100644 index 0000000000..255e4fd6e8 --- /dev/null +++ b/wasm/smolmix/src/ipr.rs @@ -0,0 +1,314 @@ +// Copyright 2026 - Nym Technologies SA +// 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>; + +/// 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, + receiver: &mut ReconstructedReceiver, + ipr_address: &Recipient, + stream_id: u64, + surbs: SurbsConfig, + connect_timeout: Duration, +) -> Result { + 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, + 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>>, 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 { + 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, + recipient: &Recipient, + data: Vec, + 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 { + 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::() 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) +} diff --git a/wasm/smolmix/src/lib.rs b/wasm/smolmix/src/lib.rs new file mode 100644 index 0000000000..53ca9d4af6 --- /dev/null +++ b/wasm/smolmix/src/lib.rs @@ -0,0 +1,280 @@ +// Copyright 2026 - Nym Technologies SA +// 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 = 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, + /// Client storage namespace; randomise per session for clean state. + #[serde(default)] + pub client_id: Option, + /// 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, + /// 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, + /// Primary DNS resolver (e.g. `"1.1.1.1:53"`). Defaults to `8.8.8.8:53`. + #[serde(default)] + pub primary_dns: Option, + /// Fallback DNS resolver used if the primary times out. Defaults to `1.1.1.1:53`. + #[serde(default)] + pub fallback_dns: Option, + /// 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, + /// IPR connect handshake timeout in milliseconds. Defaults to 60000. + #[serde(default)] + pub connect_timeout_ms: Option, + /// DNS query timeout in milliseconds (per primary/fallback attempt). + /// Defaults to 30000. + #[serde(default)] + pub dns_timeout_ms: Option, + /// TCP keepalive interval in milliseconds. Defaults to 10000. + #[serde(default)] + pub tcp_keepalive_ms: Option, + /// Per-TCP-stream RX/TX buffer size in bytes (capped at 65535). + /// Defaults to 65535. + #[serde(default)] + pub tcp_buffer_size: Option, + /// Maximum HTTP redirect chain depth before `mixFetch` gives up. + /// Defaults to 5. + #[serde(default)] + pub max_redirects: Option, +} + +#[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 = 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 = opts + .preferred_ipr + .map(|s| { + s.parse::() + .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| -> Result, 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) +} diff --git a/wasm/smolmix/src/mixdns.rs b/wasm/smolmix/src/mixdns.rs new file mode 100644 index 0000000000..c1a122afaf --- /dev/null +++ b/wasm/smolmix/src/mixdns.rs @@ -0,0 +1,21 @@ +// Copyright 2026 - Nym Technologies SA +// 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())) + }) +} diff --git a/wasm/smolmix/src/mixfetch.rs b/wasm/smolmix/src/mixfetch.rs new file mode 100644 index 0000000000..51ada1906e --- /dev/null +++ b/wasm/smolmix/src/mixfetch.rs @@ -0,0 +1,18 @@ +// Copyright 2026 - Nym Technologies SA +// 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) + }) +} diff --git a/wasm/smolmix/src/mixsocket.rs b/wasm/smolmix/src/mixsocket.rs new file mode 100644 index 0000000000..d4be74926a --- /dev/null +++ b/wasm/smolmix/src/mixsocket.rs @@ -0,0 +1,286 @@ +// Copyright 2026 - Nym Technologies SA +// 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>> = OnceLock::new(); +static WS_NEXT_ID: AtomicU32 = AtomicU32::new(1); + +struct WsHandle { + tx: futures::channel::mpsc::UnboundedSender, +} + +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 = 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::() { + 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::() { + 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, + rx: futures::channel::mpsc::UnboundedReceiver, + 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 { + 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::() { + 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); + } +} diff --git a/wasm/smolmix/src/reactor.rs b/wasm/smolmix/src/reactor.rs new file mode 100644 index 0000000000..08608812a1 --- /dev/null +++ b/wasm/smolmix/src/reactor.rs @@ -0,0 +1,202 @@ +// Copyright 2026 - Nym Technologies SA +// 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>` 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>, +} + +/// 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, +} + +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(&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 = 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; + +/// 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, + 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::(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", + ); +} diff --git a/wasm/smolmix/src/state.rs b/wasm/smolmix/src/state.rs new file mode 100644 index 0000000000..8cfcfb0150 --- /dev/null +++ b/wasm/smolmix/src/state.rs @@ -0,0 +1,105 @@ +// Copyright 2026 - Nym Technologies SA +// 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>, + /// 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(); + } +} diff --git a/wasm/smolmix/src/stream.rs b/wasm/smolmix/src/stream.rs new file mode 100644 index 0000000000..43dcb088bf --- /dev/null +++ b/wasm/smolmix/src/stream.rs @@ -0,0 +1,452 @@ +// Copyright 2026 - Nym Technologies SA +// 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>), + 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> { + let handle = self.handle; + let notify = &self.notify; + self.stack.with(|s| { + let socket = s.sockets.get_mut::(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> { + let handle = self.handle; + let notify = &self.notify; + self.stack.with(|s| { + let socket = s.sockets.get_mut::(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> { + // 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> { + 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::(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::(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> { + 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> { + 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> { + 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> { + 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 { + 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::(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::(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, + 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 { + 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 { + let handle = s.sockets.add(socket); + let crate::reactor::SmoltcpStackInner { iface, sockets, .. } = s; + if let Err(e) = sockets.get_mut::(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::(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 { + 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) +} diff --git a/wasm/smolmix/src/tls.rs b/wasm/smolmix/src/tls.rs new file mode 100644 index 0000000000..c9ac8604f1 --- /dev/null +++ b/wasm/smolmix/src/tls.rs @@ -0,0 +1,164 @@ +// Copyright 2026 - Nym Technologies SA +// 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> = 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 { + inner: S, +} + +impl MaybeCloseNotify { + pub fn new(inner: S) -> Self { + Self { inner } + } +} + +impl AsyncRead for MaybeCloseNotify { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + 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 AsyncWrite for MaybeCloseNotify { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + 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( + stream: S, + hostname: &str, +) -> Result>, 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`. +fn make_client_config() -> Result, 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()) +} diff --git a/wasm/smolmix/src/tunnel.rs b/wasm/smolmix/src/tunnel.rs new file mode 100644 index 0000000000..ad2be993f2 --- /dev/null +++ b/wasm/smolmix/src/tunnel.rs @@ -0,0 +1,623 @@ +// Copyright 2026 - Nym Technologies SA +// 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, + /// 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, + /// Fallback DNS resolver. `None` falls back to [`dns::DEFAULT_FALLBACK_DNS`]. + pub fallback_dns: Option, + /// 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, + /// 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, + client_id: Option, + force_tls: Option, + disable_poisson_traffic: Option, + disable_cover_traffic: Option, + surbs: Option, + primary_dns: Option, + fallback_dns: Option, + storage_passphrase: Option, + connect_timeout: Option, + dns_timeout: Option, + tcp_keepalive_interval: Option, + tcp_buffer_size: Option, + max_redirects: Option, +} + +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) -> 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) -> 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>, + /// Serialises DNS lookups so concurrent callers coalesce on the cache. + dns_lock: futures::lock::Mutex<()>, + /// One idle connection per (host, port). + conn_pool: Mutex>, + /// Per-origin locks to avoid stampeding parallel TCP+TLS handshakes. + #[allow(clippy::type_complexity)] + origin_locks: Mutex>>>, + /// `Mutex>` because `ShutdownTracker::shutdown(self).await` + /// takes ownership, but `WasmTunnel` lives in a `OnceLock`. + base_tracker: Mutex>, + /// Child of `base_tracker`; bridge + reactor spawn through it. + smolmix_tracker: Mutex>, + state: state::State, +} + +/// Handles the Nym base client hands back after `start_base()`. +struct ClientHandles { + client_input: Arc, + 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, +} + +/// 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 { + 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 { + 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::::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, + receiver: &mut ipr::ReconstructedReceiver, + ipr_address: &Recipient, + stream_id: u64, + surbs: ipr::SurbsConfig, + connect_timeout: Duration, + ) -> Result { + 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, + 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 { + 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 { + 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> { + &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> { + 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 { + 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); + } +} diff --git a/wasm/smolmix/src/util.rs b/wasm/smolmix/src/util.rs new file mode 100644 index 0000000000..5502ebcd46 --- /dev/null +++ b/wasm/smolmix/src/util.rs @@ -0,0 +1,24 @@ +// Copyright 2026 - Nym Technologies SA +// 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 +} diff --git a/wasm/smolmix/tests/.gitignore b/wasm/smolmix/tests/.gitignore new file mode 100644 index 0000000000..7e0f9045c0 --- /dev/null +++ b/wasm/smolmix/tests/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ diff --git a/wasm/smolmix/tests/README.md b/wasm/smolmix/tests/README.md new file mode 100644 index 0000000000..0dd7b94033 --- /dev/null +++ b/wasm/smolmix/tests/README.md @@ -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. diff --git a/wasm/smolmix/tests/package-lock.json b/wasm/smolmix/tests/package-lock.json new file mode 100644 index 0000000000..e518720716 --- /dev/null +++ b/wasm/smolmix/tests/package-lock.json @@ -0,0 +1,1119 @@ +{ + "name": "smolmix-wasm-playwright-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smolmix-wasm-playwright-tests", + "devDependencies": { + "@playwright/test": "^1.52.0", + "serve": "^14.2.4" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + } + } +} diff --git a/wasm/smolmix/tests/package.json b/wasm/smolmix/tests/package.json new file mode 100644 index 0000000000..ecb3131178 --- /dev/null +++ b/wasm/smolmix/tests/package.json @@ -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" + } +} diff --git a/wasm/smolmix/tests/playwright.config.mjs b/wasm/smolmix/tests/playwright.config.mjs new file mode 100644 index 0000000000..6af4e47381 --- /dev/null +++ b/wasm/smolmix/tests/playwright.config.mjs @@ -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" }, + }, + ], +}); diff --git a/wasm/smolmix/tests/tests/smoke.spec.mjs b/wasm/smolmix/tests/tests/smoke.spec.mjs new file mode 100644 index 0000000000..df5ea70038 --- /dev/null +++ b/wasm/smolmix/tests/tests/smoke.spec.mjs @@ -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([]); +}); diff --git a/wasm/smolmix/tests/tests/suite.spec.mjs b/wasm/smolmix/tests/tests/suite.spec.mjs new file mode 100644 index 0000000000..35c61287ae --- /dev/null +++ b/wasm/smolmix/tests/tests/suite.spec.mjs @@ -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); + } + + }); + }); +}