From 5caa1c9d5f6e3d31046d25bd4bfc552ca649cf5a Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 27 Jun 2026 23:44:27 +0200 Subject: [PATCH] (feat): redesign WASM module, add TypeScript SDK, migrate to pnpm --- .forgejo/workflows/ci.yml | 50 ++- .gitignore | 2 + Cargo.lock | 22 +- README.md | 38 +- bun.lock | 27 -- client/src/lib.rs | 66 +++- codec/src/communication_value.rs | 4 + docs/CONNECTOR.md | 45 ++- docs/NATIVE-CLIENT.md | 41 +- docs/NATIVE-HOST.md | 57 ++- docs/TYPE-MAP.md | 26 +- docs/WASM-CLIENT.md | 453 +++++++++++----------- example/Cargo.lock | 1 - example/client/src/main.rs | 6 +- example/server/src/main.rs | 30 +- example/web-client/bun.lock | 130 ------- example/web-client/index.html | 6 +- example/web-client/package.json | 8 +- example/web-client/src/main.ts | 233 ++++++----- example/web-client/src/messages.ts | 30 -- example/web-client/tsconfig.json | 8 +- example/web-client/vite.config.ts | 10 +- flake.nix | 27 +- host/Cargo.toml | 1 - host/src/lib.rs | 188 ++++++--- package.json | 57 ++- pnpm-lock.yaml | 599 +++++++++++++++++++++++++++++ pnpm-workspace.yaml | 8 + src/raw/index.ts | 2 + src/sdk/index.ts | 514 +++++++++++++++++++++++++ src/type-map/index.ts | 5 + src/vite/index.ts | 409 ++++++++++++++++++++ transport/src/connection.rs | 39 ++ transport/src/host.rs | 27 +- tsconfig.json | 26 ++ wasm/Cargo.toml | 17 +- wasm/pkg/mtp_wasm.d.ts | 94 +++-- wasm/pkg/mtp_wasm.js | 339 +++++++++------- wasm/pkg/mtp_wasm_bg.wasm | Bin 374427 -> 394684 bytes wasm/pkg/mtp_wasm_bg.wasm.d.ts | 37 +- wasm/src/client.rs | 305 +++++++++++++-- wasm/src/config.rs | 40 ++ wasm/src/frame.rs | 479 +++++++++++++++++++++++ wasm/src/lib.rs | 6 +- wasm/src/logging.rs | 10 + wasm/src/message.rs | 411 -------------------- wasm/src/subscription.rs | 44 +++ wasm/src/transport.rs | 136 +++---- wasm/types/mtp_wasm.d.ts | 173 +++++++++ 49 files changed, 3751 insertions(+), 1535 deletions(-) delete mode 100644 bun.lock delete mode 100644 example/web-client/bun.lock delete mode 100644 example/web-client/src/messages.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/raw/index.ts create mode 100644 src/sdk/index.ts create mode 100644 src/type-map/index.ts create mode 100644 src/vite/index.ts create mode 100644 tsconfig.json create mode 100644 wasm/src/config.rs create mode 100644 wasm/src/frame.rs create mode 100644 wasm/src/logging.rs delete mode 100644 wasm/src/message.rs create mode 100644 wasm/src/subscription.rs create mode 100644 wasm/types/mtp_wasm.d.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3399ddb..ff522b9 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -44,17 +44,7 @@ jobs: run: | export PATH="$HOME/.cargo/bin:$PATH" export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" - cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings \ - -W unreachable-pub \ - -W clippy::cognitive_complexity \ - -W clippy::missing_docs_in_private_items \ - -W clippy::missing_errors_doc \ - -W clippy::missing_panics_doc \ - -W clippy::missing_safety_doc \ - -W clippy::undocumented_unsafe_blocks \ - -W clippy::pedantic \ - -W clippy::restriction \ - -A clippy::blanket_clippy_restriction_lints + cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub test: name: test @@ -169,21 +159,21 @@ jobs: - uses: https://data.forgejo.org/actions/checkout@v4 - name: Install system dependencies - run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip nodejs - - name: Install Bun + - name: Install pnpm run: | - curl -fsSL https://bun.sh/install | bash + curl -fsSL https://get.pnpm.io/install.sh | SHELL=/bin/sh sh - - name: Install dependencies run: | - export PATH="$HOME/.bun/bin:$PATH" - bun install --frozen-lockfile + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm install --frozen-lockfile - name: Run duplicate detector run: | - export PATH="$HOME/.bun/bin:$PATH" - bun run dup + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm run dup web-client: name: web client @@ -192,7 +182,7 @@ jobs: - uses: https://data.forgejo.org/actions/checkout@v4 - name: Install system dependencies - run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip build-essential pkg-config libssl-dev lld + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip nodejs build-essential pkg-config libssl-dev lld - name: Install Rust run: | @@ -209,18 +199,24 @@ jobs: env: RUSTFLAGS: --cfg web_sys_unstable_apis - - name: Install Bun + - name: Install pnpm run: | - curl -fsSL https://bun.sh/install | bash + curl -fsSL https://get.pnpm.io/install.sh | SHELL=/bin/sh sh - - name: Install dependencies - working-directory: example/web-client run: | - export PATH="$HOME/.bun/bin:$PATH" - bun install --frozen-lockfile + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm install --frozen-lockfile + + - name: Build TypeScript package + run: | + export PATH="$HOME/.cargo/bin:$HOME/.local/share/pnpm:$PATH" + export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" + pnpm run build + env: + RUSTFLAGS: --cfg web_sys_unstable_apis - name: Build web client - working-directory: example/web-client run: | - export PATH="$HOME/.bun/bin:$PATH" - bun run build + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm --filter mtp-web-client run build diff --git a/.gitignore b/.gitignore index 3c93af0..2c539c1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ *.pem *.key node_modules/ +dist/ +*.tgz diff --git a/Cargo.lock b/Cargo.lock index 346bb2d..02d8a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1035,7 +1044,6 @@ dependencies = [ name = "mtp-host" version = "0.1.0" dependencies = [ - "log", "mtp-codec", "mtp-common", "mtp-crypto", @@ -1070,6 +1078,7 @@ name = "mtp-wasm" version = "0.1.0" dependencies = [ "console_error_panic_hook", + "futures-channel", "getrandom 0.2.17", "getrandom 0.4.3", "hex", @@ -1081,7 +1090,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", - "web-sys", ] [[package]] @@ -2159,16 +2167,6 @@ version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "web-time" version = "1.1.0" diff --git a/README.md b/README.md index 4e44331..492824e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,43 @@ MTP is a modular transport protocol built on QUIC. It provides version-negotiable type maps, a binary codec, cryptographic primitives (classical and post-quantum), and host/client connection management with mutual authentication. -See the area-specific docs for [Native Client](./NATIVE-CLIENT.md), [WASM Client](./WASM-CLIENT.md), and [Host](./NATIVE-HOST.md) +See the area-specific docs for [Native Client](./docs/NATIVE-CLIENT.md), [WASM Client](./docs/WASM-CLIENT.md), [Host](./docs/NATIVE-HOST.md), and [Type Maps](./docs/TYPE-MAP.md). + +## Browser SDK + +The JavaScript package is `mtp`: + +```typescript +import { MTPClient } from "mtp"; +import { mtp } from "mtp/vite"; +``` + +Use `mtp` for the SDK-first API, `mtp/raw` for generated WASM bindings, and `mtp/vite` for the Vite integration. + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import { mtp } from "mtp/vite"; + +export default defineConfig({ + plugins: [mtp({ typeMaps: "./type-maps.yaml" })], +}); +``` + +```typescript +const client = await MTPClient.create({ + url: "https://localhost:4433", + hostPublicKey, + credentials, + storage, + pings: true, + logger: (event) => console.log(event), +}); + +client.subscribe("SomeType", (message) => console.log(message)); +await client.connectOrRegister(); +await client.send("SomeType", { value: "hello" }); +``` ## Getting Started diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 08856ef..0000000 --- a/bun.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "mtp-wasm", - "devDependencies": { - "jscpd": "5.0.11", - }, - }, - }, - "packages": { - "cpd-darwin-arm64": ["cpd-darwin-arm64@5.0.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3QvH+4Dv7A7esVFM2tsRVWN3kn9EDu8dMYog6gYAVsCtxEf4xyxAwS/ef6LjC7/dh4+ATADFbg3H09A2fD//Qw=="], - - "cpd-darwin-x64": ["cpd-darwin-x64@5.0.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-OvgM2ps0OFR5jUzx7+FK9URdJGxUzzM5KKk2F1V3vf1LooGDKwkivfIDyKsqEwp37zcbyUo7COvBpJXOT0dZmQ=="], - - "cpd-linux-arm64-gnu": ["cpd-linux-arm64-gnu@5.0.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-pXMINibAeruglni8ZajlXEefZHDs7QFSG+vPtkBDu7uiIMpNU8aoktgO2vP+PbIRFD0vHkqTMb64kDtIOqQcwQ=="], - - "cpd-linux-x64-gnu": ["cpd-linux-x64-gnu@5.0.11", "", { "os": "linux", "cpu": "x64" }, "sha512-rQ7DuF0lH1HLzjGxlE0aEP2ycfhXgZH/CLSeS7FXNJ38lRVp+iXkrlcrrY6mC4WW/NgbL+DkF7/0lv3tFvGmvg=="], - - "cpd-linux-x64-musl": ["cpd-linux-x64-musl@5.0.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Yh+7Go5+fA++I5ssAZg7gUkDCT5CxnzCPvrspbwDrfnwaY6nNM5g1C6Vs0+GJhsspuAKwydJl4nf7jkxzMwRQw=="], - - "cpd-windows-x64-msvc": ["cpd-windows-x64-msvc@5.0.11", "", { "os": "win32", "cpu": "x64" }, "sha512-uV6w85qdfE0WJsrLcGw9A4Kv9ovSnlXZCybMK0esvuiJ7clgaZmDiDPozt5PvrOOShkK/NxzTZSORBSdVnquHA=="], - - "jscpd": ["jscpd@5.0.11", "", { "optionalDependencies": { "cpd-darwin-arm64": "5.0.11", "cpd-darwin-x64": "5.0.11", "cpd-linux-arm64-gnu": "5.0.11", "cpd-linux-x64-gnu": "5.0.11", "cpd-linux-x64-musl": "5.0.11", "cpd-windows-x64-msvc": "5.0.11" }, "bin": { "jscpd": "run-jscpd.js" } }, "sha512-NfLrFJHRM6rIf3oVcdZ4sfhMVop1qxi5r8aC99lpj55YC8hiWaN4VmzU2wcXTwoAo+NS4npXPs9EVEQJ6jyRlg=="], - } -} diff --git a/client/src/lib.rs b/client/src/lib.rs index 1627d63..e679df2 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -18,10 +18,47 @@ fn unexpected_response_type_error( pub struct ClientConfig { pub url: String, - pub server_cert: Option>, + pub tls: ClientTlsConfig, pub client_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientTlsConfig { + SystemRoots, + PinnedPem(Vec), +} + +impl ClientConfig { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + tls: ClientTlsConfig::SystemRoots, + client_id: 0, + } + } + + pub fn with_tls(mut self, tls: ClientTlsConfig) -> Self { + self.tls = tls; + self + } + + pub fn with_pinned_pem(self, cert_pem: Vec) -> Self { + self.with_tls(ClientTlsConfig::PinnedPem(cert_pem)) + } + + pub fn with_client_id(mut self, client_id: u64) -> Self { + self.client_id = client_id; + self + } + + fn server_cert(&self) -> Option> { + match &self.tls { + ClientTlsConfig::SystemRoots => None, + ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()), + } + } +} + /* Established MTP connection with a single negotiated version. */ pub struct MTPConnection { pub version: Version, @@ -52,7 +89,7 @@ impl MTPClient { */ pub async fn connect(config: ClientConfig) -> Result { let (sender, receiver) = - mtp_transport::connect(&config.url, config.server_cert, Policy::default()).await?; + mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; // Build the initial identification message with the protocol version. let version_str = format!("{}", PROTOCOL_VERSION); @@ -264,7 +301,7 @@ impl MTPClient { use mtp_crypto::auth; let (sender, receiver) = - mtp_transport::connect(&config.url, config.server_cert, Policy::default()).await?; + mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; let tm = mtp_codec::TypeMap::latest(); let version_str = format!("{}", PROTOCOL_VERSION); @@ -337,7 +374,7 @@ impl MTPClient { use mtp_crypto::auth; let (sender, receiver) = - mtp_transport::connect(&config.url, config.server_cert, Policy::default()).await?; + mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; let tm = mtp_codec::TypeMap::latest(); let version_str = format!("{}", PROTOCOL_VERSION); @@ -414,23 +451,20 @@ mod tests { #[test] fn test_client_config_url() { - let config = ClientConfig { - url: "https://example.com:4433".into(), - server_cert: None, - client_id: 0, - }; + let config = ClientConfig::new("https://example.com:4433"); assert_eq!(config.url, "https://example.com:4433"); - assert!(config.server_cert.is_none()); + assert_eq!(config.tls, ClientTlsConfig::SystemRoots); } #[test] fn test_client_config_with_cert() { - let config = ClientConfig { - url: "https://localhost:4433".into(), - server_cert: Some(vec![0x01, 0x02, 0x03]), - client_id: 42, - }; - assert_eq!(config.server_cert, Some(vec![0x01, 0x02, 0x03])); + let config = ClientConfig::new("https://localhost:4433") + .with_pinned_pem(vec![0x01, 0x02, 0x03]) + .with_client_id(42); + assert_eq!( + config.tls, + ClientTlsConfig::PinnedPem(vec![0x01, 0x02, 0x03]) + ); assert_eq!(config.client_id, 42); } diff --git a/codec/src/communication_value.rs b/codec/src/communication_value.rs index 636fe2b..dc03e70 100644 --- a/codec/src/communication_value.rs +++ b/codec/src/communication_value.rs @@ -124,6 +124,10 @@ impl CommunicationValue { pub fn get_data(&self, data_type: DataTypeId) -> &DataValue { self.data.get(&data_type).unwrap_or(&DataValue::Null) } + + pub fn data(&self) -> &BTreeMap { + &self.data + } } impl CommunicationValue { diff --git a/docs/CONNECTOR.md b/docs/CONNECTOR.md index 86f6948..0ef94c1 100644 --- a/docs/CONNECTOR.md +++ b/docs/CONNECTOR.md @@ -33,16 +33,17 @@ The host creates a QUIC server, manages the registry, and handles version negoti ### Initialization -The host binds to the address from the `mtp_BIND` environment variable (defaults to `::`) on the specified port: +The host binds to the address and port supplied in `HostConfig`: ```rust -use mtp::host::{MTPHost, HostConfig}; +use mtp::host::{HostConfig, MTPHost}; -let config = HostConfig { - port: 4433, - tls_fullchain: std::fs::read("cert.pem")?, - tls_key: std::fs::read("key.pem")?, -}; +let config = HostConfig::new( + "0.0.0.0".parse()?, + 4433, + std::fs::read("cert.pem")?, + std::fs::read("key.pem")?, +); let mut host = MTPHost::new(config).await?; ``` @@ -50,7 +51,7 @@ let mut host = MTPHost::new(config).await?; ### Accepting Connections with Version Negotiation ```rust -while let Some(conn) = host.accept().await { +while let Some(conn) = host.accept().await? { // conn.version is the negotiated version // conn.codec is a VersionedCodec scoped to that version // conn.sender / conn.receiver for raw CommunicationValue I/O @@ -63,23 +64,23 @@ The host's `accept()` method: 1. Accepts a QUIC connection 2. If authentication is required (crypto feature): performs login/register handshake 3. Reads the first `CommunicationValue` (always encoded with reserved type IDs) -4. Extracts the client's protocol version from `DataType::Version` (wire ID 3) +4. Extracts the client's protocol version from `DataType::Version` (reserved data type ID 0) 5. Calls `registry.negotiate(&[client_version])` -6. Returns `None` if the version is unsupported -7. Returns an `MTPConnection` with the negotiated version otherwise +6. Returns an `AcceptError` if the version is unsupported +7. Returns `Ok(Some(MTPConnection))` with the negotiated version otherwise ### Login/Register Handshake When `require_authentication` is set, the parties run a mutually-authenticated **challenge-response**. The client speaks first with an *unsigned* hello: -- **Login** (`CommunicationType::Identification`, ID 15): version, client ID -- **Register** (`CommunicationType::Register`, ID 17): version, public keys +- **Login** (`CommunicationType::Identification`, reserved ID 0): version, client ID +- **Register** (`CommunicationType::Register`, reserved ID 2): version, public keys The host then issues a fresh random `server_challenge` in a signed `Challenge` -(`CommunicationType::Challenge`, ID 21, carrying `ServerNonce`). The client signs +(`CommunicationType::Challenge`, reserved ID 4, carrying `ServerNonce`). The client signs that challenge, binding its id (login) or public keys (register), and returns a -`ChallengeResponse` (ID 22). The host verifies the proof against the challenge it +`ChallengeResponse` (reserved ID 5). The host verifies the proof against the challenge it issued and sends a signed final response, which the client verifies. Because the client's proof covers the host-issued `server_challenge` (a one-time @@ -94,21 +95,19 @@ replayed on another connection. All signed payloads are domain-separated; see The client connects to a host and uses a single compiled-in protocol version. ```rust -use mtp::client::{MTPClient, ClientConfig}; +use mtp::client::{ClientConfig, MTPClient}; -let config = ClientConfig { - url: "https://host.example.com:4433".into(), - server_cert: None, // or Some(cert_pem_bytes) -}; +let config = ClientConfig::new("https://host.example.com:4433"); +let pinned = config.clone().with_pinned_pem(cert_pem_bytes); // Connect (unauthenticated, existing client) -let conn = MTPClient::connect(config, 8765).await?; +let conn = MTPClient::connect(config.clone().with_client_id(8765)).await?; // Authenticated login -let conn = MTPClient::auth_connect(config, 8765, keys, host_pk).await?; +let conn = MTPClient::auth_connect(pinned.with_client_id(8765), &keys, &host_pk).await?; // Registration (new client) -let conn = MTPClient::auth_register(config, keys, host_pk).await?; +let conn = MTPClient::auth_register(config, &keys, &host_pk).await?; ``` The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client never imports the `registry` crate; it only uses `mtp::type_map` for enum types and `mtp::codec` for encoding. diff --git a/docs/NATIVE-CLIENT.md b/docs/NATIVE-CLIENT.md index 15e00c7..ce493d5 100644 --- a/docs/NATIVE-CLIENT.md +++ b/docs/NATIVE-CLIENT.md @@ -17,24 +17,22 @@ mtp = { path = "/path/to/mtp", features = ["client", "crypto"] } ## ClientConfig ```rust -use mtp::client::ClientConfig; +use mtp::client::{ClientConfig, ClientTlsConfig}; -let config = ClientConfig { - url: "https://host.example.com:4433".into(), - server_cert: None, // None = use system root certificates - client_id: 0, // previously assigned ID or 0 -}; +let config = ClientConfig::new("https://host.example.com:4433") + .with_tls(ClientTlsConfig::SystemRoots) + .with_client_id(0); ``` | Field | Type | Description | |--------------|--------------------|-----------------------------------------------------| | `url` | `String` | `https://host:port` address of the MTP host | -| `server_cert`| `Option>` | `None` to use system roots, `Some(pem_bytes)` to pin | +| `tls` | `ClientTlsConfig` | `SystemRoots` or `PinnedPem(pem_bytes)` | | `client_id` | `u64` | Client identifier (ignored during `auth_register`) | ### TLS Certificate Handling -When `server_cert` is `None` (the default), the client loads the **system's +When `tls` is `ClientTlsConfig::SystemRoots` (the default), the client loads the **system's native root certificate store** via `rustls_native_certs`. This works with publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS (Keychain), and Windows (Root Store). @@ -44,10 +42,7 @@ certificates: ```rust let pem = std::fs::read("my-server-cert.pem")?; -let config = ClientConfig { - server_cert: Some(pem), - // ... -}; +let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem); ``` When pinned, **only** the given certificate(s) are trusted for the TLS @@ -78,13 +73,9 @@ pub struct MTPConnection { ### Unauthenticated Connect ```rust -use mtp::client::{MTPClient, ClientConfig}; +use mtp::client::{ClientConfig, MTPClient}; -let config = ClientConfig { - url: "https://host.example.com:4433".into(), - server_cert: None, - client_id: 42, -}; +let config = ClientConfig::new("https://host.example.com:4433").with_client_id(42); let conn = MTPClient::connect(config).await?; ``` @@ -101,10 +92,8 @@ use mtp::crypto::{Keyring, PublicKeyBundle}; let keys = Keyring::from_bytes(&saved_keyring_bytes)?; let host_pk = PublicKeyBundle::from_bytes(&saved_host_pk_bytes)?; -let config = ClientConfig { - client_id: 42, // must match the keyring's identity - // ... -}; +let config = ClientConfig::new("https://host.example.com:4433") + .with_client_id(42); // must match the keyring's identity let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?; ``` @@ -315,7 +304,11 @@ using `MTPClient`: ```rust use mtp_transport::{connect, Policy}; -let (sender, receiver) = connect(&config.url, config.server_cert, policy).await?; +let server_cert = match &config.tls { + ClientTlsConfig::SystemRoots => None, + ClientTlsConfig::PinnedPem(pem) => Some(pem.clone()), +}; +let (sender, receiver) = connect(&config.url, server_cert, policy).await?; ``` Then build and send the initial `Identification` frame manually to complete @@ -324,7 +317,7 @@ version negotiation. ## Version The client's protocol version is baked in at compile time via the -`PROTOCOL_VERSION` constant from `mtp_codec`. The version is set by the +`PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the `protocol_version` field in your `type-maps.yaml`. The client never imports the `registry` module; it uses a single compiled-in diff --git a/docs/NATIVE-HOST.md b/docs/NATIVE-HOST.md index ab8cf5c..dd7454d 100644 --- a/docs/NATIVE-HOST.md +++ b/docs/NATIVE-HOST.md @@ -21,25 +21,23 @@ mtp = { path = "/path/to/mtp", features = ["host", "crypto"] } use mtp::host::HostConfig; use std::net::{IpAddr, Ipv4Addr}; -let config = HostConfig { - ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), - port: 4433, - tls_fullchain: std::fs::read("cert.pem")?, - tls_key: std::fs::read("key.pem")?, - - // Crypto fields (required when feature = "crypto"): - require_authentication: true, - host_id: 1, - host_keyring: /* Keyring */, - get_existing_user: Box::new(|client_id: u64| -> Option { +let config = HostConfig::new( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + 4433, + std::fs::read("cert.pem")?, + std::fs::read("key.pem")?, +) +.with_authentication( + /* Keyring */, + |client_id: u64| -> Option { CLIENT_DB.lock().unwrap().get(&client_id).cloned() - }), - complete_register: Box::new(|bundle: PublicKeyBundle| -> u64 { + }, + |bundle: PublicKeyBundle| -> u64 { let id = next_id(); CLIENT_DB.lock().unwrap().insert(id, bundle); id - }), -}; + }, +); ``` | Field | Type | Description | @@ -49,10 +47,9 @@ let config = HostConfig { | `tls_fullchain` | `Vec` | PEM-encoded TLS certificate chain | | `tls_key` | `Vec` | PEM-encoded TLS private key | | `require_authentication` | `bool` (crypto) | Enable login/register handshake | -| `host_id` | `u64` (crypto) | Host identifier | | `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys | -| `get_existing_user` | `Box Option + Send>` (crypto) | Lookup callback for login | -| `complete_register` | `Box u64 + Send>` (crypto) | Registration callback, returns new client ID | +| `get_existing_user` | `Fn(u64) -> Option + Send + Sync` (crypto) | Lookup callback for login | +| `complete_register` | `Fn(PublicKeyBundle) -> u64 + Send + Sync` (crypto) | Registration callback, returns new client ID | ### TLS @@ -67,7 +64,7 @@ use mtp::host::MTPHost; let mut host = MTPHost::new(config).await?; println!("Listening on {}", host.local_addr()); -while let Some(conn) = host.accept().await { +while let Some(conn) = host.accept().await? { // conn is an MTPConnection ready for I/O } ``` @@ -107,12 +104,12 @@ When a client connects, `accept()` performs the following sequence: 1. Accept the QUIC connection 2. Read the client's first `CommunicationValue` (always encoded with reserved type IDs) -3. Extract the protocol version from `DataType::Version` (wire ID 3) as a +3. Extract the protocol version from `DataType::Version` (reserved data type ID 0) as a `DataValue::Str("major.minor")` 4. Call `registry.negotiate(&[client_version])` to find the highest mutually supported version -5. Return `None` (closing the connection) if no compatible version exists -6. Return an `MTPConnection` with the negotiated version +5. Return an `AcceptError` (closing the connection) if no compatible version exists +6. Return `Ok(Some(MTPConnection))` with the negotiated version The `Registry` is built automatically from all type maps defined in your `type-maps.yaml` via `Registry::builtin()`. @@ -221,14 +218,14 @@ available for verifying subsequent signed messages from the client. If verification fails or the client is not found (login), the host sends a rejection response with `Connected=false` and closes the send stream, returning -`None` from `accept()`. +`AcceptError::AuthenticationFailed` from `accept()`. ## Handling Messages Use `conn.sender` and `conn.receiver` for bidirectional message exchange: ```rust -while let Some(conn) = host.accept().await { +while let Some(conn) = host.accept().await? { tokio::spawn(async move { loop { match conn.receiver.receive().await { @@ -265,9 +262,9 @@ verification. Must return `Some(PublicKeyBundle)` if the client ID is known, or `None` to reject. ```rust -let get_existing_user = Box::new(|id: u64| -> Option { +let get_existing_user = |id: u64| -> Option { db.lock().unwrap().get(&id).cloned() -}); +}; ``` ### complete_register @@ -277,16 +274,16 @@ assign a client ID. The returned `u64` becomes the client's permanent identifier. ```rust -let complete_register = Box::new(|bundle: PublicKeyBundle| -> u64 { +let complete_register = |bundle: PublicKeyBundle| -> u64 { let mut db = db.lock().unwrap(); let id = next_id; next_id += 1; db.insert(id, bundle); id -}); +}; ``` -Both callbacks are called from within `accept()` and must be `Send`. They are +Both callbacks are called from within `accept()` and must be `Send + Sync`. They are invoked synchronously, so avoid long-running operations (or use `spawn_blocking` if needed, though the callbacks are `Fn`, not `AsyncFn`). @@ -338,5 +335,3 @@ let transport = host(ip, port, cert, key, custom_policy).await?; Drop the `MTPHost` to stop accepting new connections. Active connections continue until their `Sender`/`Receiver` are dropped or the peer disconnects. - - diff --git a/docs/TYPE-MAP.md b/docs/TYPE-MAP.md index ce650f3..675b341 100644 --- a/docs/TYPE-MAP.md +++ b/docs/TYPE-MAP.md @@ -10,7 +10,18 @@ Type names are defined in a YAML config and turned into Rust enums at **compile ### Defining Type Maps -An example `type-maps.yaml` is provided in the [`example-type-maps.yaml`](./example-type-maps.yaml) file. Place your own `type-maps.yaml` in your project root and set the `MTP_TYPE_MAPS` environment variable (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)). +Place your own `type-maps.yaml` in your project root. Browser apps should use the Vite plugin so the app-specific WASM package is generated into Vite's cache during dev/build: + +```typescript +import { defineConfig } from "vite"; +import { mtp } from "mtp/vite"; + +export default defineConfig({ + plugins: [mtp({ typeMaps: "./type-maps.yaml" })], +}); +``` + +Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)). ### Using Generated Enums @@ -84,7 +95,18 @@ let decoded = codec.decode(&bytes, Version(2, 0)).unwrap(); ## Customizing Type Maps in Downstream Projects -External projects must provide their own type map configuration via the `MTP_TYPE_MAPS` environment variable. There is no bundled default; the build script will error if the variable is not set or points to an invalid file. +External projects must provide their own type map configuration. Browser projects should install `mtp` and configure `mtp/vite`; they do not need to publish, fork, or copy a generated WASM package. + +```typescript +import { defineConfig } from "vite"; +import { mtp } from "mtp/vite"; + +export default defineConfig({ + plugins: [mtp({ typeMaps: "./type-maps.yaml" })], +}); +``` + +For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available. 1. Create a `type-maps.yaml` in your project root 2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`: diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index f42c033..5cfc3b4 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -1,291 +1,282 @@ # MTP WASM Client -The WASM client is a browser-compatible MTP implementation that uses the **WebTransport** API to communicate with an MTP host over QUIC (HTTP/3). It compiles from Rust to WebAssembly via `wasm-bindgen` and exposes a JavaScript/TypeScript API through the `mtp-wasm` npm package. +The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations. -## Package - -The compiled package lives in `wasm/pkg/` and contains: - -- `mtp_wasm.js` -- generated JS glue -- `mtp_wasm_bg.wasm` -- the WebAssembly binary -- `mtp_wasm.d.ts` -- TypeScript type declarations -- `package.json` -- npm package definition - -Install or copy these files into your web project. Then initialise the module: +## Package Entry Points ```typescript -import init, { WasmClient } from 'mtp-wasm'; - -await init(); +import { MTPClient } from "mtp"; +import init, { WasmClient } from "mtp/raw"; +import { mtp } from "mtp/vite"; ``` -## Browser Support +- `mtp` exports the SDK-first `MTPClient` wrapper. +- `mtp/raw` exports the generated `wasm-bindgen` module and raw classes/functions. +- `mtp/vite` exports the Vite plugin that builds app-specific WASM bindings from your `type-maps.yaml`. +- `mtp/type-map` exports generated TypeScript unions for communication and data type names. -WebTransport is required. Check availability at runtime: +## Vite Type-Map Workflow + +Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev/build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. ```typescript -if (!WasmClient.is_supported()) { - // fall back or show an error -} +// vite.config.ts +import { defineConfig } from "vite"; +import { mtp } from "mtp/vite"; + +export default defineConfig({ + plugins: [mtp({ typeMaps: "./type-maps.yaml" })], +}); ``` -## Connecting to a Host +You do not need to publish, fork, or copy an app-specific generated WASM package. -### ConnectionConfig +## SDK Quick Start ```typescript -const config = new ConnectionConfig("https://host.example.com:4433"); -config.client_id = 12345n; // optional, for re-authentication -config.server_certificate_hashes = [ // optional, for certificate pinning - "sha-256:abc123...", -]; -``` +import { MTPClient } from "mtp"; -`client_id` is only needed for authenticated login (`auth_connect`). For registration (`auth_register`) it is ignored. +const client = await MTPClient.create({ + url: "https://host.example.com:4433", + hostPublicKey, + credentials, + storage, + serverCertificateHashes: ["sha-256:abcd1234..."], + pings: true, + logger: (event) => console.log(event), +}); -### TLS Certificate Handling +const unsubscribe = client.subscribe("SomeType", (message) => { + console.log(message.type, message.data); +}); -By default, when `server_certificate_hashes` is not set, the browser uses its -**built-in root certificate store** to verify the server's TLS certificate, -just like any other HTTPS/WebSocket connection. This works with publicly-trusted -certificate authorities automatically. +await client.connectOrRegister(); +await client.send("SomeType", { value: "hello" }); -For development or self-signed certificates, pin the server certificate by -providing its hash: - -```typescript -config.server_certificate_hashes = [ - "sha-256:abcd1234...", // hex-encoded hash value -]; -``` - -The hash format is `":"`, where the only algorithm -the browser's WebTransport API currently accepts is `sha-256`. When hashes are -provided, the browser **only** trusts certificates matching one of the given -hashes and ignores its root store for this connection. - -### Callbacks - -The client uses three callbacks for state, messages, and errors: - -```typescript -const client = new WasmClient( - (state: number) => console.log("state", state), // ConnectionState enum - (data: Uint8Array) => console.log("msg", data), // raw frame bytes - (err: any) => console.error("err", err), // error description +const response = await client.request( + "SomeRequestType", + { id: "abc" }, + { responseType: "SomeResponseType" }, ); -``` -### Connection States +client.raw.client; // underlying WasmClient instance +client.raw.bindings; // generated raw WASM module exports -| Value | Name | -|-------|--------------| -| 0 | Disconnected | -| 1 | Connecting | -| 2 | Connected | -| 3 | Failed | - -Poll `client.state` at any time. - -## Connection Methods - -### Unauthenticated Connect - -```typescript -await client.connect(config); -``` - -Sends an `Identification` frame with the protocol version and client ID. The host may accept or reject. No cryptographic handshake occurs. - -### Authenticated Login - -```typescript -const confirmedId = await client.auth_connect( - config, - hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host - keyringBytes, // Uint8Array: serialized Keyring matching the client ID - clientId, // bigint: previously assigned client ID -); -``` - -Exchange (challenge-response): the client sends an unsigned `Identification` -hello, the host replies with a signed `Challenge` carrying a fresh -`server_challenge`, the client signs that challenge in a `ChallengeResponse`, and -the host verifies it and replies with a signed `IdentificationResponse`. Signing -over the host-issued challenge is what prevents a captured proof from being -replayed on another connection. Returns the confirmed client ID. - -### Registration - -```typescript -const newId = await client.auth_register( - config, - hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host - keyringBytes, // Uint8Array: serialized Keyring for the new identity -); -``` - -Exchange (challenge-response): the client sends an unsigned `Register` hello with -its public keys, the host replies with a signed `Challenge`, the client signs it -(binding the public-key bundle) in a `ChallengeResponse`, and the host verifies -it, assigns a new ID, and responds with a signed `RegisterResponse`. Returns the -newly assigned client ID. - -## Sending and Receiving Messages - -### Send - -```typescript -const frame = build_ping_frame(clientId, "hello", timestamp, data); -await client.send(frame); -``` - -`send()` takes raw frame bytes (a serialized `CommunicationValue`). Build frames -with the provided helper functions or construct them manually. - -### Receive - -Incoming frames arrive on the `on_message` callback registered in the constructor. -The callback receives a `Uint8Array` of raw frame bytes. Parse with -`CommunicationValue.from_bytes()` on the Rust side or handle the bytes in JS. - -### Disconnect - -```typescript +unsubscribe(); client.disconnect(); ``` -Gracefully closes the WebTransport session. - -## Building Frames - -### `build_ping_frame` +`MTPClient.isSupported()` checks whether the current browser exposes WebTransport: ```typescript -function build_ping_frame( - clientId: bigint, - description: string, - timestamp: bigint, - data: Uint8Array, -): Uint8Array; -``` - -Constructs a basic `Ping` message with description, timestamp, and optional -binary payload. Useful for health checks and simple messaging. - -### `build_demo_message` - -```typescript -function build_demo_message( - clientId: bigint, - keyringBytes: Uint8Array, - hostBundleBytes: Uint8Array, -): Uint8Array; -``` - -Constructs a `Ping` frame that demonstrates encrypted, signed, and -signed+encrypted containers. The containers are ML-KEM-encrypted to the host's -`PublicKeyBundle` (`hostBundleBytes`, the same bytes passed to `auth_connect` / -`auth_register`), so the host decrypts them with its own keyring; signatures use -the client keyring's Ed25519 key. The client keyring only needs its Ed25519 -signing key for this demo. - -### `parse_auth_response` - -```typescript -function parse_auth_response(response: Uint8Array): any; -``` - -Parses an `IdentificationResponse` or `RegisterResponse` frame into a JS object: - -```typescript -{ - connected: boolean, - clientNonce?: Uint8Array, - assignedId?: number, - timestamp?: number, - signature?: Uint8Array, +if (!MTPClient.isSupported()) { + throw new Error("WebTransport is not available in this browser"); } ``` -## Crypto Primitives +## Credentials And Storage -### Key Generation +Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration. ```typescript -const result = ed25519_generate(); -// result.signer -> WasmEd25519Signer -// result.secretKey -> Uint8Array (32 bytes) -// result.publicKey -> Uint8Array (32 bytes) +const storage = { + getItem: (key: string) => localStorage.getItem(key), + setItem: (key: string, value: string) => localStorage.setItem(key, value), + removeItem: (key: string) => localStorage.removeItem(key), +}; + +const client = await MTPClient.create({ + url: "https://host.example.com:4433", + hostPublicKey, + storage, +}); + +const clientId = await client.connectOrRegister(); ``` -### Keyring - -A `Keyring` bundles all key material for an identity. For Ed25519-only setups: +The storage contract is intentionally small and may be sync or async: ```typescript -const keyringBytes = keyring_from_ed25519(secretKey, publicKey); -// keyringBytes is ready for WasmClient.auth_register or WasmClient.auth_connect +interface MTPCredentialStorage { + getItem(key: string): string | null | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +} ``` -Full keyring with KEM + ML-DSA requires constructing on the Rust side. The -serialized bytes are portable: +`credentials` can also be supplied directly: ```typescript -const keyring = WasmKeyring.from_bytes(keyringBytes); -const bundle = keyring.public_key_bundle(); -// bundle.kem_public_key -> Uint8Array -// bundle.sig_cl_public_key -> Uint8Array -// bundle.sig_pq_public_key -> Uint8Array +const client = await MTPClient.create({ + url: "https://host.example.com:4433", + hostPublicKey, + credentials: { + clientId: 42n, + keyring: savedKeyringBytes, + }, +}); + +await client.connect(); ``` -### Signing and Verification +`client.credentials` returns the current public credential object. Call `clearCredentials()` to remove in-memory credentials and delete the configured storage key. + +## Connection Methods + +- `connect()` opens a connection. If credentials include a `clientId` and `hostPublicKey` is available, it uses authenticated login; otherwise it uses unauthenticated connect. +- `register()` performs authenticated registration and persists the assigned client ID when storage is configured. +- `connectOrRegister()` registers when no client ID is present, otherwise performs authenticated login. +- `disconnect()` stops protocol pings and closes the underlying WebTransport session. + +For certificate pinning, pass WebTransport certificate hashes: ```typescript -const signer = new WasmEd25519Signer(secretKey); -const sig = signer.sign(message); // Uint8Array -signer.verify(message, sig); // throws on mismatch - -// Standalone verification (no signer object needed): -ed25519_verify(publicKey, message, signature); +await MTPClient.create({ + url: "https://host.example.com:4433", + serverCertificateHashes: ["sha-256:abcd1234..."], +}); ``` -### Symmetric Encryption +If hashes are omitted, the browser uses its normal TLS root store. + +## Sending, Requests, Subscriptions, And Pings + +`send` accepts either a typed message or a prebuilt raw frame: ```typescript -const cipher = new WasmChaCha20Poly1305(key); // 32-byte key -const encrypted = cipher.encrypt(plaintext, aad); // nonce || ciphertext -const decrypted = cipher.decrypt(encrypted, aad); +await client.send("SomeType", { value: "hello" }); +await client.send(rawFrameBytes); ``` -### Hashing and KDF +Typed sends are encoded by the generated WASM binding using the app type map. Optional frame metadata can be passed as the third argument: ```typescript -const hash = wasm_sha256(data); // 32 bytes -const double = wasm_sha256_double(data); // SHA-256(SHA-256(data)) - -const derived = wasm_hkdf_expand(ikm, salt, info, len); -const encKey = wasm_derive_encryption_key(ikm, salt, context); // 32 bytes +await client.send("SomeType", { value: "hello" }, { + id: 7, + sender: client.credentials?.clientId ?? 0n, +}); ``` -## Lifecycle and Best Practices +`request` sends one frame and resolves with the matching parsed response from the WASM layer: -1. **Key persistence** -- serialise keyring bytes after registration and store - them (e.g. in `localStorage`). On next visit, load the saved keyring and - call `auth_connect` instead of registering again. +```typescript +const response = await client.request( + "SomeRequestType", + { id: "abc" }, + { responseType: "SomeResponseType" }, +); +``` -2. **Ownership** -- call `config.free()` after connecting if the config object - is no longer needed. WASM objects (`WasmClient`, `WasmKeyring`, etc.) are - garbage-collected, but explicit `free()` or `dispose()` reclaims memory - sooner. +`subscribe` registers a message-type handler and returns an unsubscribe function: -3. **Receive loop** -- once `connect`, `auth_connect`, or `auth_register` - resolves, the receive loop is running in the background. Incoming frames - arrive on the `on_message` callback. There is no need to poll. +```typescript +const unsubscribe = client.subscribe("SomeType", (message) => { + console.log(message.id, message.sender, message.data); +}); -4. **Single active client** -- a `WasmClient` manages one WebTransport session. - Create a new instance for each connection. +unsubscribe(); +``` -5. **State transitions** -- after `disconnect()` the client transitions to - `Disconnected`. The instance is reusable; call a connect method again to - open a new session. +Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives: + +```typescript +await MTPClient.create({ + url: "https://host.example.com:4433", + pings: { intervalMs: 30_000 }, +}); +``` + +Use `pings: true` for the default interval. + +## Logger Events + +The SDK logger receives parsed events: + +```typescript +type MTPLogEvent = + | { hint: "info" | "warning"; type: string; data: unknown } + | { hint: "error"; type: string | "error"; error: string }; +``` + +Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`. + +## Advanced Raw Bindings + +Use `mtp/raw` when you need direct access to the generated `wasm-bindgen` API: + +```typescript +import init, { + ConnectionConfig, + WasmClient, + ed25519_generate, + keyring_from_ed25519, +} from "mtp/raw"; + +await init(); + +const rawClient = new WasmClient( + (state) => console.log("state", state), + (frame) => console.log("message", frame), + (error) => console.error(error), +); + +const config = new ConnectionConfig("https://host.example.com:4433"); +config.client_id = 42n; + +await rawClient.connect(config); +config.free(); +``` + +Raw callbacks receive parsed frames, not application-specific SDK objects: + +```typescript +interface ParsedFrame { + id?: number; + type: string; + sender?: bigint; + receiver?: bigint; + data: Record; + raw: Uint8Array; +} +``` + +Raw message helpers that remain available include: + +- `build_frame(messageType, data, options?)` +- `build_ping_frame(clientId, description, timestamp, data)` +- `parse_frame(frame)` +- `format_frame(frame)` +- `parse_auth_response(frame)` + +Raw crypto and key helpers include: + +- `ed25519_generate()` +- `ed25519_verify(publicKey, message, signature)` +- `keyring_from_ed25519(secretKey, publicKey)` +- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()` +- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()` +- `WasmEd25519Signer` +- `WasmChaCha20Poly1305` +- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key` + +Raw authenticated login and registration map directly to the Rust WASM layer: + +```typescript +const generated = ed25519_generate(); +const keyringBytes = keyring_from_ed25519(generated.secretKey, generated.publicKey); + +const registeredId = await rawClient.auth_register( + config, + hostPublicKeyBytes, + keyringBytes, +); + +const confirmedId = await rawClient.auth_connect( + config, + hostPublicKeyBytes, + keyringBytes, + registeredId, +); +``` + +A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly. diff --git a/example/Cargo.lock b/example/Cargo.lock index 104fc8b..0d5ae64 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -952,7 +952,6 @@ dependencies = [ name = "mtp-host" version = "0.1.0" dependencies = [ - "log", "mtp-codec", "mtp-common", "mtp-crypto", diff --git a/example/client/src/main.rs b/example/client/src/main.rs index a9931b0..e940e0c 100644 --- a/example/client/src/main.rs +++ b/example/client/src/main.rs @@ -41,11 +41,7 @@ async fn main() -> Result<(), Box> { println!("Connecting to 127.0.0.1:8080 ..."); - let config = ClientConfig { - url: "https://127.0.0.1:8080".into(), - server_cert: Some(cert_pem), - client_id: 0, - }; + let config = ClientConfig::new("https://127.0.0.1:8080").with_pinned_pem(cert_pem); let server_bundle = host_public_key.clone(); let (conn, keyring) = diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 01a748e..7280f2c 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -33,7 +33,7 @@ async fn main() -> Result<(), Box> { tls::export_webtransport_cert_hash(&cert_hash)?; println!("WebTransport certificate sha256: {cert_hash}"); - let (host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?; + let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?; keys::export_host_public_keys(&host_keyring)?; // The keyring is moved into the host config; keep a copy for decrypting the @@ -44,7 +44,7 @@ async fn main() -> Result<(), Box> { let (clients, next_id) = clients::load_client_db("clients.json")?; let clients_for_get = clients.clone(); - let get_existing_user = Box::new(move |id: u64| -> Option { + let get_existing_user = move |id: u64| -> Option { let result = clients_for_get.lock().unwrap().get(&id).cloned(); if result.is_some() { println!("Auth lookup: client ID {id} found"); @@ -52,12 +52,12 @@ async fn main() -> Result<(), Box> { eprintln!("Auth lookup: unknown client ID {id}"); } result - }); + }; let clients_for_register = clients.clone(); let next_id_for_register = next_id.clone(); let clients_path = "clients.json".to_string(); - let complete_register = Box::new(move |bundle: mtp::crypto::PublicKeyBundle| -> u64 { + let complete_register = move |bundle: mtp::crypto::PublicKeyBundle| -> u64 { let mut db = clients_for_register.lock().unwrap(); let mut nid = next_id_for_register.lock().unwrap(); let id = *nid; @@ -72,26 +72,22 @@ async fn main() -> Result<(), Box> { } println!("Registered new client with ID: {}", id); id - }); + }; println!("Starting MTP server on port 8080 ..."); - let config = HostConfig { - ip: std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - port: 8080, - tls_fullchain: cert_pem, - tls_key: key_pem, - require_authentication: true, - host_id, - host_keyring, - get_existing_user, - complete_register, - }; + let config = HostConfig::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), + 8080, + cert_pem, + key_pem, + ) + .with_authentication(host_keyring, get_existing_user, complete_register); let mut host = MTPHost::new(config).await?; println!("Server listening on {}", host.local_addr()); - while let Some(conn) = host.accept().await { + while let Some(conn) = host.accept().await? { println!( "\n--- New authenticated connection (version {}) ---", conn.version diff --git a/example/web-client/bun.lock b/example/web-client/bun.lock deleted file mode 100644 index 7bd1e94..0000000 --- a/example/web-client/bun.lock +++ /dev/null @@ -1,130 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "mtp-web-client", - "devDependencies": { - "typescript": "^5.4.0", - "vite": "^5.4.0", - }, - }, - }, - "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "nanoid": ["nanoid@3.3.15", "", { "bin": "bin/nanoid.cjs" }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - - "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], - } -} diff --git a/example/web-client/index.html b/example/web-client/index.html index 5a6b1b2..66e395b 100644 --- a/example/web-client/index.html +++ b/example/web-client/index.html @@ -23,11 +23,11 @@ - - + +
- +
diff --git a/example/web-client/package.json b/example/web-client/package.json index 8c64fc8..673f8e6 100644 --- a/example/web-client/package.json +++ b/example/web-client/package.json @@ -3,13 +3,17 @@ "private": true, "version": "0.1.0", "type": "module", + "packageManager": "pnpm@11.8.0", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, + "dependencies": { + "mtp": "workspace:*" + }, "devDependencies": { - "typescript": "^5.4.0", - "vite": "^5.4.0" + "typescript": "^6.0.3", + "vite": "^8.1.0" } } diff --git a/example/web-client/src/main.ts b/example/web-client/src/main.ts index 874a3e6..364860a 100644 --- a/example/web-client/src/main.ts +++ b/example/web-client/src/main.ts @@ -1,35 +1,34 @@ -import init, { - WasmClient, - ConnectionConfig, - ConnectionState, - WasmKeyring, - ed25519_generate, - keyring_from_ed25519, - build_demo_message, - format_frame, -} from "mtp-wasm"; +import { MTPClient } from "mtp"; +import type { MTPCredentialStorage, MTPLogEvent, ParsedFrame } from "mtp"; const STATUS = document.getElementById("status")!; const KEY_STATUS = document.getElementById("key-status")!; const SERVER_URL = document.getElementById("server-url") as HTMLInputElement; const HOST_PUBLIC_KEY = document.getElementById("host-public-key") as HTMLTextAreaElement; -const CLIENT_PUBLIC_KEY = document.getElementById("client-public-key") as HTMLTextAreaElement; +const CLIENT_CREDENTIALS = document.getElementById("client-credentials") as HTMLTextAreaElement; const GENERATE_KEYPAIR = document.getElementById("generate-keypair") as HTMLButtonElement; const CONNECT = document.getElementById("connect") as HTMLButtonElement; const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement; -const STORAGE_KEY = "mtp-web-client-keys"; +const CREDENTIALS_KEY = "mtp-web-client-credentials"; +const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key"; type SavedKeys = { clientId: string | null; - keyring: number[]; + keyring?: number[]; + keyringBytes?: number[]; hostPublicKey?: number[]; }; -let keyringBytes: Uint8Array | null = null; let clientId: bigint | null = null; let devCertHash = ""; +const credentialStorage: MTPCredentialStorage = { + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: (key) => localStorage.removeItem(key), +}; + function log(msg: string, cls = "") { const line = document.createElement("div"); line.textContent = msg; @@ -37,6 +36,35 @@ function log(msg: string, cls = "") { STATUS.appendChild(line); } +function renderStructured(value: unknown): string { + return JSON.stringify(value, (_key, item) => { + if (typeof item === "bigint") { + return item.toString(); + } + if (item instanceof Uint8Array) { + return { bytes: item.length, hex: bytesToHex(item.slice(0, 32)) }; + } + return item; + }); +} + +function formatParsedFrame(frame: ParsedFrame): string { + return renderStructured({ + id: frame.id, + type: frame.type, + sender: frame.sender, + receiver: frame.receiver, + data: frame.data, + rawBytes: frame.raw.length, + }); +} + +function renderLoggerEvent(event: MTPLogEvent): string { + return event.hint === "error" + ? `[${event.hint}] ${event.type}: ${event.error}` + : `[${event.hint}] ${event.type}: ${renderStructured(event.data)}`; +} + function setKeyStatus(msg: string) { KEY_STATUS.textContent = msg; } @@ -57,37 +85,35 @@ function hexToBytes(value: string): Uint8Array { return bytes; } -function saveKeys() { - if (!keyringBytes) { - return; - } - - let hostPublicKey: number[] | undefined; +function saveHostPublicKey() { try { - hostPublicKey = Array.from(hexToBytes(HOST_PUBLIC_KEY.value)); + localStorage.setItem(HOST_PUBLIC_KEY_KEY, bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value))); } catch { - hostPublicKey = undefined; + localStorage.removeItem(HOST_PUBLIC_KEY_KEY); } - - const data: SavedKeys = { - clientId: clientId?.toString() ?? null, - keyring: Array.from(keyringBytes), - hostPublicKey, - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } function loadKeys() { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(CREDENTIALS_KEY); + const savedHostPublicKey = localStorage.getItem(HOST_PUBLIC_KEY_KEY); + if (savedHostPublicKey) { + HOST_PUBLIC_KEY.value = savedHostPublicKey; + } + if (!raw) { - setKeyStatus("No client keypair generated yet."); + CLIENT_CREDENTIALS.value = ""; + setKeyStatus("No saved SDK credentials. The next connection will generate and store a reusable keyring."); return; } const data = JSON.parse(raw) as SavedKeys; - keyringBytes = new Uint8Array(data.keyring); clientId = data.clientId ? BigInt(data.clientId) : null; - CLIENT_PUBLIC_KEY.value = publicKeyHexFromKeyring(keyringBytes); + const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length; + CLIENT_CREDENTIALS.value = renderStructured({ + clientId: data.clientId, + keyringBytes: keyringLength, + hostPublicKeyBytes: data.hostPublicKey?.length ?? 0, + }); if (data.hostPublicKey) { HOST_PUBLIC_KEY.value = bytesToHex(new Uint8Array(data.hostPublicKey)); } @@ -108,7 +134,7 @@ async function loadHostPublicKey() { if (!hostPublicKey) return; HOST_PUBLIC_KEY.value = hostPublicKey; - saveKeys(); + saveHostPublicKey(); log(`Loaded host public key bundle (${hostPublicKey.length / 2} bytes).`); } catch { // Manual paste still works when the server has not exported the file yet. @@ -131,109 +157,82 @@ async function loadDevCertHash() { async function initWasm() { log("Loading WASM module..."); - await init(); - log(`WASM loaded. WebTransport supported: ${WasmClient.is_supported()}`); - CONNECT.disabled = !WasmClient.is_supported(); -} - -function createClient(): WasmClient { - return new WasmClient( - (state: number) => - log(`[state] ${ConnectionState[state] ?? state}`, "state"), - (data: Uint8Array) => { - try { - log(`Received: ${format_frame(data)}`, "received"); - } catch (e) { - log(`[message parse error] ${e}`, "error"); - } - }, - (err: any) => log(`[error] ${err}`, "error"), - ); -} - -function generateKeyringBytes(): Uint8Array { - const gen = ed25519_generate(); - const sk = gen.secretKey as Uint8Array; - const pk = gen.publicKey as Uint8Array; - gen.signer.free(); - return keyring_from_ed25519(sk, pk); -} - -function publicKeyHexFromKeyring(bytes: Uint8Array): string { - const keyring = WasmKeyring.from_bytes(bytes); - const publicBundle = keyring.public_key_bundle(); - const publicHex = bytesToHex(publicBundle.to_bytes()); - publicBundle.free(); - keyring.free(); - return publicHex; + await MTPClient.create({ url: SERVER_URL.value, storage: credentialStorage, credentialsStorageKey: CREDENTIALS_KEY }); + const supported = MTPClient.isSupported(); + log(`WASM loaded. WebTransport supported: ${supported}`); + CONNECT.disabled = !supported; } async function connect() { STATUS.textContent = ""; - if (!WasmClient.is_supported()) { + if (!MTPClient.isSupported()) { log("WebTransport is not supported in this browser.", "error"); return; } - if (!keyringBytes) { - log("Generate a client keypair first.", "error"); - return; - } - const hostPk = hexToBytes(HOST_PUBLIC_KEY.value); + saveHostPublicKey(); await loadDevCertHash(); - const client = createClient(); const serverUrl = SERVER_URL.value.trim(); - const config = new ConnectionConfig(serverUrl); - if (devCertHash) { - log(`Pinning WebTransport certificate hash: sha-256:${devCertHash}`); - config.server_certificate_hashes = [`sha-256:${devCertHash}`]; + const serverCertificateHashes = devCertHash ? [`sha-256:${devCertHash}`] : undefined; + if (serverCertificateHashes) { + log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`); } else { log("No WebTransport certificate hash loaded; relying on browser trust store.", "state"); } try { - let activeClientId: bigint; - if (clientId !== null) { - log(`Using saved client ID ${clientId}...`); - activeClientId = await client.auth_connect( - config, - hostPk, - keyringBytes, - clientId, - ); - log(`Authenticated as client ${activeClientId}`); - } else { - log("Registering generated client keypair..."); - activeClientId = await client.auth_register(config, hostPk, keyringBytes); - clientId = activeClientId; - saveKeys(); - log(`Registered with ID: ${activeClientId}`); - } + const client = await MTPClient.create({ + url: serverUrl, + hostPublicKey: hostPk, + storage: credentialStorage, + credentialsStorageKey: CREDENTIALS_KEY, + serverCertificateHashes, + pings: { intervalMs: 30_000 }, + logger(event) { + log(renderLoggerEvent(event), event.hint === "error" ? "error" : event.type === "state" ? "state" : ""); + }, + }); - log("\nSending demo message..."); - const frame = build_demo_message(activeClientId, keyringBytes, hostPk); - log(`Sending: ${format_frame(frame)}`, "state"); - await client.send(frame); - log(`Sent ${frame.length} bytes`); + client.subscribe("Pong", (frame: ParsedFrame) => { + log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); + }); + + const activeClientId = await client.connectOrRegister(); + clientId = activeClientId; + loadKeys(); + log(`Connected as client ${activeClientId}`); + + log("\nSending typed Ping..."); + await client.send("Ping", { + Description: "MTP web client send ping", + Timestamp: BigInt(Date.now()), + }, { sender: activeClientId }); + log("Typed Ping sent."); + + log("\nRequesting Pong by Ping frame id..."); + const response = await client.request("Ping", { + Description: "MTP web client request ping", + Timestamp: BigInt(Date.now()), + }, { sender: activeClientId, responseType: "Pong" }); + log(`Request response: ${formatParsedFrame(response)}`, "received"); log("\nClient running. Waiting for incoming messages..."); - } finally { - config.free(); + } catch (error) { + log(`[error] ${error}`, "error"); } } GENERATE_KEYPAIR.addEventListener("click", () => { try { - keyringBytes = generateKeyringBytes(); clientId = null; - saveKeys(); - CLIENT_PUBLIC_KEY.value = publicKeyHexFromKeyring(keyringBytes); - setKeyStatus("Generated client keypair. Not registered yet."); - log("Generated and saved a new client keypair."); + localStorage.removeItem(CREDENTIALS_KEY); + CLIENT_CREDENTIALS.value = ""; + setKeyStatus("Cleared saved credentials. The next connection will generate a new reusable keyring."); + log("Cleared saved SDK credentials."); } catch (e) { - log(`Key generation failed: ${e}`, "error"); + log(`Credential reset failed: ${e}`, "error"); console.error(e); } }); @@ -242,7 +241,7 @@ CONNECT.addEventListener("click", () => { connect().catch((e) => { log(`Fatal error: ${e}`, "error"); log( - `[fatal context] clientId=${clientId?.toString() ?? "unregistered"}, server=${SERVER_URL.value.trim()}, hostPkChars=${HOST_PUBLIC_KEY.value.replace(/[^0-9a-fA-F]/g, "").length}, keyringBytes=${keyringBytes?.length ?? 0}, certHash=${devCertHash || "none"}`, + `[fatal context] clientId=${clientId?.toString() ?? "unregistered"}, server=${SERVER_URL.value.trim()}, hostPkChars=${HOST_PUBLIC_KEY.value.replace(/[^0-9a-fA-F]/g, "").length}, hasStoredCredentials=${localStorage.getItem(CREDENTIALS_KEY) ? "yes" : "no"}, certHash=${devCertHash || "none"}`, "error", ); console.error(e); @@ -250,15 +249,15 @@ CONNECT.addEventListener("click", () => { }); CLEAR_KEYS.addEventListener("click", () => { - keyringBytes = null; clientId = null; - CLIENT_PUBLIC_KEY.value = ""; - localStorage.removeItem(STORAGE_KEY); - setKeyStatus("No client keypair generated yet."); - log("Cleared saved client keys."); + CLIENT_CREDENTIALS.value = ""; + localStorage.removeItem(CREDENTIALS_KEY); + localStorage.removeItem(HOST_PUBLIC_KEY_KEY); + setKeyStatus("No saved SDK credentials."); + log("Cleared saved SDK credentials and host public key."); }); -HOST_PUBLIC_KEY.addEventListener("change", saveKeys); +HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey); initWasm() .then(() => { diff --git a/example/web-client/src/messages.ts b/example/web-client/src/messages.ts deleted file mode 100644 index e7e1c17..0000000 --- a/example/web-client/src/messages.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { build_demo_message, build_ping_frame, parse_auth_response } from 'mtp-wasm'; - -export function buildAuthResponse( - response: Uint8Array, -): { - connected: boolean; - clientNonce: Uint8Array; - assignedId: bigint; - timestamp: bigint; - signature: Uint8Array; -} { - return parse_auth_response(response); -} - -export function buildDemoMessage( - clientId: bigint, - keyringBytes: Uint8Array, - hostBundle: Uint8Array, -): Uint8Array { - return build_demo_message(clientId, keyringBytes, hostBundle); -} - -export function buildPingFrame( - clientId: bigint, - description: string, - timestamp: bigint, - data?: Uint8Array, -): Uint8Array { - return build_ping_frame(clientId, description, timestamp, data ?? new Uint8Array()); -} diff --git a/example/web-client/tsconfig.json b/example/web-client/tsconfig.json index 6b0f16b..57b61ab 100644 --- a/example/web-client/tsconfig.json +++ b/example/web-client/tsconfig.json @@ -8,11 +8,7 @@ "isolatedModules": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "paths": { - "mtp-wasm": ["../../wasm/pkg"] - }, - "rootDir": "../.." + "forceConsistentCasingInFileNames": true }, - "include": ["src", "../../wasm/pkg"] + "include": ["src"] } diff --git a/example/web-client/vite.config.ts b/example/web-client/vite.config.ts index 904b1ca..6be6c39 100644 --- a/example/web-client/vite.config.ts +++ b/example/web-client/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; import fs from 'fs'; import path from 'path'; +import { mtp } from 'mtp/vite'; const devCertDir = path.resolve(__dirname, '../dev-cert'); const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem'); @@ -9,11 +10,7 @@ const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem'); const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath); export default defineConfig({ - resolve: { - alias: { - 'mtp-wasm': path.resolve(__dirname, '../../wasm/pkg'), - }, - }, + plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' })], server: { https: hasDevCert ? { @@ -21,8 +18,5 @@ export default defineConfig({ key: fs.readFileSync(keyPath), } : undefined, - fs: { - allow: ['.', path.resolve(__dirname, '../../wasm/pkg')], - }, }, }); diff --git a/flake.nix b/flake.nix index 5311243..cd24a7e 100644 --- a/flake.nix +++ b/flake.nix @@ -32,17 +32,7 @@ runtimeInputs = [rustToolchain]; text = '' export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" - cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings \ - -W unreachable-pub \ - -W clippy::cognitive_complexity \ - -W clippy::missing_docs_in_private_items \ - -W clippy::missing_errors_doc \ - -W clippy::missing_panics_doc \ - -W clippy::missing_safety_doc \ - -W clippy::undocumented_unsafe_blocks \ - -W clippy::pedantic \ - -W clippy::restriction \ - -A clippy::blanket_clippy_restriction_lints + cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub ''; }; @@ -56,21 +46,20 @@ buildAll = pkgs.writeShellApplication { name = "mtp-build-all"; - runtimeInputs = [rustToolchain pkgs.wasm-pack pkgs.bun clippyCheck macheteCheck]; + runtimeInputs = [rustToolchain pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; text = '' export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" - bun install --frozen-lockfile + timeout 60s pnpm install --frozen-lockfile cargo fmt --all --check cargo b cargo test --workspace --exclude mtp-wasm --all-features cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features mtp-clippy mtp-machete - bun run dup - RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release - bun install --cwd example/web-client --frozen-lockfile - bun run --cwd example/web-client build + pnpm run dup + pnpm run build + pnpm --filter mtp-web-client run build ''; }; @@ -91,6 +80,7 @@ rustToolchain cargo-machete wasm-pack + pnpm pkg-config openssl ]; @@ -140,9 +130,10 @@ name = "autoStart"; buildInputs = with pkgs; [ mprocs + pnpm ]; shellHook = '' - nix develop --command bash -c "mprocs 'cd wasm && wasm-pack build --target web --out-dir pkg && cd ../example/web-client && bun dev' 'cargo b && cd example && cargo r --bin server' 'cd example && cargo r --bin client'" + nix develop --command bash -c "mprocs 'cd wasm && wasm-pack build --target web --out-dir pkg && cd ../example/web-client && pnpm -r update mtp --latest && pnpm dev' 'cargo b && cd example && cargo r --bin server' 'cd example && cargo r --bin client'" exit ''; }; diff --git a/host/Cargo.toml b/host/Cargo.toml index 7a8a44e..9750284 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -9,7 +9,6 @@ mtp-codec = { path = "../codec", features = ["registry"] } mtp-transport = { path = "../transport", features = ["host"] } mtp-crypto = { path = "../crypto", optional = true } rand = "0.8" -log = "0.4" [features] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] diff --git a/host/src/lib.rs b/host/src/lib.rs index 4dd6b9e..54e58a1 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -5,6 +5,7 @@ use mtp_codec::{ use mtp_common::CommunicationError; use mtp_transport::{Policy, Receiver, Sender}; use std::net::IpAddr; +use std::{error::Error, fmt}; /* Host configuration. */ pub struct HostConfig { @@ -16,15 +17,81 @@ pub struct HostConfig { #[cfg(feature = "crypto")] pub require_authentication: bool, #[cfg(feature = "crypto")] - pub host_id: u64, - #[cfg(feature = "crypto")] pub host_keyring: mtp_crypto::Keyring, #[cfg(feature = "crypto")] - pub get_existing_user: Box Option + Send>, + pub get_existing_user: Box Option + Send + Sync>, #[cfg(feature = "crypto")] - pub complete_register: Box u64 + Send>, + pub complete_register: Box u64 + Send + Sync>, } +impl HostConfig { + pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec, tls_key: Vec) -> Self { + Self { + ip, + port, + tls_fullchain, + tls_key, + #[cfg(feature = "crypto")] + require_authentication: false, + #[cfg(feature = "crypto")] + host_keyring: mtp_crypto::Keyring::new( + mtp_crypto::KemPublicKey::new(Vec::new()), + mtp_crypto::KemPrivateKey::new(Vec::new()), + mtp_crypto::SignaturePqPublicKey::new(Vec::new()), + mtp_crypto::SignaturePqPrivateKey::new(Vec::new()), + mtp_crypto::SignaturePublicKey::new(Vec::new()), + mtp_crypto::SignaturePrivateKey::new(Vec::new()), + ), + #[cfg(feature = "crypto")] + get_existing_user: Box::new(|_| None), + #[cfg(feature = "crypto")] + complete_register: Box::new(|_| 0), + } + } + + #[cfg(feature = "crypto")] + pub fn with_authentication( + mut self, + host_keyring: mtp_crypto::Keyring, + get_existing_user: impl Fn(u64) -> Option + Send + Sync + 'static, + complete_register: impl Fn(mtp_crypto::PublicKeyBundle) -> u64 + Send + Sync + 'static, + ) -> Self { + self.require_authentication = true; + self.host_keyring = host_keyring; + self.get_existing_user = Box::new(get_existing_user); + self.complete_register = Box::new(complete_register); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcceptError { + Receive(CommunicationError), + MissingVersion, + UnsupportedVersion(Version), + AuthenticationFailed(String), + Send(CommunicationError), +} + +impl fmt::Display for AcceptError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Receive(error) => write!(f, "failed to receive opening message: {error}"), + Self::MissingVersion => write!( + f, + "opening message did not include a valid protocol version" + ), + Self::UnsupportedVersion(version) => { + write!(f, "unsupported protocol version: {version}") + } + Self::AuthenticationFailed(reason) => write!(f, "authentication failed: {reason}"), + Self::Send(error) => write!(f, "failed to send handshake message: {error}"), + } + } +} + +impl Error for AcceptError {} + #[cfg(feature = "crypto")] #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthState { @@ -80,16 +147,13 @@ impl MTPHost { * Accept an incoming connection, negotiate the protocol version, * and return a ready-to-use `MTPConnection`. * - * Returns `None` if the connection is closed or the version is - * incompatible. + * Returns `Ok(None)` if the listener is closed. Handshake and version + * negotiation failures are returned explicitly. */ - pub async fn accept(&mut self) -> Option { + pub async fn accept(&mut self) -> Result, AcceptError> { let (sender, receiver) = match self.transport.next().await { Some(pair) => pair, - None => { - log::warn!("accept: transport.next() returned None (listener closed)"); - return None; - } + None => return Ok(None), }; #[cfg(feature = "crypto")] @@ -100,10 +164,7 @@ impl MTPHost { // Read the first message (always encoded with reserved types). let first_msg = match receiver.receive().await { Ok(m) => m, - Err(e) => { - log::warn!("accept: receive failed: {e:?}"); - return None; - } + Err(e) => return Err(AcceptError::Receive(e)), }; /* @@ -115,13 +176,7 @@ impl MTPHost { */ let client_version = match extract_version(&first_msg) { Some(v) => v, - None => { - log::warn!( - "accept: extract_version failed on msg type {:?}", - first_msg.get_type() - ); - return None; - } + None => return Err(AcceptError::MissingVersion), }; let negotiated = match self @@ -129,15 +184,12 @@ impl MTPHost { .negotiate(std::slice::from_ref(&client_version)) { Some(v) => v, - None => { - log::warn!("accept: negotiate failed for client version {client_version:?}"); - return None; - } + None => return Err(AcceptError::UnsupportedVersion(client_version)), }; let codec = VersionedCodec::new(self.registry.clone()); - Some(MTPConnection { + Ok(Some(MTPConnection { version: negotiated, codec, sender, @@ -148,7 +200,7 @@ impl MTPHost { client_id: 0, #[cfg(feature = "crypto")] client_public_key: None, - }) + })) } pub fn local_addr(&self) -> std::net::SocketAddr { @@ -180,7 +232,7 @@ impl MTPHost { &mut self, sender: Sender, receiver: Receiver, - ) -> Option { + ) -> Result, AcceptError> { use mtp_crypto::{ Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519, verify_ml_dsa, @@ -207,32 +259,36 @@ impl MTPHost { .is_empty(); // Sign `payload` with the host keys (Ed25519 always, ML-DSA when configured). - let host_sign = |payload: &[u8]| -> Option<(Vec, Vec)> { - let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key).ok()?; - let sig = signer.sign(payload).ok()?; + let host_sign = |payload: &[u8]| -> Result<(Vec, Vec), AcceptError> { + let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; + let sig = signer + .sign(payload) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; let pq_sig = if pq_enabled { let pq = MlDsaSigner::new( &self.config.host_keyring.sig_pq_secret_key, &self.config.host_keyring.sig_pq_public_key, ) - .ok()?; - pq.sign(payload).ok()? + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; + pq.sign(payload) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))? } else { Vec::new() }; - Some((sig, pq_sig)) + Ok((sig, pq_sig)) }; // ===== Step 1: receive the client's unsigned hello ===== - let hello = receiver.receive().await.ok()?; + let hello = receiver.receive().await.map_err(AcceptError::Receive)?; let version_str = match hello.get_data(DataType::Version.to_id(&tm)) { DataValue::Str(s) => s.clone(), _ => { sender.close(); - return None; + return Err(AcceptError::MissingVersion); } }; - let client_version = Version::parse(&version_str)?; + let client_version = Version::parse(&version_str).ok_or(AcceptError::MissingVersion)?; let (flow, response_type) = if hello.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) { @@ -241,7 +297,9 @@ impl MTPHost { DataValue::UnsignedNumber(n) => *n as u64, _ => { sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "missing client id".into(), + )); } }; let bundle = match (self.config.get_existing_user)(cid) { @@ -253,7 +311,9 @@ impl MTPHost { .add_typed_default(DataType::Connected, DataValue::BoolFalse); let _ = sender.send(&rejection).await; sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "unknown client id".into(), + )); } }; ( @@ -263,10 +323,14 @@ impl MTPHost { } else if hello.get_type() == mtp_codec::CommunicationType::Register.to_id(&tm) { // REGISTER: the client presents the bundle it wants to register. let bundle = match hello.get_data(DataType::PublicKeys.to_id(&tm)) { - DataValue::Bytes(b) => PublicKeyBundle::from_bytes(b).ok()?, + DataValue::Bytes(b) => PublicKeyBundle::from_bytes(b).map_err(|_| { + AcceptError::AuthenticationFailed("invalid public key bundle".into()) + })?, _ => { sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "missing public keys".into(), + )); } }; let pk_bytes = bundle.as_bytes(); @@ -276,7 +340,9 @@ impl MTPHost { ) } else { sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "unexpected authentication message".into(), + )); }; // The id bound into the challenge (0 for register: none assigned yet). @@ -300,26 +366,35 @@ impl MTPHost { challenge_msg = challenge_msg .add_typed_default(DataType::PqSignature, DataValue::Bytes(chal_pq_sig)); } - sender.send(&challenge_msg).await.ok()?; + sender + .send(&challenge_msg) + .await + .map_err(AcceptError::Send)?; // ===== Step 3: receive and verify the client's proof ===== - let proof = receiver.receive().await.ok()?; + let proof = receiver.receive().await.map_err(AcceptError::Receive)?; if proof.get_type() != mtp_codec::CommunicationType::ChallengeResponse.to_id(&tm) { sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "missing challenge response".into(), + )); } let client_nonce = match proof.get_data(DataType::ClientNonce.to_id(&tm)) { DataValue::UnsignedNumber(n) => *n, _ => { sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "missing client nonce".into(), + )); } }; let sig_bytes = match proof.get_data(DataType::Signature.to_id(&tm)) { DataValue::Bytes(b) => b.clone(), _ => { sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "missing challenge signature".into(), + )); } }; let pq_sig_bytes: Vec = match proof.get_data(DataType::PqSignature.to_id(&tm)) { @@ -355,7 +430,9 @@ impl MTPHost { .add_typed_default(DataType::Connected, DataValue::BoolFalse); let _ = sender.send(&rejection).await; sender.close(); - return None; + return Err(AcceptError::AuthenticationFailed( + "client proof signature invalid".into(), + )); } // Proof verified: resolve the assigned id and retain the client's bundle. @@ -387,14 +464,17 @@ impl MTPHost { response.add_typed_default(DataType::PqSignature, DataValue::Bytes(host_pq_sig)); } - sender.send(&response).await.ok()?; - sender.finish_stream().await.ok()?; + sender.send(&response).await.map_err(AcceptError::Send)?; + sender.finish_stream().await.map_err(AcceptError::Send)?; // ===== Version negotiation ===== - let negotiated = self.registry.negotiate(&[client_version])?; + let negotiated = self + .registry + .negotiate(std::slice::from_ref(&client_version)) + .ok_or(AcceptError::UnsupportedVersion(client_version))?; let codec = VersionedCodec::new(self.registry.clone()); - Some(MTPConnection { + Ok(Some(MTPConnection { version: negotiated, codec, sender, @@ -402,7 +482,7 @@ impl MTPHost { auth_state: AuthState::Authenticated, client_id: assigned_id, client_public_key: Some(client_bundle), - }) + })) } } diff --git a/package.json b/package.json index 06100bd..29e6045 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,60 @@ { - "name": "mtp-wasm", + "name": "mtp", "version": "0.1.0", - "description": "Browser WASM interface for MTP.", + "description": "MTP TypeScript SDK", "type": "module", - "module": "./wasm/pkg/mtp_wasm.js", - "types": "./wasm/pkg/mtp_wasm.d.ts", + "packageManager": "pnpm@11.8.0", + "module": "./dist/sdk/index.js", + "types": "./dist/sdk/index.d.ts", "exports": { ".": { - "types": "./wasm/pkg/mtp_wasm.d.ts", - "import": "./wasm/pkg/mtp_wasm.js", - "default": "./wasm/pkg/mtp_wasm.js" + "types": "./dist/sdk/index.d.ts", + "import": "./dist/sdk/index.js", + "default": "./dist/sdk/index.js" + }, + "./raw": { + "types": "./dist/raw/index.d.ts", + "import": "./dist/raw/index.js", + "default": "./dist/raw/index.js" + }, + "./vite": { + "types": "./dist/vite/index.d.ts", + "import": "./dist/vite/index.js", + "default": "./dist/vite/index.js" + }, + "./type-map": { + "types": "./dist/type-map/index.d.ts", + "import": "./dist/type-map/index.js", + "default": "./dist/type-map/index.js" } }, "files": [ - "wasm/pkg/mtp_wasm.js", - "wasm/pkg/mtp_wasm.d.ts", - "wasm/pkg/mtp_wasm_bg.wasm", - "wasm/pkg/mtp_wasm_bg.wasm.d.ts" + "Cargo.lock", + "dist/", + "README.md", + "codec/Cargo.toml", + "codec/src/", + "common/Cargo.toml", + "common/src/", + "crypto/Cargo.toml", + "crypto/src/", + "type-map/Cargo.toml", + "type-map/build.rs", + "type-map/src/", + "wasm/.cargo/", + "wasm/Cargo.toml", + "wasm/src/", + "tsconfig.json" ], "scripts": { - "build": "MTP_TYPE_MAPS=$PWD/example/type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release", + "example": "pnpm install && pnpm run build:all && nix develop .#autoStart", + "build": "tsc && MTP_TYPE_MAPS=$PWD/example/type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release", "build:all": "nix run .#build-all", "dup": "jscpd --pattern '**/*.rs' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips ." }, "devDependencies": { - "jscpd": "5.0.11" + "@types/node": "^26.0.1", + "jscpd": "5.0.11", + "typescript": "^6.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8797120 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,599 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 + jscpd: + specifier: 5.0.11 + version: 5.0.11 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + example/web-client: + dependencies: + mtp: + specifier: workspace:* + version: link:../.. + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.1.0 + version: 8.1.0(@types/node@26.0.1) + +packages: + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + cpd-darwin-arm64@5.0.11: + resolution: {integrity: sha512-3QvH+4Dv7A7esVFM2tsRVWN3kn9EDu8dMYog6gYAVsCtxEf4xyxAwS/ef6LjC7/dh4+ATADFbg3H09A2fD//Qw==} + cpu: [arm64] + os: [darwin] + + cpd-darwin-x64@5.0.11: + resolution: {integrity: sha512-OvgM2ps0OFR5jUzx7+FK9URdJGxUzzM5KKk2F1V3vf1LooGDKwkivfIDyKsqEwp37zcbyUo7COvBpJXOT0dZmQ==} + cpu: [x64] + os: [darwin] + + cpd-linux-arm64-gnu@5.0.11: + resolution: {integrity: sha512-pXMINibAeruglni8ZajlXEefZHDs7QFSG+vPtkBDu7uiIMpNU8aoktgO2vP+PbIRFD0vHkqTMb64kDtIOqQcwQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + cpd-linux-x64-gnu@5.0.11: + resolution: {integrity: sha512-rQ7DuF0lH1HLzjGxlE0aEP2ycfhXgZH/CLSeS7FXNJ38lRVp+iXkrlcrrY6mC4WW/NgbL+DkF7/0lv3tFvGmvg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + cpd-linux-x64-musl@5.0.11: + resolution: {integrity: sha512-Yh+7Go5+fA++I5ssAZg7gUkDCT5CxnzCPvrspbwDrfnwaY6nNM5g1C6Vs0+GJhsspuAKwydJl4nf7jkxzMwRQw==} + cpu: [x64] + os: [linux] + libc: [musl] + + cpd-windows-x64-msvc@5.0.11: + resolution: {integrity: sha512-uV6w85qdfE0WJsrLcGw9A4Kv9ovSnlXZCybMK0esvuiJ7clgaZmDiDPozt5PvrOOShkK/NxzTZSORBSdVnquHA==} + cpu: [x64] + os: [win32] + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jscpd@5.0.11: + resolution: {integrity: sha512-NfLrFJHRM6rIf3oVcdZ4sfhMVop1qxi5r8aC99lpj55YC8hiWaN4VmzU2wcXTwoAo+NS4npXPs9EVEQJ6jyRlg==} + engines: {node: '>=18'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite@8.1.0: + resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + + cpd-darwin-arm64@5.0.11: + optional: true + + cpd-darwin-x64@5.0.11: + optional: true + + cpd-linux-arm64-gnu@5.0.11: + optional: true + + cpd-linux-x64-gnu@5.0.11: + optional: true + + cpd-linux-x64-musl@5.0.11: + optional: true + + cpd-windows-x64-msvc@5.0.11: + optional: true + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + jscpd@5.0.11: + optionalDependencies: + cpd-darwin-arm64: 5.0.11 + cpd-darwin-x64: 5.0.11 + cpd-linux-arm64-gnu: 5.0.11 + cpd-linux-x64-gnu: 5.0.11 + cpd-linux-x64-musl: 5.0.11 + cpd-windows-x64-msvc: 5.0.11 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.15: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + vite@8.1.0(@types/node@26.0.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.1 + fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7213eac --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - . + - example/web-client + +onlyBuiltDependencies: + - esbuild +allowBuilds: + esbuild: true diff --git a/src/raw/index.ts b/src/raw/index.ts new file mode 100644 index 0000000..21d9415 --- /dev/null +++ b/src/raw/index.ts @@ -0,0 +1,2 @@ +export { default } from "../../wasm/pkg/mtp_wasm.js"; +export * from "../../wasm/pkg/mtp_wasm.js"; diff --git a/src/sdk/index.ts b/src/sdk/index.ts new file mode 100644 index 0000000..5e34666 --- /dev/null +++ b/src/sdk/index.ts @@ -0,0 +1,514 @@ +import initWasm, { + ConnectionConfig, + ConnectionState, + WasmClient, + ed25519_generate, + keyring_from_ed25519, +} from "mtp/raw"; +import * as bindings from "mtp/raw"; +import type * as RawBindings from "../raw/index"; +import type { MTPCommunicationType } from "../type-map/index"; + +export type StorageValue = string | null; + +export interface MTPCredentialStorage { + getItem(key: string): StorageValue | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +} + +export type MTPStorage = MTPCredentialStorage; + +export type MTPLogEvent = + | { hint: "info" | "warning"; type: string; data: unknown } + | { hint: "error"; type: string | "error"; error: string }; + +export type ParsedFrame = RawBindings.ParsedFrame; + +export interface MTPCredentials { + clientId: bigint | string | number | null; + keyring: Uint8Array | number[]; + /** @deprecated Use keyring. Kept as a migration alias for existing callers. */ + keyringBytes?: Uint8Array | number[]; + hostPublicKey?: Uint8Array | number[]; +} + +export interface MTPClientOptions { + url: string; + hostPublicKey?: Uint8Array | string; + credentials?: MTPCredentials | string | null; + credentialsStorageKey?: string; + storage?: MTPCredentialStorage; + serverCertificateHashes?: string[]; + pings?: boolean | { intervalMs?: number }; + wasm?: RawBindings.InitInput | Promise | { module_or_path: RawBindings.InitInput | Promise }; + logger?: (event: MTPLogEvent) => void; +} + +export type Unsubscribe = () => void; + +export interface MTPSendOptions { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +} + +export interface MTPRequestOptions extends MTPSendOptions { + responseType?: MTPCommunicationType; +} + +type InternalCredentials = Omit & { + clientId: bigint | null; + keyringBytes: Uint8Array; + hostPublicKey?: Uint8Array; +}; + +type NormalizedMTPClientOptions = Omit & { + hostPublicKey?: Uint8Array; +}; + +const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; + +function emit(logger, event) { + if (typeof logger === "function") { + logger(event); + } +} + +function isErrorType(type) { + return type === "Error" || type.startsWith("Error") || [ + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "TooManyRequests", + "InternalServerError", + "BadGateway", + "ServiceUnavailable", + "GatewayTimeout", + ].includes(type); +} + +function errorMessage(frame) { + const data = frame?.data ?? {}; + return String(data.ErrorMessage ?? data.Error ?? data.Description ?? `Received ${frame?.type ?? "error"} frame`); +} + +async function storageGet(storage, key) { + return storage ? await storage.getItem(key) : null; +} + +async function storageSet(storage, key, value) { + if (storage) { + await storage.setItem(key, value); + } +} + +async function storageRemove(storage, key) { + if (storage) { + await storage.removeItem(key); + } +} + +function isBytes(value) { + return value instanceof Uint8Array || Array.isArray(value); +} + +function bytesFrom(value, name) { + if (value instanceof Uint8Array) { + return value; + } + if (Array.isArray(value)) { + return new Uint8Array(value); + } + throw new TypeError(`${name} must be a Uint8Array or number[]`); +} + +function bytesFromString(value, name) { + const trimmed = value.trim(); + if (!trimmed) { + throw new TypeError(`${name} must not be empty`); + } + + const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); + if (/^[0-9a-fA-F]+$/.test(hex)) { + if (hex.length % 2 !== 0) { + throw new TypeError(`${name} hex string has an odd length`); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + if (typeof atob === "function") { + const binary = atob(trimmed); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(trimmed, "base64")); + } + + throw new TypeError(`${name} must be bytes, hex, or base64`); +} + +function normalizeBytes(value, name) { + if (typeof value === "string") { + return bytesFromString(value, name); + } + return bytesFrom(value, name); +} + +function normalizeCredentials(value) { + if (!value) { + return null; + } + + if (typeof value === "string") { + return JSON.parse(value); + } + + return value; +} + +function toBigInt(value) { + if (value == null || value === "") { + return null; + } + return typeof value === "bigint" ? value : BigInt(value); +} + +function generateKeyringBytes() { + const generated = ed25519_generate(); + try { + return keyring_from_ed25519(generated.secretKey, generated.publicKey); + } finally { + generated.signer?.free?.(); + } +} + +function serializeCredentials(credentials) { + return JSON.stringify({ + clientId: credentials.clientId?.toString() ?? null, + keyring: Array.from(credentials.keyringBytes ?? []), + hostPublicKey: credentials.hostPublicKey ? Array.from(credentials.hostPublicKey) : undefined, + }); +} + +function deserializeCredentials(credentials) { + const normalized = normalizeCredentials(credentials); + if (!normalized) { + return null; + } + + const keyring = normalized.keyring ?? normalized.keyringBytes; + if (!isBytes(keyring)) { + throw new TypeError("credentials.keyring must be a Uint8Array or number[]"); + } + + return { + clientId: toBigInt(normalized.clientId), + keyringBytes: bytesFrom(keyring, "credentials.keyring"), + hostPublicKey: normalized.hostPublicKey == null + ? undefined + : normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey"), + }; +} + +function publicCredentials(credentials) { + if (!credentials) { + return null; + } + return { + clientId: credentials.clientId, + keyring: credentials.keyringBytes, + keyringBytes: credentials.keyringBytes, + hostPublicKey: credentials.hostPublicKey, + }; +} + +function validateOptions(options) { + if (!options || typeof options !== "object") { + throw new TypeError("MTPClient.create requires an options object"); + } + if (typeof options.url !== "string" || !options.url.trim()) { + throw new TypeError("MTPClient.create requires a non-empty url"); + } + if (options.storage) { + for (const method of ["getItem", "setItem", "removeItem"]) { + if (typeof options.storage[method] !== "function") { + throw new TypeError(`storage.${method} must be a function`); + } + } + } +} + +export class MTPClient { + #credentials: InternalCredentials | null; + #options: NormalizedMTPClientOptions; + readonly raw: { + client: RawBindings.WasmClient; + bindings: typeof RawBindings; + }; + + private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) { + this.#options = options; + this.#credentials = deserializeCredentials(options.credentials); + this.raw = { client, bindings }; + } + + static async create(options: MTPClientOptions = {} as MTPClientOptions): Promise { + validateOptions(options); + await initWasm(options.wasm); + + const normalizedOptions = { + ...options, + hostPublicKey: options.hostPublicKey == null + ? undefined + : normalizeBytes(options.hostPublicKey, "hostPublicKey"), + }; + + let sdk: MTPClient | undefined; + const client = new WasmClient( + (state) => emit(normalizedOptions.logger, { + hint: "info", + type: "state", + data: ConnectionState[state] ?? state, + }), + (frame) => { + if (sdk) { + sdk.#handleFrame(frame); + } + }, + (error) => emit(normalizedOptions.logger, { + hint: "error", + type: "error", + error: String(error), + }), + ); + + sdk = new MTPClient(normalizedOptions, client); + await sdk.#loadStoredCredentials(); + if (!sdk.#credentials) { + sdk.#credentials = { + clientId: null, + keyringBytes: generateKeyringBytes(), + hostPublicKey: normalizedOptions.hostPublicKey, + }; + } else if (!sdk.#credentials.hostPublicKey && normalizedOptions.hostPublicKey) { + sdk.#credentials = { ...sdk.#credentials, hostPublicKey: normalizedOptions.hostPublicKey }; + } else if (!normalizedOptions.hostPublicKey && sdk.#credentials.hostPublicKey) { + sdk.#options = { ...sdk.#options, hostPublicKey: sdk.#credentials.hostPublicKey }; + } + return sdk; + } + + static isSupported(): boolean { + return WasmClient.is_supported(); + } + + get credentials(): MTPCredentials | null { + return publicCredentials(this.#credentials); + } + + async #loadStoredCredentials() { + if (this.#credentials || !this.#options.storage) { + return; + } + + const stored = await storageGet( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + ); + this.#credentials = deserializeCredentials(stored); + } + + #connectionConfig() { + const config = new ConnectionConfig(this.#options.url); + if (this.#options.serverCertificateHashes) { + config.server_certificate_hashes = this.#options.serverCertificateHashes; + } + return config; + } + + async connect(): Promise { + if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { + await this.#connectAuthenticated(); + return; + } + + const config = this.#connectionConfig(); + try { + await this.raw.client.connect(config); + this.#startPings(0n); + } finally { + config.free(); + } + } + + async #connectAuthenticated() { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.connect requires hostPublicKey for authenticated connections"); + } + if (!this.#credentials?.keyringBytes?.length || this.#credentials.clientId == null) { + throw new Error("MTPClient.connect requires credentials with clientId and keyring"); + } + + const config = this.#connectionConfig(); + try { + const clientId = await this.raw.client.auth_connect( + config, + this.#options.hostPublicKey, + this.#credentials.keyringBytes, + this.#credentials.clientId, + ); + this.#credentials = { ...this.#credentials, clientId }; + await this.#persistCredentials(); + this.#startPings(clientId); + return clientId; + } finally { + config.free(); + } + } + + async register(): Promise { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.register requires hostPublicKey"); + } + if (!this.#credentials?.keyringBytes?.length) { + this.#credentials = { + clientId: null, + keyringBytes: generateKeyringBytes(), + hostPublicKey: this.#options.hostPublicKey, + }; + } + + const config = this.#connectionConfig(); + try { + const clientId = await this.raw.client.auth_register( + config, + this.#options.hostPublicKey, + this.#credentials.keyringBytes, + ); + this.#credentials = { ...this.#credentials, clientId }; + await this.#persistCredentials(); + this.#startPings(clientId); + return clientId; + } finally { + config.free(); + } + } + + async connectOrRegister(): Promise { + return this.#credentials?.clientId == null + ? await this.register() + : await this.#connectAuthenticated(); + } + + async #persistCredentials() { + await storageSet( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + serializeCredentials(this.#credentials), + ); + } + + async clearCredentials(): Promise { + this.#credentials = null; + await storageRemove( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + ); + } + + #startPings(clientId) { + const pings = this.#options.pings; + if (!pings) { + this.raw.client.stop_protocol_pings(); + return; + } + const intervalMs = typeof pings === "object" ? pings.intervalMs ?? 30_000 : 30_000; + this.raw.client.start_protocol_pings(intervalMs, clientId); + } + + #buildFrame(typeOrFrame, data, options) { + if (typeOrFrame instanceof Uint8Array) { + return typeOrFrame; + } + if (typeof typeOrFrame !== "string" || !typeOrFrame) { + throw new TypeError("message type must be a non-empty string or Uint8Array frame"); + } + if (data == null || typeof data !== "object" || Array.isArray(data)) { + throw new TypeError("message data must be an object"); + } + return this.raw.bindings.build_frame(typeOrFrame, data, options ?? {}); + } + + async send(message: Uint8Array): Promise; + async send(type: MTPCommunicationType, data: Record, options?: MTPSendOptions): Promise; + async send(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record, options?: MTPSendOptions): Promise { + const message = this.#buildFrame(typeOrFrame, data, options); + + try { + const frame = this.raw.bindings.parse_frame(message); + emit(this.#options.logger, isErrorType(frame.type) + ? { hint: "error", type: frame.type, error: errorMessage(frame) } + : { hint: "info", type: frame.type, data: frame.data }); + } catch (error) { + emit(this.#options.logger, { + hint: "error", + type: "error", + error: String(error), + }); + } + + await this.raw.client.send(message); + } + + async request(message: Uint8Array, data?: never, options?: MTPRequestOptions): Promise; + async request(type: MTPCommunicationType, data: Record, options?: MTPRequestOptions): Promise; + async request(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record, options: MTPRequestOptions = {}): Promise { + const frame = this.#buildFrame(typeOrFrame, data, options); + return await this.raw.client.request(frame, options.responseType ?? null); + } + + subscribe(type: MTPCommunicationType, handler: (message: ParsedFrame) => void): Unsubscribe { + if (typeof type !== "string" || !type) { + throw new TypeError("subscription type must be a non-empty string"); + } + if (typeof handler !== "function") { + throw new TypeError("subscription handler must be a function"); + } + const id = this.raw.client.subscribe(type, handler); + return () => this.raw.client.unsubscribe(id); + } + + #handleFrame(frame) { + if (isErrorType(frame.type)) { + emit(this.#options.logger, { + hint: "error", + type: frame.type, + error: errorMessage(frame), + }); + } else { + emit(this.#options.logger, { + hint: "info", + type: frame.type, + data: frame.data, + }); + } + } + + disconnect(): void { + this.raw.client.stop_protocol_pings(); + this.raw.client.disconnect(); + } +} + +export { bindings as raw }; diff --git a/src/type-map/index.ts b/src/type-map/index.ts new file mode 100644 index 0000000..852f9af --- /dev/null +++ b/src/type-map/index.ts @@ -0,0 +1,5 @@ +export type MTPCommunicationType = string; +export type MTPDataType = string; + +export const communicationTypes: readonly MTPCommunicationType[] = []; +export const dataTypes: readonly MTPDataType[] = []; diff --git a/src/vite/index.ts b/src/vite/index.ts new file mode 100644 index 0000000..a906000 --- /dev/null +++ b/src/vite/index.ts @@ -0,0 +1,409 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import { spawn } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface MTPVitePluginOptions { + typeMaps: string; + release?: boolean; + wasmPackArgs?: string[]; + outDir?: string; +} + +export interface VitePlugin { + name: string; + config?: (...args: any[]) => unknown; + buildStart?: (...args: any[]) => unknown; + configureServer?: (...args: any[]) => unknown; +} + +const packageRoot = process.env.MTP_PACKAGE_ROOT + ? path.resolve(process.env.MTP_PACKAGE_ROOT) + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const rawEntryName = "mtp_wasm.js"; +const wasmEntryName = "mtp_wasm_bg.wasm"; +const typeMapEntryName = "mtp_type_map.js"; + +const reservedCommunicationTypes = [ + "Identification", + "IdentificationResponse", + "Register", + "RegisterResponse", + "Challenge", + "ChallengeResponse", + "Ping", + "Pong", + "Disconnect", + "Redirect", + "Shutdown", + "Error", + "ErrorParsing", + "ErrorBadVersion", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "TooManyRequests", + "InternalServerError", + "BadGateway", + "ServiceUnavailable", + "GatewayTimeout", +]; + +const reservedDataTypes = [ + "Version", + "Id", + "ClientNonce", + "ServerNonce", + "PublicKeys", + "Signature", + "PqSignature", + "Description", + "Connected", + "Timestamp", + "Error", + "ErrorParsing", + "ErrorMessage", +]; + +function normalizeOptions(options) { + if (!options?.typeMaps) { + throw new Error("mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })"); + } + + return options; +} + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function devServerPath(root: string, filePath: string) { + const relativePath = path.relative(root, filePath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return null; + } + + return `/${relativePath.split(path.sep).join("/")}`; +} + +async function hashPackageInputs() { + const hash = crypto.createHash("sha256"); + const inputs = [ + "wasm/Cargo.toml", + "wasm/src", + "common/Cargo.toml", + "common/src", + "codec/Cargo.toml", + "codec/src", + "crypto/Cargo.toml", + "crypto/src", + "type-map/Cargo.toml", + "type-map/build.rs", + "type-map/src", + ]; + + async function addPath(relativePath) { + const absolutePath = path.join(packageRoot, relativePath); + const stat = await fs.stat(absolutePath).catch(() => null); + if (!stat) { + return; + } + + if (stat.isDirectory()) { + const entries = await fs.readdir(absolutePath); + for (const entry of entries.sort()) { + await addPath(path.join(relativePath, entry)); + } + return; + } + + hash.update(relativePath); + hash.update(await fs.readFile(absolutePath)); + } + + for (const input of inputs) { + await addPath(input); + } + + return hash.digest("hex"); +} + +function quoteList(values) { + return values.length === 0 + ? "never" + : values.map((value) => JSON.stringify(value)).join(" | "); +} + +function parseTypeMapYaml(source, filePath) { + const communicationTypes = new Set(reservedCommunicationTypes); + const dataTypes = new Set(reservedDataTypes); + let section = null; + let sectionIndent = -1; + + for (const [index, originalLine] of source.split(/\r?\n/).entries()) { + const withoutComment = originalLine.replace(/\s+#.*$/, ""); + if (!withoutComment.trim()) { + continue; + } + if (/^\t/.test(withoutComment)) { + throw new Error(`${filePath}:${index + 1}: tabs are not supported in type-maps.yaml indentation`); + } + + const indent = withoutComment.match(/^ */)?.[0].length ?? 0; + const trimmed = withoutComment.trim(); + const sectionMatch = trimmed.match(/^(CommunicationTypes|DataTypes):\s*$/); + if (sectionMatch) { + section = sectionMatch[1]; + sectionIndent = indent; + continue; + } + + if (section && indent <= sectionIndent) { + section = null; + } + if (!section) { + continue; + } + + const entryMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*\d+\s*$/); + if (!entryMatch) { + throw new Error(`${filePath}:${index + 1}: expected '${section}' entries as 'Name: numeric_id'`); + } + + if (section === "CommunicationTypes") { + communicationTypes.add(entryMatch[1]); + } else { + dataTypes.add(entryMatch[1]); + } + } + + return { + communicationTypes: [...communicationTypes].sort(), + dataTypes: [...dataTypes].sort(), + }; +} + +function generateTypeMapModule(metadata) { + const js = `export const communicationTypes = ${JSON.stringify(metadata.communicationTypes, null, 2)};\nexport const dataTypes = ${JSON.stringify(metadata.dataTypes, null, 2)};\n`; + const dts = `export type MTPCommunicationType = ${quoteList(metadata.communicationTypes)};\nexport type MTPDataType = ${quoteList(metadata.dataTypes)};\nexport declare const communicationTypes: readonly MTPCommunicationType[];\nexport declare const dataTypes: readonly MTPDataType[];\n`; + return { js, dts }; +} + +async function writeTypeMapModule(outDir, typeMapsPath) { + const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => { + throw new Error(`Failed to read type map '${typeMapsPath}': ${error.message}`); + }); + const metadata = parseTypeMapYaml(source, typeMapsPath); + const module = generateTypeMapModule(metadata); + await fs.mkdir(outDir, { recursive: true }); + await fs.writeFile(path.join(outDir, typeMapEntryName), module.js); + await fs.writeFile(path.join(outDir, "mtp_type_map.d.ts"), module.dts); + await fs.writeFile(path.join(outDir, `${typeMapEntryName}.d.ts`), module.dts); + return source; +} + +async function copyWasmBuildInputs(buildRoot) { + const inputs = [ + "Cargo.lock", + "wasm", + "common", + "codec", + "crypto", + "type-map", + ]; + + for (const input of inputs) { + const source = path.join(packageRoot, input); + if (!await pathExists(source)) { + continue; + } + + await fs.cp(source, path.join(buildRoot, input), { recursive: true }); + } +} + +async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) { + const buildRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mtp-wasm-")); + const args = [ + "build", + path.join(buildRoot, "wasm"), + "--target", + "web", + "--out-dir", + outDir, + ]; + if (release) { + args.push("--release"); + } else { + args.push("--dev"); + } + args.push(...wasmPackArgs); + + try { + await copyWasmBuildInputs(buildRoot); + await new Promise((resolve, reject) => { + const child = spawn("wasm-pack", args, { + cwd: buildRoot, + env: { + ...process.env, + MTP_TYPE_MAPS: typeMapsPath, + RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"].filter(Boolean).join(" "), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + reject(new Error("Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.")); + } else { + reject(error); + } + }); + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim())); + } + }); + }); + } finally { + await fs.rm(buildRoot, { recursive: true, force: true }); + } +} + +async function buildIfNeeded(state, force = false) { + if (state.buildPromise) { + return state.buildPromise; + } + + state.buildPromise = (async () => { + const typeMapSource = await writeTypeMapModule(state.outDir, state.typeMapsPath); + const packageInputs = await hashPackageInputs(); + const fingerprint = crypto + .createHash("sha256") + .update(JSON.stringify({ + packageRoot, + packageInputs, + typeMapsPath: state.typeMapsPath, + typeMapSource, + release: state.release, + wasmPackArgs: state.wasmPackArgs, + })) + .digest("hex"); + const stampPath = path.join(state.outDir, ".mtp-build.json"); + const rawEntryPath = path.join(state.outDir, rawEntryName); + const wasmPath = path.join(state.outDir, wasmEntryName); + let previousFingerprint = null; + try { + previousFingerprint = JSON.parse(await fs.readFile(stampPath, "utf8")).fingerprint; + } catch { + previousFingerprint = null; + } + + if (!force && previousFingerprint === fingerprint && await pathExists(rawEntryPath) && await pathExists(wasmPath)) { + return; + } + + console.info("\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)"); + await runWasmPack(state); + console.log("\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m"); + await fs.writeFile(stampPath, JSON.stringify({ fingerprint, builtAt: new Date().toISOString() }, null, 2)); + })().finally(() => { + state.buildPromise = null; + }); + + return state.buildPromise; +} + +export function mtp(options: MTPVitePluginOptions): VitePlugin { + const normalized = normalizeOptions(options); + const state = { + outDir: null, + typeMapsPath: null, + release: true, + wasmPackArgs: normalized.wasmPackArgs ?? [], + buildPromise: null, + }; + + return { + name: "mtp", + async config(config, env) { + const root = path.resolve(config.root ?? process.cwd()); + state.typeMapsPath = path.resolve(root, normalized.typeMaps); + state.outDir = path.resolve(root, normalized.outDir ?? path.join("node_modules", ".vite", "mtp")); + state.release = normalized.release ?? env.command === "build"; + + if (!await pathExists(state.typeMapsPath)) { + throw new Error(`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`); + } + + await buildIfNeeded(state); + + return { + resolve: { + preserveSymlinks: true, + alias: { + "mtp/raw": path.join(state.outDir, rawEntryName), + "mtp/type-map": path.join(state.outDir, typeMapEntryName), + }, + }, + }; + }, + buildStart() { + this.addWatchFile(state.typeMapsPath); + }, + configureServer(server) { + const wasmPath = path.join(state.outDir, wasmEntryName); + const wasmUrl = devServerPath(server.config.root, wasmPath); + if (wasmUrl) { + server.middlewares.use(async (req, res, next) => { + if (!req.url || new URL(req.url, "http://localhost").pathname !== wasmUrl) { + next(); + return; + } + + try { + res.setHeader("Content-Type", "application/wasm"); + res.end(await fs.readFile(wasmPath)); + } catch (error) { + next(error); + } + }); + } + + server.watcher.add(state.typeMapsPath); + server.watcher.on("change", async (changedPath) => { + if (path.resolve(changedPath) !== state.typeMapsPath) { + return; + } + try { + await buildIfNeeded(state, true); + server.moduleGraph.invalidateAll(); + server.ws.send({ type: "full-reload" }); + } catch (error) { + server.config.logger.error(error instanceof Error ? error.message : String(error)); + } + }); + }, + }; +} + +export const mtpVitePlugin = mtp; +export default mtpVitePlugin; diff --git a/transport/src/connection.rs b/transport/src/connection.rs index 73be00f..21f8121 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -53,6 +53,45 @@ impl Default for Policy { } } +impl Policy { + pub fn with_send_mode(mut self, send_mode: SendMode) -> Self { + self.send_mode = send_mode; + self + } + + pub fn with_max_message_size(mut self, max_message_size: u64) -> Self { + self.max_message_size = max_message_size; + self + } + + pub fn with_timeouts( + mut self, + open_stream_timeout: Duration, + write_timeout: Duration, + read_timeout: Duration, + ) -> Self { + self.open_stream_timeout = open_stream_timeout; + self.write_timeout = write_timeout; + self.read_timeout = read_timeout; + self + } + + pub fn with_keep_alive(mut self, keep_alive_interval: Option) -> Self { + self.keep_alive_interval = keep_alive_interval; + self + } + + pub fn with_max_idle_timeout(mut self, max_idle_timeout: Option) -> Self { + self.max_idle_timeout = max_idle_timeout; + self + } + + pub fn with_receiver_queue_capacity(mut self, receiver_queue_capacity: usize) -> Self { + self.receiver_queue_capacity = receiver_queue_capacity; + self + } +} + enum ReceivedFrame { Message(CommunicationValue), ClosedByPeer, diff --git a/transport/src/host.rs b/transport/src/host.rs index 375c4bb..0927aa0 100644 --- a/transport/src/host.rs +++ b/transport/src/host.rs @@ -1,5 +1,4 @@ use crate::{ConnectionHandle, Policy, Receiver, Sender}; -use log; use mtp_common::CommunicationError; use rustls::pki_types::{PrivateKeyDer, pem::PemObject}; use std::net::{IpAddr, SocketAddr}; @@ -14,13 +13,7 @@ pub struct Host { impl Host { pub async fn next(&mut self) -> Option<(Sender, Receiver)> { - log::warn!("[transport Host::next] waiting on recv..."); - let result = self.incoming.recv().await; - match &result { - Some(_) => log::warn!("[transport Host::next] received connection"), - None => log::warn!("[transport Host::next] incoming channel closed - sender dropped"), - } - result + self.incoming.recv().await } pub fn local_addr(&self) -> std::net::SocketAddr { @@ -66,19 +59,13 @@ pub async fn host( let policy = Arc::new(policy); let task = tokio::spawn(async move { - log::warn!("[transport bg task] started"); loop { - log::warn!("[transport bg task] waiting for connection..."); let incoming_session = endpoint.accept().await; - log::warn!("[transport bg task] got incoming session"); let request = match incoming_session.await { - Ok(req) => { - log::warn!("[transport bg task] got request"); - req - } + Ok(req) => req, Err(e) => { - log::warn!("[transport bg task] incoming session error: {e}"); + log::debug!("incoming WebTransport session failed: {e}"); continue; } }; @@ -87,19 +74,15 @@ pub async fn host( .accept_with_headers([("sec-webtransport-http3-draft02", "1")]) .await { - Ok(conn) => { - log::warn!("[transport bg task] connection accepted"); - conn - } + Ok(conn) => conn, Err(e) => { - log::warn!("[transport bg task] accept error: {e}"); + log::debug!("WebTransport request accept failed: {e}"); continue; } }; let incoming_tx = incoming_tx.clone(); let policy = policy.clone(); - eprintln!("[transport bg task] spawning handle_connection"); tokio::spawn(handle_connection(connection, incoming_tx, policy)); } }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1664c17 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "emitDeclarationOnly": false, + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": { + "mtp/raw": ["src/raw/index.ts"], + "mtp/type-map": ["src/type-map/index.ts"] + }, + "strict": false, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/raw/**/*.ts", + "src/sdk/**/*.ts", + "src/type-map/**/*.ts", + "src/vite/**/*.ts" + ] +} diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 6ea67d1..bdc68aa 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -13,22 +13,7 @@ crate-type = ["cdylib"] wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = [ - "console", - "WebTransport", - "WebTransportOptions", - "WebTransportHash", - "WebTransportBidirectionalStream", - "WebTransportCloseInfo", - "WebTransportDatagramDuplexStream", - "WebTransportError", - "WebTransportReceiveStream", - "WebTransportSendStream", - "ReadableStream", - "ReadableStreamDefaultReader", - "WritableStream", - "WritableStreamDefaultWriter", -] } +futures-channel = "0.3" console_error_panic_hook = "0.1" hex = "0.4" diff --git a/wasm/pkg/mtp_wasm.d.ts b/wasm/pkg/mtp_wasm.d.ts index c99ca38..2b169dc 100644 --- a/wasm/pkg/mtp_wasm.d.ts +++ b/wasm/pkg/mtp_wasm.d.ts @@ -1,6 +1,17 @@ /* tslint:disable */ /* eslint-disable */ +export interface ParsedFrame { + id?: number; + type: string; + sender?: bigint; + receiver?: bigint; + data: Record; + raw: Uint8Array; +} + + + export class ConnectionConfig { free(): void; [Symbol.dispose](): void; @@ -66,7 +77,12 @@ export class WasmClient { disconnect(): void; static is_supported(): boolean; constructor(on_state_change: Function, on_message: Function, on_error: Function); + request(frame: Uint8Array, response_type?: string | null): Promise; send(frame: Uint8Array): Promise; + start_protocol_pings(interval_ms: number, client_id: bigint): void; + stop_protocol_pings(): void; + subscribe(message_type: string, callback: Function): number; + unsubscribe(id: number): boolean; readonly state: number; } @@ -105,6 +121,15 @@ export class WasmKeyring { to_bytes(): Uint8Array; } +/** + * Log severity used by the public SDK when translating raw WASM events. + */ +export enum WasmLogHint { + Info = 0, + Warning = 1, + Error = 2, +} + export class WasmPublicKeyBundle { private constructor(); free(): void; @@ -117,25 +142,27 @@ export class WasmPublicKeyBundle { } /** - * Build a demo Ping frame with encrypted and signed containers - * (mirrors the Rust client example but uses only reserved data types). + * Minimal message router used by higher-level SDK subscription code. */ -export function build_demo_message(client_id: bigint, keyring_bytes: Uint8Array, host_bundle_bytes: Uint8Array): Uint8Array; +export class WasmSubscriptionRouter { + free(): void; + [Symbol.dispose](): void; + dispatch(message_type: string, message: any): boolean; + constructor(); + subscribe(message_type: string, callback: Function): void; + unsubscribe(message_type: string): boolean; +} /** - * Build a simple Ping frame with description, timestamp, and optional data. + * Build a typed MTP frame using generated communication/data type names. + */ +export function build_frame(message_type: string, data: any, options: any): Uint8Array; + +/** + * Build a protocol-level Ping frame with description, timestamp, and optional data. */ export function build_ping_frame(client_id: bigint, description: string, timestamp: bigint, data: Uint8Array): Uint8Array; -/** - * Build a request frame with the given communication type name, request ID, and JSON data. - * - * - `comm_type`: communication type name (e.g. "get_model", "rank_models") or PascalCase - * - `id`: request ID for response correlation - * - `json_data`: JSON-stringified request payload - */ -export function build_request_frame(comm_type: string, id: number, json_data: string): Uint8Array; - /** * Generate a fresh Ed25519 keypair. * @@ -169,9 +196,9 @@ export function main(): void; export function parse_auth_response(response: Uint8Array): any; /** - * Parse a response frame into a JSON string containing `_id`, `_type`, and data fields. + * Parse any MTP frame into structured JavaScript data. */ -export function parse_response_frame(frame: Uint8Array): string; +export function parse_frame(frame: Uint8Array): ParsedFrame; /** * Derive a 32-byte encryption key from `ikm` with `salt` and `context`. @@ -197,6 +224,11 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; + readonly build_frame: (a: number, b: number, c: any, d: any) => [number, number, number, number]; + readonly build_ping_frame: (a: bigint, b: number, c: number, d: bigint, e: number, f: number) => [number, number, number, number]; + readonly format_frame: (a: number, b: number) => [number, number, number, number]; + readonly parse_auth_response: (a: number, b: number) => [number, number, number]; + readonly parse_frame: (a: number, b: number) => [number, number, number]; readonly __wbg_wasmchacha20poly1305_free: (a: number, b: number) => void; readonly __wbg_wasmed25519signer_free: (a: number, b: number) => void; readonly __wbg_wasmkeyring_free: (a: number, b: number) => void; @@ -222,32 +254,36 @@ export interface InitOutput { readonly wasmpublickeybundle_sig_cl_public_key: (a: number) => [number, number]; readonly wasmpublickeybundle_sig_pq_public_key: (a: number) => [number, number]; readonly wasmpublickeybundle_to_bytes: (a: number) => [number, number]; - readonly build_demo_message: (a: bigint, b: number, c: number, d: number, e: number) => [number, number, number, number]; - readonly build_ping_frame: (a: bigint, b: number, c: number, d: bigint, e: number, f: number) => [number, number, number, number]; - readonly build_request_frame: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; - readonly format_frame: (a: number, b: number) => [number, number, number, number]; - readonly parse_auth_response: (a: number, b: number) => [number, number, number]; - readonly parse_response_frame: (a: number, b: number) => [number, number, number, number]; - readonly __wbg_connectionconfig_free: (a: number, b: number) => void; readonly __wbg_wasmclient_free: (a: number, b: number) => void; - readonly connectionconfig_client_id: (a: number) => bigint; - readonly connectionconfig_new: (a: number, b: number) => number; - readonly connectionconfig_set_client_id: (a: number, b: bigint) => void; - readonly connectionconfig_set_server_certificate_hashes: (a: number, b: number, c: number) => void; - readonly connectionconfig_url: (a: number) => [number, number]; readonly wasmclient_auth_connect: (a: number, b: number, c: number, d: number, e: number, f: number, g: bigint) => any; readonly wasmclient_auth_register: (a: number, b: number, c: number, d: number, e: number, f: number) => any; readonly wasmclient_connect: (a: number, b: number) => any; readonly wasmclient_disconnect: (a: number) => void; readonly wasmclient_is_supported: () => number; readonly wasmclient_new: (a: any, b: any, c: any) => number; + readonly wasmclient_request: (a: number, b: number, c: number, d: number, e: number) => any; readonly wasmclient_send: (a: number, b: number, c: number) => any; + readonly wasmclient_start_protocol_pings: (a: number, b: number, c: bigint) => [number, number]; readonly wasmclient_state: (a: number) => number; + readonly wasmclient_stop_protocol_pings: (a: number) => void; + readonly wasmclient_subscribe: (a: number, b: number, c: number, d: any) => number; + readonly wasmclient_unsubscribe: (a: number, b: number) => number; + readonly __wbg_wasmsubscriptionrouter_free: (a: number, b: number) => void; readonly main: () => void; + readonly wasmsubscriptionrouter_dispatch: (a: number, b: number, c: number, d: any) => number; + readonly wasmsubscriptionrouter_new: () => number; + readonly wasmsubscriptionrouter_subscribe: (a: number, b: number, c: number, d: any) => void; + readonly wasmsubscriptionrouter_unsubscribe: (a: number, b: number, c: number) => number; + readonly __wbg_connectionconfig_free: (a: number, b: number) => void; + readonly connectionconfig_client_id: (a: number) => bigint; + readonly connectionconfig_new: (a: number, b: number) => number; + readonly connectionconfig_set_client_id: (a: number, b: bigint) => void; + readonly connectionconfig_set_server_certificate_hashes: (a: number, b: number, c: number) => void; + readonly connectionconfig_url: (a: number) => [number, number]; readonly wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584: (a: number, b: number, c: any) => [number, number]; - readonly wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f: (a: number, b: number, c: any) => [number, number]; - readonly wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f_2: (a: number, b: number, c: any) => [number, number]; readonly wasm_bindgen__convert__closures_____invoke__h4bf2427f775cf424: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h3c511b580d027299: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h99ead69e8d7f5bae: (a: number, b: number) => void; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_exn_store: (a: number) => void; diff --git a/wasm/pkg/mtp_wasm.js b/wasm/pkg/mtp_wasm.js index 946089e..86dda08 100644 --- a/wasm/pkg/mtp_wasm.js +++ b/wasm/pkg/mtp_wasm.js @@ -227,6 +227,19 @@ export class WasmClient { WasmClientFinalization.register(this, this.__wbg_ptr, this); return this; } + /** + * @param {Uint8Array} frame + * @param {string | null} [response_type] + * @returns {Promise} + */ + request(frame, response_type) { + const ptr0 = passArray8ToWasm0(frame, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + var ptr1 = isLikeNone(response_type) ? 0 : passStringToWasm0(response_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + const ret = wasm.wasmclient_request(this.__wbg_ptr, ptr0, len0, ptr1, len1); + return ret; + } /** * @param {Uint8Array} frame * @returns {Promise} @@ -237,6 +250,16 @@ export class WasmClient { const ret = wasm.wasmclient_send(this.__wbg_ptr, ptr0, len0); return ret; } + /** + * @param {number} interval_ms + * @param {bigint} client_id + */ + start_protocol_pings(interval_ms, client_id) { + const ret = wasm.wasmclient_start_protocol_pings(this.__wbg_ptr, interval_ms, client_id); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } /** * @returns {number} */ @@ -244,6 +267,28 @@ export class WasmClient { const ret = wasm.wasmclient_state(this.__wbg_ptr); return ret; } + stop_protocol_pings() { + wasm.wasmclient_stop_protocol_pings(this.__wbg_ptr); + } + /** + * @param {string} message_type + * @param {Function} callback + * @returns {number} + */ + subscribe(message_type, callback) { + const ptr0 = passStringToWasm0(message_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmclient_subscribe(this.__wbg_ptr, ptr0, len0, callback); + return ret >>> 0; + } + /** + * @param {number} id + * @returns {boolean} + */ + unsubscribe(id) { + const ret = wasm.wasmclient_unsubscribe(this.__wbg_ptr, id); + return ret !== 0; + } } if (Symbol.dispose) WasmClient.prototype[Symbol.dispose] = WasmClient.prototype.free; @@ -365,6 +410,16 @@ export class WasmKeyring { } if (Symbol.dispose) WasmKeyring.prototype[Symbol.dispose] = WasmKeyring.prototype.free; +/** + * Log severity used by the public SDK when translating raw WASM events. + * @enum {0 | 1 | 2} + */ +export const WasmLogHint = Object.freeze({ + Info: 0, "0": "Info", + Warning: 1, "1": "Warning", + Error: 2, "2": "Error", +}); + export class WasmPublicKeyBundle { static __wrap(ptr) { const obj = Object.create(WasmPublicKeyBundle.prototype); @@ -435,29 +490,79 @@ export class WasmPublicKeyBundle { if (Symbol.dispose) WasmPublicKeyBundle.prototype[Symbol.dispose] = WasmPublicKeyBundle.prototype.free; /** - * Build a demo Ping frame with encrypted and signed containers - * (mirrors the Rust client example but uses only reserved data types). - * @param {bigint} client_id - * @param {Uint8Array} keyring_bytes - * @param {Uint8Array} host_bundle_bytes + * Minimal message router used by higher-level SDK subscription code. + */ +export class WasmSubscriptionRouter { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + WasmSubscriptionRouterFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_wasmsubscriptionrouter_free(ptr, 0); + } + /** + * @param {string} message_type + * @param {any} message + * @returns {boolean} + */ + dispatch(message_type, message) { + const ptr0 = passStringToWasm0(message_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmsubscriptionrouter_dispatch(this.__wbg_ptr, ptr0, len0, message); + return ret !== 0; + } + constructor() { + const ret = wasm.wasmsubscriptionrouter_new(); + this.__wbg_ptr = ret; + WasmSubscriptionRouterFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @param {string} message_type + * @param {Function} callback + */ + subscribe(message_type, callback) { + const ptr0 = passStringToWasm0(message_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + wasm.wasmsubscriptionrouter_subscribe(this.__wbg_ptr, ptr0, len0, callback); + } + /** + * @param {string} message_type + * @returns {boolean} + */ + unsubscribe(message_type) { + const ptr0 = passStringToWasm0(message_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.wasmsubscriptionrouter_unsubscribe(this.__wbg_ptr, ptr0, len0); + return ret !== 0; + } +} +if (Symbol.dispose) WasmSubscriptionRouter.prototype[Symbol.dispose] = WasmSubscriptionRouter.prototype.free; + +/** + * Build a typed MTP frame using generated communication/data type names. + * @param {string} message_type + * @param {any} data + * @param {any} options * @returns {Uint8Array} */ -export function build_demo_message(client_id, keyring_bytes, host_bundle_bytes) { - const ptr0 = passArray8ToWasm0(keyring_bytes, wasm.__wbindgen_malloc); +export function build_frame(message_type, data, options) { + const ptr0 = passStringToWasm0(message_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; - const ptr1 = passArray8ToWasm0(host_bundle_bytes, wasm.__wbindgen_malloc); - const len1 = WASM_VECTOR_LEN; - const ret = wasm.build_demo_message(client_id, ptr0, len0, ptr1, len1); + const ret = wasm.build_frame(ptr0, len0, data, options); if (ret[3]) { throw takeFromExternrefTable0(ret[2]); } - var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); + var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); - return v3; + return v2; } /** - * Build a simple Ping frame with description, timestamp, and optional data. + * Build a protocol-level Ping frame with description, timestamp, and optional data. * @param {bigint} client_id * @param {string} description * @param {bigint} timestamp @@ -478,31 +583,6 @@ export function build_ping_frame(client_id, description, timestamp, data) { return v3; } -/** - * Build a request frame with the given communication type name, request ID, and JSON data. - * - * - `comm_type`: communication type name (e.g. "get_model", "rank_models") or PascalCase - * - `id`: request ID for response correlation - * - `json_data`: JSON-stringified request payload - * @param {string} comm_type - * @param {number} id - * @param {string} json_data - * @returns {Uint8Array} - */ -export function build_request_frame(comm_type, id, json_data) { - const ptr0 = passStringToWasm0(comm_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(json_data, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - const ret = wasm.build_request_frame(ptr0, len0, id, ptr1, len1); - if (ret[3]) { - throw takeFromExternrefTable0(ret[2]); - } - var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); - wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); - return v3; -} - /** * Generate a fresh Ed25519 keypair. * @@ -605,29 +685,18 @@ export function parse_auth_response(response) { } /** - * Parse a response frame into a JSON string containing `_id`, `_type`, and data fields. + * Parse any MTP frame into structured JavaScript data. * @param {Uint8Array} frame - * @returns {string} + * @returns {ParsedFrame} */ -export function parse_response_frame(frame) { - let deferred3_0; - let deferred3_1; - try { - const ptr0 = passArray8ToWasm0(frame, wasm.__wbindgen_malloc); - const len0 = WASM_VECTOR_LEN; - const ret = wasm.parse_response_frame(ptr0, len0); - var ptr2 = ret[0]; - var len2 = ret[1]; - if (ret[3]) { - ptr2 = 0; len2 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred3_0 = ptr2; - deferred3_1 = len2; - return getStringFromWasm0(ptr2, len2); - } finally { - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); +export function parse_frame(frame) { + const ptr0 = passArray8ToWasm0(frame, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_frame(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); } + return takeFromExternrefTable0(ret[0]); } /** @@ -707,6 +776,10 @@ export function wasm_sha256_double(data) { function __wbg_get_imports() { const import0 = { __proto__: null, + __wbg_BigInt_ff69cca7a537413a: function(arg0, arg1) { + const ret = BigInt(getStringFromWasm0(arg0, arg1)); + return ret; + }, __wbg___wbindgen_boolean_get_fa956cfa2d1bd751: function(arg0) { const v = arg0; const ret = typeof(v) === 'boolean' ? v : undefined; @@ -740,6 +813,12 @@ function __wbg_get_imports() { const ret = arg0 === undefined; return ret; }, + __wbg___wbindgen_number_get_394265ed1e1b84ee: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) { const obj = arg1; const ret = typeof(obj) === 'string' ? obj : undefined; @@ -751,6 +830,10 @@ function __wbg_get_imports() { __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { throw new Error(getStringFromWasm0(arg0, arg1)); }, + __wbg___wbindgen_typeof_b1bf2ff71f77b13e: function(arg0) { + const ret = typeof arg0; + return ret; + }, __wbg__wbg_cb_unref_fffb441def202758: function(arg0) { arg0._wbg_cb_unref(); }, @@ -762,21 +845,18 @@ function __wbg_get_imports() { const ret = arg0.call(arg1, arg2); return ret; }, arguments); }, - __wbg_close_23b01d38b065688a: function(arg0, arg1) { - arg0.close(arg1); - }, - __wbg_createUnidirectionalStream_9ff0e4127f40ed0d: function(arg0) { - const ret = arg0.createUnidirectionalStream(); + __wbg_call_e3b662382210db98: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg0.call(arg1, arg2, arg3); return ret; - }, + }, arguments); }, + __wbg_construct_4e1a16de27aea5b9: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.construct(arg0, arg1); + return ret; + }, arguments); }, __wbg_crypto_38df2bab126b63dc: function(arg0) { const ret = arg0.crypto; return ret; }, - __wbg_entries_015dc610cd81ede0: function(arg0) { - const ret = Object.entries(arg0); - return ret; - }, __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { let deferred0_0; let deferred0_1; @@ -788,32 +868,56 @@ function __wbg_get_imports() { wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); } }, + __wbg_from_13e323c65fc8f464: function(arg0) { + const ret = Array.from(arg0); + return ret; + }, __wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) { arg0.getRandomValues(arg1); }, arguments); }, __wbg_getRandomValues_cc7f052a444bb2ce: function() { return handleError(function (arg0, arg1) { globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); }, arguments); }, - __wbg_get_507a50627bffa49b: function(arg0, arg1) { - const ret = arg0[arg1 >>> 0]; - return ret; - }, __wbg_get_78f252d074a84d0b: function() { return handleError(function (arg0, arg1) { const ret = Reflect.get(arg0, arg1); return ret; }, arguments); }, + __wbg_get_unchecked_6e0ad6d2a41b06f6: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, __wbg_has_8374cf06984d8bfc: function() { return handleError(function (arg0, arg1) { const ret = Reflect.has(arg0, arg1); return ret; }, arguments); }, - __wbg_incomingUnidirectionalStreams_505a67efefd669d3: function(arg0) { - const ret = arg0.incomingUnidirectionalStreams; + __wbg_instanceof_Promise_4cb210c0b8f8c959: function(arg0) { + let result; + try { + result = arg0 instanceof Promise; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Uint8Array_309b927aaf7a3fc7: function(arg0) { + let result; + try { + result = arg0 instanceof Uint8Array; + } catch (_) { + result = false; + } + const ret = result; return ret; }, __wbg_isArray_0677c962b281d01a: function(arg0) { const ret = Array.isArray(arg0); return ret; }, + __wbg_keys_58421f8f96795607: function(arg0) { + const ret = Object.keys(arg0); + return ret; + }, __wbg_length_1f0964f4a5e2c6d8: function(arg0) { const ret = arg0.length; return ret; @@ -822,9 +926,6 @@ function __wbg_get_imports() { const ret = arg0.length; return ret; }, - __wbg_log_d267660666346fb3: function(arg0) { - console.log(arg0); - }, __wbg_msCrypto_bd5a034af96bcba6: function(arg0) { const ret = arg0.msCrypto; return ret; @@ -833,10 +934,10 @@ function __wbg_get_imports() { const ret = new Error(); return ret; }, - __wbg_new_a2778e1fb014b494: function() { return handleError(function (arg0, arg1) { - const ret = new WebTransport(getStringFromWasm0(arg0, arg1)); + __wbg_new_32b398fb48b6d94a: function() { + const ret = new Array(); return ret; - }, arguments); }, + }, __wbg_new_cd45aabdf6073e84: function(arg0) { const ret = new Uint8Array(arg0); return ret; @@ -871,10 +972,6 @@ function __wbg_get_imports() { const ret = new Uint8Array(arg0 >>> 0); return ret; }, - __wbg_new_with_options_5b1cc213336d0b4c: function() { return handleError(function (arg0, arg1, arg2) { - const ret = new WebTransport(getStringFromWasm0(arg0, arg1), arg2); - return ret; - }, arguments); }, __wbg_node_84ea875411254db1: function(arg0) { const ret = arg0.node; return ret; @@ -883,10 +980,6 @@ function __wbg_get_imports() { const ret = Date.now(); return ret; }, - __wbg_parse_1c0d8a8656d7e016: function() { return handleError(function (arg0, arg1) { - const ret = JSON.parse(getStringFromWasm0(arg0, arg1)); - return ret; - }, arguments); }, __wbg_process_44c7a14e11e9f69e: function(arg0) { const ret = arg0.process; return ret; @@ -894,6 +987,10 @@ function __wbg_get_imports() { __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) { Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); }, + __wbg_push_d2ae3af0c1217ae6: function(arg0, arg1) { + const ret = arg0.push(arg1); + return ret; + }, __wbg_queueMicrotask_0ab5b2d2393e99b9: function(arg0) { const ret = arg0.queueMicrotask; return ret; @@ -904,10 +1001,6 @@ function __wbg_get_imports() { __wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) { arg0.randomFillSync(arg1); }, arguments); }, - __wbg_ready_e4dad560377c42e6: function(arg0) { - const ret = arg0.ready; - return ret; - }, __wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () { const ret = module.require; return ret; @@ -920,15 +1013,6 @@ function __wbg_get_imports() { const ret = Reflect.set(arg0, arg1, arg2); return ret; }, arguments); }, - __wbg_set_algorithm_4884633ae550b091: function(arg0, arg1, arg2) { - arg0.algorithm = getStringFromWasm0(arg1, arg2); - }, - __wbg_set_server_certificate_hashes_c6f10c7638672baf: function(arg0, arg1, arg2) { - arg0.serverCertificateHashes = getArrayJsValueViewFromWasm0(arg1, arg2); - }, - __wbg_set_value_u8_array_38f1c892603c4916: function(arg0, arg1) { - arg0.value = arg1; - }, __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { const ret = arg1.stack; const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -952,10 +1036,6 @@ function __wbg_get_imports() { const ret = typeof window === 'undefined' ? null : window; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, - __wbg_stringify_b54333f60f1e4dad: function() { return handleError(function (arg0) { - const ret = JSON.stringify(arg0); - return ret; - }, arguments); }, __wbg_subarray_3ed232c8a6baee09: function(arg0, arg1, arg2) { const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); return ret; @@ -968,6 +1048,10 @@ function __wbg_get_imports() { const ret = arg0.then(arg1); return ret; }, + __wbg_toString_34387d7c1df9ca1e: function() { return handleError(function (arg0, arg1) { + const ret = arg0.toString(arg1); + return ret; + }, arguments); }, __wbg_versions_276b2795b1c6a219: function(arg0) { const ret = arg0.versions; return ret; @@ -977,18 +1061,18 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 135, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 128, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WebTransportSendStream")], shim_idx: 64, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 65, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3c511b580d027299); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("undefined")], shim_idx: 64, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f_2); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 63, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h99ead69e8d7f5bae); return ret; }, __wbindgen_cast_0000000000000004: function(arg0) { @@ -1027,6 +1111,14 @@ function __wbg_get_imports() { }; } +function wasm_bindgen__convert__closures_____invoke__h99ead69e8d7f5bae(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h99ead69e8d7f5bae(arg0, arg1); +} + +function wasm_bindgen__convert__closures_____invoke__h3c511b580d027299(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h3c511b580d027299(arg0, arg1, arg2); +} + function wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584(arg0, arg1, arg2) { const ret = wasm.wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584(arg0, arg1, arg2); if (ret[1]) { @@ -1034,20 +1126,6 @@ function wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584(arg0, arg } } -function wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f(arg0, arg1, arg2); - if (ret[1]) { - throw takeFromExternrefTable0(ret[0]); - } -} - -function wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f_2(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f_2(arg0, arg1, arg2); - if (ret[1]) { - throw takeFromExternrefTable0(ret[0]); - } -} - function wasm_bindgen__convert__closures_____invoke__h4bf2427f775cf424(arg0, arg1, arg2, arg3) { wasm.wasm_bindgen__convert__closures_____invoke__h4bf2427f775cf424(arg0, arg1, arg2, arg3); } @@ -1070,6 +1148,9 @@ const WasmKeyringFinalization = (typeof FinalizationRegistry === 'undefined') const WasmPublicKeyBundleFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_wasmpublickeybundle_free(ptr, 1)); +const WasmSubscriptionRouterFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_wasmsubscriptionrouter_free(ptr, 1)); function addToExternrefTable0(obj) { const idx = wasm.__externref_table_alloc(); @@ -1152,16 +1233,6 @@ function debugString(val) { return className; } -function getArrayJsValueViewFromWasm0(ptr, len) { - ptr = ptr >>> 0; - const mem = getDataViewMemory0(); - const result = []; - for (let i = ptr; i < ptr + 4 * len; i += 4) { - result.push(wasm.__wbindgen_externrefs.get(mem.getUint32(i, true))); - } - return result; -} - function getArrayU8FromWasm0(ptr, len) { ptr = ptr >>> 0; return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); diff --git a/wasm/pkg/mtp_wasm_bg.wasm b/wasm/pkg/mtp_wasm_bg.wasm index 8feff5eb13ee1d2bb60854bdf5986117a01dba37..42eeb7965f4533bc134ff35657a604e3dbe31207 100644 GIT binary patch delta 136737 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 [number, number, number, number]; +export const build_ping_frame: (a: bigint, b: number, c: number, d: bigint, e: number, f: number) => [number, number, number, number]; +export const format_frame: (a: number, b: number) => [number, number, number, number]; +export const parse_auth_response: (a: number, b: number) => [number, number, number]; +export const parse_frame: (a: number, b: number) => [number, number, number]; export const __wbg_wasmchacha20poly1305_free: (a: number, b: number) => void; export const __wbg_wasmed25519signer_free: (a: number, b: number) => void; export const __wbg_wasmkeyring_free: (a: number, b: number) => void; @@ -26,32 +31,36 @@ export const wasmpublickeybundle_kem_public_key: (a: number) => [number, number] export const wasmpublickeybundle_sig_cl_public_key: (a: number) => [number, number]; export const wasmpublickeybundle_sig_pq_public_key: (a: number) => [number, number]; export const wasmpublickeybundle_to_bytes: (a: number) => [number, number]; -export const build_demo_message: (a: bigint, b: number, c: number, d: number, e: number) => [number, number, number, number]; -export const build_ping_frame: (a: bigint, b: number, c: number, d: bigint, e: number, f: number) => [number, number, number, number]; -export const build_request_frame: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; -export const format_frame: (a: number, b: number) => [number, number, number, number]; -export const parse_auth_response: (a: number, b: number) => [number, number, number]; -export const parse_response_frame: (a: number, b: number) => [number, number, number, number]; -export const __wbg_connectionconfig_free: (a: number, b: number) => void; export const __wbg_wasmclient_free: (a: number, b: number) => void; -export const connectionconfig_client_id: (a: number) => bigint; -export const connectionconfig_new: (a: number, b: number) => number; -export const connectionconfig_set_client_id: (a: number, b: bigint) => void; -export const connectionconfig_set_server_certificate_hashes: (a: number, b: number, c: number) => void; -export const connectionconfig_url: (a: number) => [number, number]; export const wasmclient_auth_connect: (a: number, b: number, c: number, d: number, e: number, f: number, g: bigint) => any; export const wasmclient_auth_register: (a: number, b: number, c: number, d: number, e: number, f: number) => any; export const wasmclient_connect: (a: number, b: number) => any; export const wasmclient_disconnect: (a: number) => void; export const wasmclient_is_supported: () => number; export const wasmclient_new: (a: any, b: any, c: any) => number; +export const wasmclient_request: (a: number, b: number, c: number, d: number, e: number) => any; export const wasmclient_send: (a: number, b: number, c: number) => any; +export const wasmclient_start_protocol_pings: (a: number, b: number, c: bigint) => [number, number]; export const wasmclient_state: (a: number) => number; +export const wasmclient_stop_protocol_pings: (a: number) => void; +export const wasmclient_subscribe: (a: number, b: number, c: number, d: any) => number; +export const wasmclient_unsubscribe: (a: number, b: number) => number; +export const __wbg_wasmsubscriptionrouter_free: (a: number, b: number) => void; export const main: () => void; +export const wasmsubscriptionrouter_dispatch: (a: number, b: number, c: number, d: any) => number; +export const wasmsubscriptionrouter_new: () => number; +export const wasmsubscriptionrouter_subscribe: (a: number, b: number, c: number, d: any) => void; +export const wasmsubscriptionrouter_unsubscribe: (a: number, b: number, c: number) => number; +export const __wbg_connectionconfig_free: (a: number, b: number) => void; +export const connectionconfig_client_id: (a: number) => bigint; +export const connectionconfig_new: (a: number, b: number) => number; +export const connectionconfig_set_client_id: (a: number, b: bigint) => void; +export const connectionconfig_set_server_certificate_hashes: (a: number, b: number, c: number) => void; +export const connectionconfig_url: (a: number) => [number, number]; export const wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen__convert__closures_____invoke__h2faccefeed15993f_2: (a: number, b: number, c: any) => [number, number]; export const wasm_bindgen__convert__closures_____invoke__h4bf2427f775cf424: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h3c511b580d027299: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h99ead69e8d7f5bae: (a: number, b: number) => void; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_exn_store: (a: number) => void; diff --git a/wasm/src/client.rs b/wasm/src/client.rs index 0df8f24..3d60139 100644 --- a/wasm/src/client.rs +++ b/wasm/src/client.rs @@ -1,6 +1,9 @@ -use std::cell::Cell; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use std::rc::Rc; +use futures_channel::oneshot; +use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION}; @@ -8,9 +11,116 @@ use mtp_type_map::CommunicationTypeId; use mtp_crypto::SignatureScheme; +use crate::config::ConnectionConfig; use crate::error::js_error; use crate::transport::WasmTransport; +struct PendingRequest { + response_type: Option, + sender: oneshot::Sender>, +} + +struct PingTimer { + id: i32, + closure: Closure, +} + +fn frame_property(frame: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(frame, &JsValue::from_str(key)) + .ok() + .filter(|value| !value.is_null() && !value.is_undefined()) +} + +fn frame_id(frame: &JsValue) -> Option { + frame_property(frame, "id") + .and_then(|value| value.as_f64()) + .map(|value| value as u32) +} + +fn frame_type(frame: &JsValue) -> Option { + frame_property(frame, "type").and_then(|value| value.as_string()) +} + +fn route_incoming_frame( + frame: &JsValue, + on_message: &js_sys::Function, + subscriptions: &Rc>>, + pending_requests: &Rc>>, +) { + let message_type = frame_type(frame); + + if let Some(request_id) = frame_id(frame) { + let pending = pending_requests.borrow_mut().remove(&request_id); + if let Some(pending) = pending { + let type_matches = pending + .response_type + .as_ref() + .zip(message_type.as_ref()) + .map(|(expected, actual)| expected == actual) + .unwrap_or(true); + if type_matches { + let _ = pending.sender.send(Ok(frame.clone())); + } else { + let actual = message_type.clone().unwrap_or_else(|| "unknown".into()); + let _ = pending.sender.send(Err(js_error(&format!( + "unexpected response type: expected {}, got {}", + pending.response_type.unwrap_or_else(|| "unknown".into()), + actual + )))); + } + } + } + + if let Some(message_type) = message_type.as_ref() { + let matching_id = pending_requests + .borrow() + .iter() + .find_map(|(id, pending)| match pending.response_type.as_ref() { + Some(response_type) if response_type == message_type => Some(*id), + _ => None, + }); + if let Some(id) = matching_id { + if let Some(pending) = pending_requests.borrow_mut().remove(&id) { + let _ = pending.sender.send(Ok(frame.clone())); + } + } + } + + let _ = on_message.call1(&JsValue::NULL, frame); + + let Some(message_type) = message_type else { + return; + }; + for (_, (subscription_type, callback)) in subscriptions.borrow().iter() { + if subscription_type == &message_type { + let _ = callback.call1(&JsValue::NULL, frame); + } + } +} + +fn stop_ping_timer(ping_timer: &Rc>>) { + let Some(timer) = ping_timer.borrow_mut().take() else { + return; + }; + if let Ok(clear_interval) = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) + .and_then(|value| value.dyn_into::().map_err(Into::into)) + { + let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); + } + drop(timer.closure); +} + +fn reject_pending_requests( + pending_requests: &Rc>>, + message: &str, +) { + let pending = std::mem::take(&mut *pending_requests.borrow_mut()); + for (_, pending) in pending { + let _ = pending.sender.send(Err(js_error(message))); + } +} + fn raw_frame_preview(bytes: &[u8]) -> String { let shown = bytes.len().min(256); let mut preview = hex::encode(&bytes[..shown]); @@ -139,45 +249,6 @@ pub enum ConnectionState { Failed = 3, } -#[wasm_bindgen] -pub struct ConnectionConfig { - url: String, - server_certificate_hashes: Option>, - client_id: u64, -} - -#[wasm_bindgen] -impl ConnectionConfig { - #[wasm_bindgen(constructor)] - pub fn new(url: String) -> Self { - Self { - url, - server_certificate_hashes: None, - client_id: 0, - } - } - - #[wasm_bindgen(getter)] - pub fn url(&self) -> String { - self.url.clone() - } - - #[wasm_bindgen(setter)] - pub fn set_client_id(&mut self, id: u64) { - self.client_id = id; - } - - #[wasm_bindgen(getter)] - pub fn client_id(&self) -> u64 { - self.client_id - } - - #[wasm_bindgen(setter)] - pub fn set_server_certificate_hashes(&mut self, hashes: Vec) { - self.server_certificate_hashes = Some(hashes); - } -} - #[wasm_bindgen] pub struct WasmClient { transport: Option, @@ -185,6 +256,10 @@ pub struct WasmClient { on_state_change: js_sys::Function, pub(crate) on_message: js_sys::Function, pub(crate) on_error: js_sys::Function, + subscriptions: Rc>>, + next_subscription_id: Rc>, + pending_requests: Rc>>, + ping_timer: Rc>>, } #[wasm_bindgen] @@ -201,6 +276,10 @@ impl WasmClient { on_state_change: on_state_change.clone(), on_message: on_message.clone(), on_error: on_error.clone(), + subscriptions: Rc::new(RefCell::new(HashMap::new())), + next_subscription_id: Rc::new(Cell::new(1)), + pending_requests: Rc::new(RefCell::new(HashMap::new())), + ping_timer: Rc::new(RefCell::new(None)), } } @@ -455,12 +534,134 @@ impl WasmClient { } } + #[wasm_bindgen] + pub async fn request( + &self, + frame: Vec, + response_type: Option, + ) -> Result { + let request = CommunicationValue::from_bytes(&frame) + .map_err(|e| js_error(&format!("parse request: {}", e)))?; + let request_id = request.get_id(); + if request_id == 0 { + return Err(js_error("request frame must have a non-zero id")); + } + + let Some(transport) = self.transport.clone() else { + return Err(js_error("not connected")); + }; + + let (sender, receiver) = oneshot::channel(); + self.pending_requests.borrow_mut().insert( + request_id, + PendingRequest { + response_type, + sender, + }, + ); + + if let Err(error) = transport.send_frame(&frame).await { + self.pending_requests.borrow_mut().remove(&request_id); + return Err(error); + } + + match receiver.await { + Ok(result) => result, + Err(_) => Err(js_error("request cancelled")), + } + } + + #[wasm_bindgen] + pub fn subscribe(&self, message_type: String, callback: js_sys::Function) -> u32 { + let id = self.next_subscription_id.get(); + self.next_subscription_id.set(id.wrapping_add(1).max(1)); + self.subscriptions + .borrow_mut() + .insert(id, (message_type, callback)); + id + } + + #[wasm_bindgen] + pub fn unsubscribe(&self, id: u32) -> bool { + self.subscriptions.borrow_mut().remove(&id).is_some() + } + + #[wasm_bindgen] + pub fn start_protocol_pings(&self, interval_ms: u32, client_id: u64) -> Result<(), JsValue> { + self.stop_protocol_pings(); + let Some(transport) = self.transport.clone() else { + return Err(js_error("not connected")); + }; + let interval_ms = interval_ms.max(1_000) as i32; + let on_error = self.on_error.clone(); + let closure = Closure::wrap(Box::new(move || { + let transport = transport.clone(); + let on_error = on_error.clone(); + wasm_bindgen_futures::spawn_local(async move { + let timestamp = js_sys::Date::now() as u64; + let frame = CommunicationValue::new(CommunicationType::Ping) + .add_typed_default( + DataType::Description, + DataValue::Str("protocol ping".into()), + ) + .add_typed_default( + DataType::Timestamp, + DataValue::UnsignedNumber(timestamp as u128), + ) + .with_sender(client_id) + .to_bytes() + .map_err(|e| js_error(&format!("encode ping failed: {}", e))); + match frame { + Ok(frame) => { + if let Err(error) = transport.send_frame(&frame).await { + let _ = on_error.call1(&JsValue::NULL, &error); + } + } + Err(error) => { + let _ = on_error.call1(&JsValue::NULL, &error); + } + } + }); + }) as Box); + + let set_interval = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))? + .dyn_into::()?; + let id = set_interval + .call2( + &JsValue::NULL, + closure.as_ref().unchecked_ref(), + &JsValue::from_f64(interval_ms as f64), + )? + .as_f64() + .ok_or_else(|| js_error("setInterval did not return an id"))? as i32; + *self.ping_timer.borrow_mut() = Some(PingTimer { id, closure }); + Ok(()) + } + + #[wasm_bindgen] + pub fn stop_protocol_pings(&self) { + let Some(timer) = self.ping_timer.borrow_mut().take() else { + return; + }; + if let Ok(clear_interval) = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) + .and_then(|value| value.dyn_into::().map_err(Into::into)) + { + let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); + } + drop(timer.closure); + } + #[wasm_bindgen] pub fn disconnect(&mut self) { + self.stop_protocol_pings(); if let Some(t) = &self.transport { t.close(); } self.transport = None; + self.subscriptions.borrow_mut().clear(); + self.reject_pending_requests("disconnected"); self.set_state(ConnectionState::Disconnected); } @@ -479,12 +680,34 @@ impl WasmClient { let state = self.state.clone(); let on_msg = self.on_message.clone(); let on_err = self.on_error.clone(); + let subscriptions = self.subscriptions.clone(); + let pending_requests = self.pending_requests.clone(); + let loop_pending_requests = pending_requests.clone(); + let ping_timer = self.ping_timer.clone(); wasm_bindgen_futures::spawn_local(async move { - loop_transport.receive_loop(on_msg, on_err).await; + let route_frame = Closure::wrap(Box::new(move |frame: JsValue| { + route_incoming_frame(&frame, &on_msg, &subscriptions, &loop_pending_requests); + }) as Box); + loop_transport + .receive_loop( + route_frame + .as_ref() + .unchecked_ref::() + .clone(), + on_err.clone(), + ) + .await; + drop(route_frame); state.set(ConnectionState::Disconnected); + stop_ping_timer(&ping_timer); + reject_pending_requests(&pending_requests, "disconnected"); }); } + fn reject_pending_requests(&self, message: &str) { + reject_pending_requests(&self.pending_requests, message); + } + async fn read_verified_challenge( &self, transport: &WasmTransport, diff --git a/wasm/src/config.rs b/wasm/src/config.rs new file mode 100644 index 0000000..42f1f27 --- /dev/null +++ b/wasm/src/config.rs @@ -0,0 +1,40 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct ConnectionConfig { + pub(crate) url: String, + pub(crate) server_certificate_hashes: Option>, + pub(crate) client_id: u64, +} + +#[wasm_bindgen] +impl ConnectionConfig { + #[wasm_bindgen(constructor)] + pub fn new(url: String) -> Self { + Self { + url, + server_certificate_hashes: None, + client_id: 0, + } + } + + #[wasm_bindgen(getter)] + pub fn url(&self) -> String { + self.url.clone() + } + + #[wasm_bindgen(setter)] + pub fn set_client_id(&mut self, id: u64) { + self.client_id = id; + } + + #[wasm_bindgen(getter)] + pub fn client_id(&self) -> u64 { + self.client_id + } + + #[wasm_bindgen(setter)] + pub fn set_server_certificate_hashes(&mut self, hashes: Vec) { + self.server_certificate_hashes = Some(hashes); + } +} diff --git a/wasm/src/frame.rs b/wasm/src/frame.rs new file mode 100644 index 0000000..e0315b7 --- /dev/null +++ b/wasm/src/frame.rs @@ -0,0 +1,479 @@ +use wasm_bindgen::{JsCast, prelude::*}; + +use mtp_codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, communication_type_name, + data_type_name, +}; +use mtp_type_map::TypeMap; + +use crate::error::js_error; + +#[wasm_bindgen(typescript_custom_section)] +const PARSED_FRAME_TS: &'static str = r#" +export interface ParsedFrame { + id?: number; + type: string; + sender?: bigint; + receiver?: bigint; + data: Record; + raw: Uint8Array; +} +"#; + +fn set_prop(obj: &js_sys::Object, key: &str, value: &JsValue) -> Result<(), JsValue> { + js_sys::Reflect::set(obj, &JsValue::from_str(key), value).map(|_| ()) +} + +fn integer_value(value: &str) -> JsValue { + if let Ok(number) = value.parse::() { + if number.fract() == 0.0 && number.abs() <= 9_007_199_254_740_991.0 { + return JsValue::from_f64(number); + } + } + JsValue::from_str(value) +} + +fn data_value_to_js(value: &DataValue) -> Result { + match value { + DataValue::BoolTrue => Ok(JsValue::TRUE), + DataValue::BoolFalse => Ok(JsValue::FALSE), + DataValue::Bool(v) => Ok(JsValue::from_bool(*v)), + DataValue::SignedNumber(n) => Ok(integer_value(&n.to_string())), + DataValue::UnsignedNumber(n) => Ok(integer_value(&n.to_string())), + DataValue::Float(exp, mant) => { + Ok(JsValue::from_f64((*mant as f64) * 10f64.powi(*exp as i32))) + } + DataValue::Str(s) => Ok(JsValue::from_str(s)), + DataValue::Bytes(bytes) => Ok(js_sys::Uint8Array::from(&bytes[..]).into()), + DataValue::Array(values) => { + let arr = js_sys::Array::new(); + for value in values { + arr.push(&data_value_to_js(value)?); + } + Ok(arr.into()) + } + DataValue::Container(entries) => { + let obj = js_sys::Object::new(); + for (key, value) in entries { + let name = data_type_name(key.0) + .map(str::to_string) + .unwrap_or_else(|| key.0.to_string()); + set_prop(&obj, &name, &data_value_to_js(value)?)?; + } + Ok(obj.into()) + } + DataValue::EncryptedContainer(bytes) + | DataValue::SignedContainer(bytes) + | DataValue::SignedEncryptedContainer(bytes) => { + Ok(js_sys::Uint8Array::from(&bytes[..]).into()) + } + DataValue::Null => Ok(JsValue::NULL), + } +} + +fn js_to_data_value(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Ok(DataValue::Null); + } + if let Some(v) = value.as_bool() { + return Ok(DataValue::Bool(v)); + } + if let Some(v) = value.as_string() { + return Ok(DataValue::Str(v)); + } + if js_sys::Uint8Array::instanceof(value) { + return Ok(DataValue::Bytes(js_sys::Uint8Array::new(value).to_vec())); + } + if js_sys::Array::is_array(value) { + let array = js_sys::Array::from(value); + let mut values = Vec::with_capacity(array.length() as usize); + for item in array.iter() { + values.push(js_to_data_value(&item)?); + } + return Ok(DataValue::Array(values)); + } + if let Some(v) = value.as_f64() { + if v.fract() == 0.0 { + if v >= 0.0 { + return Ok(DataValue::UnsignedNumber(v as u128)); + } + return Ok(DataValue::SignedNumber(v as i128)); + } + let mantissa = (v * 1_000_000.0).round() as u32; + return Ok(DataValue::Float(246, mantissa)); + } + + let type_name = value.js_typeof().as_string().unwrap_or_default(); + if type_name == "bigint" { + let bigint = value.clone().unchecked_into::(); + let as_string = bigint + .to_string(10)? + .as_string() + .ok_or_else(|| js_error("failed to stringify bigint"))?; + if let Some(unsigned) = as_string.strip_prefix('-') { + let n = unsigned + .parse::() + .map_err(|_| js_error("bigint out of range"))?; + return Ok(DataValue::SignedNumber(-n)); + } + let n = as_string + .parse::() + .map_err(|_| js_error("bigint out of range"))?; + return Ok(DataValue::UnsignedNumber(n)); + } + + if value.is_object() { + let object = js_sys::Object::from(value.clone()); + let keys = js_sys::Object::keys(&object); + let mut entries = Vec::with_capacity(keys.length() as usize); + for key in keys.iter() { + let key = key + .as_string() + .ok_or_else(|| js_error("object key must be a string"))?; + let data_type = DataType::from_name(&key) + .ok_or_else(|| js_error(&format!("unknown data type: {key}")))?; + let value = js_sys::Reflect::get(&object, &JsValue::from_str(&key))?; + entries.push(( + data_type.to_id(&TypeMap::latest()), + js_to_data_value(&value)?, + )); + } + return Ok(DataValue::Container(entries)); + } + + Err(js_error("unsupported data value")) +} + +fn option_u32(options: &JsValue, key: &str) -> Result, JsValue> { + let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + let Some(n) = value.as_f64() else { + return Err(js_error(&format!("{key} must be a number"))); + }; + Ok(Some(n as u32)) +} + +fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { + let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + if let Some(n) = value.as_f64() { + return Ok(Some(n as u64)); + } + let type_name = value.js_typeof().as_string().unwrap_or_default(); + if type_name == "bigint" { + let bigint = value.clone().unchecked_into::(); + let as_string = bigint + .to_string(10)? + .as_string() + .ok_or_else(|| js_error("failed to stringify bigint"))?; + return as_string + .parse::() + .map(Some) + .map_err(|_| js_error(&format!("{key} out of range"))); + } + Err(js_error(&format!("{key} must be a number or bigint"))) +} + +pub(crate) fn parse_frame_value(frame: &[u8]) -> Result { + let comm = CommunicationValue::from_bytes(frame) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; + let obj = js_sys::Object::new(); + let data = js_sys::Object::new(); + + if comm.get_id() != 0 { + set_prop(&obj, "id", &JsValue::from_f64(comm.get_id() as f64))?; + } + + let frame_type = communication_type_name(comm.get_type().0) + .map(str::to_string) + .unwrap_or_else(|| comm.get_type().0.to_string()); + set_prop(&obj, "type", &JsValue::from_str(&frame_type))?; + + if comm.get_sender() != 0 { + set_prop( + &obj, + "sender", + &JsValue::bigint_from_str(&comm.get_sender().to_string()), + )?; + } + if comm.get_receiver() != 0 { + set_prop( + &obj, + "receiver", + &JsValue::bigint_from_str(&comm.get_receiver().to_string()), + )?; + } + + for (key, value) in comm.data() { + let name = data_type_name(key.0) + .map(str::to_string) + .unwrap_or_else(|| key.0.to_string()); + set_prop(&data, &name, &data_value_to_js(value)?)?; + } + set_prop(&obj, "data", &data.into())?; + set_prop(&obj, "raw", &js_sys::Uint8Array::from(frame).into())?; + + Ok(obj.into()) +} + +/// Build a protocol-level Ping frame with description, timestamp, and optional data. +#[wasm_bindgen] +pub fn build_ping_frame( + client_id: u64, + description: &str, + timestamp: u64, + data: &[u8], +) -> Result, JsValue> { + let mut msg = CommunicationValue::new(CommunicationType::Ping) + .add_typed_default( + DataType::Description, + DataValue::Str(description.to_string()), + ) + .add_typed_default( + DataType::Timestamp, + DataValue::UnsignedNumber(timestamp as u128), + ) + .with_sender(client_id); + + if !data.is_empty() { + msg = msg.add_typed_default(DataType::Id, DataValue::Bytes(data.to_vec())); + } + + msg.to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e))) +} + +/// Parse an auth response frame into a JS object. +#[wasm_bindgen] +pub fn parse_auth_response(response: &[u8]) -> Result { + let comm = CommunicationValue::from_bytes(response) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; + + let connected = matches!( + comm.get_data(DataType::Connected.to_id(&TypeMap::latest())), + DataValue::BoolTrue + ); + + let client_nonce = match comm.get_data(DataType::ClientNonce.to_id(&TypeMap::latest())) { + DataValue::UnsignedNumber(n) => Some(*n), + _ => None, + }; + + let assigned_id = match comm.get_data(DataType::Id.to_id(&TypeMap::latest())) { + DataValue::UnsignedNumber(n) => Some(*n as u64), + _ => None, + }; + + let timestamp = match comm.get_data(DataType::Timestamp.to_id(&TypeMap::latest())) { + DataValue::UnsignedNumber(n) => Some(*n), + _ => None, + }; + + let signature = match comm.get_data(DataType::Signature.to_id(&TypeMap::latest())) { + DataValue::Bytes(b) => Some(b.clone()), + _ => None, + }; + + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &"connected".into(), &JsValue::from(connected)).ok(); + if let Some(n) = client_nonce { + let arr = js_sys::Uint8Array::from(&n.to_be_bytes()[..]); + js_sys::Reflect::set(&obj, &"clientNonce".into(), &arr).ok(); + } + if let Some(id) = assigned_id { + js_sys::Reflect::set(&obj, &"assignedId".into(), &JsValue::from(id as f64)).ok(); + } + if let Some(ts) = timestamp { + js_sys::Reflect::set(&obj, &"timestamp".into(), &JsValue::from(ts as f64)).ok(); + } + if let Some(sig) = signature { + let arr = js_sys::Uint8Array::from(&sig[..]); + js_sys::Reflect::set(&obj, &"signature".into(), &arr).ok(); + } + + Ok(obj.into()) +} + +/// Parse any MTP frame into the human-readable CommunicationValue display form. +#[wasm_bindgen] +pub fn format_frame(frame: &[u8]) -> Result { + let comm = CommunicationValue::from_bytes(frame) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; + Ok(comm.to_string()) +} + +/// Parse any MTP frame into structured JavaScript data. +#[wasm_bindgen(unchecked_return_type = "ParsedFrame")] +pub fn parse_frame(frame: &[u8]) -> Result { + parse_frame_value(frame) +} + +/// Build a typed MTP frame using generated communication/data type names. +#[wasm_bindgen] +pub fn build_frame( + message_type: &str, + data: JsValue, + options: JsValue, +) -> Result, JsValue> { + let comm_type = CommunicationType::from_name(message_type) + .ok_or_else(|| js_error(&format!("unknown communication type: {message_type}")))?; + let mut msg = CommunicationValue::new(comm_type); + + if !options.is_null() && !options.is_undefined() { + if let Some(id) = option_u32(&options, "id")? { + msg = msg.with_id(id); + } + if let Some(sender) = option_u64(&options, "sender")? { + msg = msg.with_sender(sender); + } + if let Some(receiver) = option_u64(&options, "receiver")? { + msg = msg.with_receiver(receiver); + } + } + + if data.is_object() && !js_sys::Uint8Array::instanceof(&data) && !js_sys::Array::is_array(&data) + { + let object = js_sys::Object::from(data); + let keys = js_sys::Object::keys(&object); + for key in keys.iter() { + let key = key + .as_string() + .ok_or_else(|| js_error("object key must be a string"))?; + let data_type = DataType::from_name(&key) + .ok_or_else(|| js_error(&format!("unknown data type: {key}")))?; + let value = js_sys::Reflect::get(&object, &JsValue::from_str(&key))?; + msg = msg.add_data( + data_type.to_id(&TypeMap::latest()), + js_to_data_value(&value)?, + ); + } + } else if !data.is_null() && !data.is_undefined() { + return Err(js_error( + "frame data must be an object keyed by MTP data type", + )); + } + + msg.to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e))) +} + +#[cfg(test)] +#[cfg(target_arch = "wasm32")] +mod tests { + use super::*; + use wasm_bindgen_test::*; + + #[wasm_bindgen_test] + fn build_ping_frame_roundtrip() { + let bytes = build_ping_frame(42, "test-ping", 1234567890, &[]).expect("encode failed"); + let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); + let tm = TypeMap::latest(); + + assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); + assert_eq!(cv.get_sender(), 42); + assert_eq!( + cv.get_data(DataType::Description.to_id(&tm)), + &DataValue::Str("test-ping".into()) + ); + assert_eq!( + cv.get_data(DataType::Timestamp.to_id(&tm)), + &DataValue::UnsignedNumber(1234567890) + ); + } + + #[wasm_bindgen_test] + fn build_ping_frame_with_data() { + let payload = b"attachment-data"; + let bytes = build_ping_frame(99, "with-data", 555, payload).expect("encode failed"); + let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); + let tm = TypeMap::latest(); + + assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); + assert_eq!(cv.get_sender(), 99); + assert_eq!( + cv.get_data(DataType::Description.to_id(&tm)), + &DataValue::Str("with-data".into()) + ); + assert_eq!( + cv.get_data(DataType::Timestamp.to_id(&tm)), + &DataValue::UnsignedNumber(555) + ); + assert_eq!( + cv.get_data(DataType::Id.to_id(&tm)), + &DataValue::Bytes(payload.to_vec()) + ); + } + + #[wasm_bindgen_test] + fn build_ping_frame_client_id_zero() { + let bytes = build_ping_frame(0, "zero-id", 0, &[]).expect("encode failed"); + let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); + assert_eq!(cv.get_sender(), 0); + } + + #[wasm_bindgen_test] + fn parse_auth_response_success() { + let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_typed_default(DataType::Connected, DataValue::BoolTrue) + .add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(999)) + .add_typed_default(DataType::Id, DataValue::UnsignedNumber(42)) + .add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(12345)) + .to_bytes() + .expect("encode failed"); + + let result = parse_auth_response(&resp).expect("parse failed"); + + let connected = js_sys::Reflect::get(&result, &"connected".into()) + .ok() + .and_then(|v| v.as_bool()); + assert_eq!(connected, Some(true)); + + let id = js_sys::Reflect::get(&result, &"assignedId".into()) + .ok() + .and_then(|v| v.as_f64()); + assert_eq!(id, Some(42.0)); + } + + #[wasm_bindgen_test] + fn parse_auth_response_rejected() { + let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_typed_default(DataType::Connected, DataValue::BoolFalse) + .to_bytes() + .expect("encode failed"); + + let result = parse_auth_response(&resp).expect("parse failed"); + + let connected = js_sys::Reflect::get(&result, &"connected".into()) + .ok() + .and_then(|v| v.as_bool()); + assert_eq!(connected, Some(false)); + + let has_id = js_sys::Reflect::has(&result, &"assignedId".into()).unwrap_or(false); + assert!(!has_id); + } + + #[wasm_bindgen_test] + fn parse_auth_response_with_signature() { + let sig_bytes = vec![0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]; + let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) + .add_typed_default(DataType::Connected, DataValue::BoolTrue) + .add_typed_default(DataType::Signature, DataValue::Bytes(sig_bytes.clone())) + .to_bytes() + .expect("encode failed"); + + let result = parse_auth_response(&resp).expect("parse failed"); + + let has_sig = js_sys::Reflect::has(&result, &"signature".into()).unwrap_or(false); + assert!(has_sig); + } + + #[wasm_bindgen_test] + fn parse_auth_response_invalid_frame() { + let result = parse_auth_response(b"garbage-data"); + assert!(result.is_err()); + } +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 7727c88..5b49dc6 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -1,7 +1,10 @@ pub mod client; +pub mod config; pub mod crypto; pub mod error; -pub mod message; +pub mod frame; +pub mod logging; +pub mod subscription; pub mod transport; #[cfg(not(test))] @@ -11,5 +14,4 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] pub fn main() { console_error_panic_hook::set_once(); - web_sys::console::log_1(&"mtp-wasm: module loaded".into()); } diff --git a/wasm/src/logging.rs b/wasm/src/logging.rs new file mode 100644 index 0000000..162b846 --- /dev/null +++ b/wasm/src/logging.rs @@ -0,0 +1,10 @@ +use wasm_bindgen::prelude::*; + +/// Log severity used by the public SDK when translating raw WASM events. +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WasmLogHint { + Info = 0, + Warning = 1, + Error = 2, +} diff --git a/wasm/src/message.rs b/wasm/src/message.rs deleted file mode 100644 index 2d94873..0000000 --- a/wasm/src/message.rs +++ /dev/null @@ -1,411 +0,0 @@ -use wasm_bindgen::prelude::*; - -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue}; -use mtp_crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm}; -use mtp_type_map::{TypeMap, communication_type_name}; - -use crate::error::js_error; - -/// Build a simple Ping frame with description, timestamp, and optional data. -#[wasm_bindgen] -pub fn build_ping_frame( - client_id: u64, - description: &str, - timestamp: u64, - data: &[u8], -) -> Result, JsValue> { - let mut msg = CommunicationValue::new(CommunicationType::Ping) - .add_typed_default( - DataType::Description, - DataValue::Str(description.to_string()), - ) - .add_typed_default( - DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), - ) - .with_sender(client_id); - - if !data.is_empty() { - msg = msg.add_typed_default(DataType::Id, DataValue::Bytes(data.to_vec())); - } - - msg.to_bytes() - .map_err(|e| js_error(&format!("encode failed: {}", e))) -} - -/// Build a demo Ping frame with encrypted and signed containers -/// (mirrors the Rust client example but uses only reserved data types). -#[wasm_bindgen] -pub fn build_demo_message( - client_id: u64, - keyring_bytes: &[u8], - host_bundle_bytes: &[u8], -) -> Result, JsValue> { - let keyring = Keyring::from_bytes(keyring_bytes) - .map_err(|e| js_error(&format!("invalid keyring: {}", e)))?; - - // Encrypt to the server's KEM public key; the server decrypts with its keyring. - // (The client keyring only needs the Ed25519 signing key for this demo.) - let recipient = PublicKeyBundle::from_bytes(host_bundle_bytes) - .map_err(|e| js_error(&format!("invalid host bundle: {}", e)))?; - let enc_type = EncryptionType::MlKemChaCha20Poly1305; - let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key) - .map_err(|e| js_error(&format!("signer creation failed: {}", e)))?; - - // Encrypted container - let inner_enc = DataValue::Container(vec![ - ( - DataType::Version.to_id(&TypeMap::latest()), - DataValue::Str("secret inner data".into()), - ), - ( - DataType::Id.to_id(&TypeMap::latest()), - DataValue::UnsignedNumber(42), - ), - ]); - let mut dv_enc = inner_enc; - dv_enc - .encrypt_container(enc_type, &recipient, b"demo-aad") - .ok_or_else(|| js_error("encryption failed"))?; - - // Signed container - let inner_sig = DataValue::Container(vec![ - ( - DataType::Version.to_id(&TypeMap::latest()), - DataValue::Str("signed by client".into()), - ), - ( - DataType::Id.to_id(&TypeMap::latest()), - DataValue::UnsignedNumber(99), - ), - ]); - let mut dv_sig = inner_sig; - dv_sig - .sign_container(SigAlgorithm::ED25519, &signer) - .ok_or_else(|| js_error("signing failed"))?; - - // Signed + encrypted container - let inner_sec = DataValue::Container(vec![ - ( - DataType::Version.to_id(&TypeMap::latest()), - DataValue::Str("signed+encrypted payload".into()), - ), - ( - DataType::Id.to_id(&TypeMap::latest()), - DataValue::UnsignedNumber(7), - ), - ]); - let mut dv_sec = inner_sec; - dv_sec - .sign_and_encrypt_container( - SigAlgorithm::ED25519, - &signer, - enc_type, - &recipient, - b"demo-aad", - ) - .ok_or_else(|| js_error("sign+encrypt failed"))?; - - let timestamp = js_sys::Date::now() as u64; - - let msg = CommunicationValue::new(CommunicationType::Ping) - .add_typed_default( - DataType::Description, - DataValue::Str("MTP WASM Demo".into()), - ) - .add_typed_default( - DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), - ) - .add_typed_default(DataType::Version, DataValue::Str("demo-wasm".into())) - .with_sender(client_id); - - msg.to_bytes() - .map_err(|e| js_error(&format!("encode failed: {}", e))) -} - -/// Parse an auth response frame into a JS object. -#[wasm_bindgen] -pub fn parse_auth_response(response: &[u8]) -> Result { - let comm = CommunicationValue::from_bytes(response) - .map_err(|e| js_error(&format!("parse failed: {}", e)))?; - - let connected = matches!( - comm.get_data(DataType::Connected.to_id(&TypeMap::latest())), - DataValue::BoolTrue - ); - - let client_nonce = match comm.get_data(DataType::ClientNonce.to_id(&TypeMap::latest())) { - DataValue::UnsignedNumber(n) => Some(*n), - _ => None, - }; - - let assigned_id = match comm.get_data(DataType::Id.to_id(&TypeMap::latest())) { - DataValue::UnsignedNumber(n) => Some(*n as u64), - _ => None, - }; - - let timestamp = match comm.get_data(DataType::Timestamp.to_id(&TypeMap::latest())) { - DataValue::UnsignedNumber(n) => Some(*n), - _ => None, - }; - - let signature = match comm.get_data(DataType::Signature.to_id(&TypeMap::latest())) { - DataValue::Bytes(b) => Some(b.clone()), - _ => None, - }; - - let obj = js_sys::Object::new(); - js_sys::Reflect::set(&obj, &"connected".into(), &JsValue::from(connected)).ok(); - if let Some(n) = client_nonce { - let arr = js_sys::Uint8Array::from(&n.to_be_bytes()[..]); - js_sys::Reflect::set(&obj, &"clientNonce".into(), &arr).ok(); - } - if let Some(id) = assigned_id { - js_sys::Reflect::set(&obj, &"assignedId".into(), &JsValue::from(id as f64)).ok(); - } - if let Some(ts) = timestamp { - js_sys::Reflect::set(&obj, &"timestamp".into(), &JsValue::from(ts as f64)).ok(); - } - if let Some(sig) = signature { - let arr = js_sys::Uint8Array::from(&sig[..]); - js_sys::Reflect::set(&obj, &"signature".into(), &arr).ok(); - } - - Ok(obj.into()) -} - -/// Build a request frame with the given communication type name, request ID, and JSON data. -/// -/// - `comm_type`: communication type name (e.g. "get_model", "rank_models") or PascalCase -/// - `id`: request ID for response correlation -/// - `json_data`: JSON-stringified request payload -#[wasm_bindgen] -pub fn build_request_frame(comm_type: &str, id: u32, json_data: &str) -> Result, JsValue> { - let comm_type_enum = CommunicationType::from_name(comm_type) - .or_else(|| { - let pascal = comm_type - .split('_') - .map(|s| { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().to_string() + c.as_str(), - } - }) - .collect::(); - CommunicationType::from_name(&pascal) - }) - .ok_or_else(|| js_error(&format!("unknown communication type: {}", comm_type)))?; - - let frame = CommunicationValue::new(comm_type_enum) - .with_id(id) - .add_data(DataTypeId(32), DataValue::Str(json_data.to_string())) - .to_bytes() - .map_err(|e| js_error(&format!("encode failed: {}", e)))?; - - Ok(frame) -} - -/// Parse a response frame into a JSON string containing `_id`, `_type`, and data fields. -#[wasm_bindgen] -pub fn parse_response_frame(frame: &[u8]) -> Result { - let comm = CommunicationValue::from_bytes(frame) - .map_err(|e| js_error(&format!("parse failed: {}", e)))?; - - let obj = js_sys::Object::new(); - - let _ = js_sys::Reflect::set( - &obj, - &JsValue::from_str("_id"), - &JsValue::from(comm.get_id()), - ); - - let type_name = communication_type_name(comm.get_type().0).unwrap_or("Unknown"); - let _ = js_sys::Reflect::set( - &obj, - &JsValue::from_str("_type"), - &JsValue::from_str(&type_name), - ); - - if let DataValue::Str(s) = comm.get_data(DataTypeId(32)) { - if let Ok(parsed) = js_sys::JSON::parse(s) { - let parsed_obj: &js_sys::Object = parsed.unchecked_ref(); - let entries = js_sys::Object::entries(parsed_obj); - let len = entries.length(); - for i in 0..len { - let entry = js_sys::Array::get(&entries, i); - if let Some(entry_arr) = entry.dyn_ref::() { - if let Some(key) = entry_arr.get(0).as_string() { - let val = entry_arr.get(1); - let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(&key), &val); - } - } - } - } - } - - let stringified = - js_sys::JSON::stringify(&obj).map_err(|_| js_error("JSON stringify failed"))?; - stringified - .as_string() - .ok_or_else(|| js_error("JSON stringify result not a string")) -} - -/// Parse any MTP frame into the human-readable CommunicationValue display form. -#[wasm_bindgen] -pub fn format_frame(frame: &[u8]) -> Result { - let comm = CommunicationValue::from_bytes(frame) - .map_err(|e| js_error(&format!("parse failed: {}", e)))?; - Ok(comm.to_string()) -} - -#[cfg(test)] -#[cfg(target_arch = "wasm32")] -mod tests { - use super::*; - use wasm_bindgen_test::*; - - #[wasm_bindgen_test] - fn build_ping_frame_roundtrip() { - let bytes = build_ping_frame(42, "test-ping", 1234567890, &[]).expect("encode failed"); - let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - let tm = TypeMap::latest(); - - assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); - assert_eq!(cv.get_sender(), 42); - assert_eq!( - cv.get_data(DataType::Description.to_id(&tm)), - &DataValue::Str("test-ping".into()) - ); - assert_eq!( - cv.get_data(DataType::Timestamp.to_id(&tm)), - &DataValue::UnsignedNumber(1234567890) - ); - } - - #[wasm_bindgen_test] - fn build_ping_frame_with_data() { - let payload = b"attachment-data"; - let bytes = build_ping_frame(99, "with-data", 555, payload).expect("encode failed"); - let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - let tm = TypeMap::latest(); - - assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); - assert_eq!(cv.get_sender(), 99); - assert_eq!( - cv.get_data(DataType::Description.to_id(&tm)), - &DataValue::Str("with-data".into()) - ); - assert_eq!( - cv.get_data(DataType::Timestamp.to_id(&tm)), - &DataValue::UnsignedNumber(555) - ); - assert_eq!( - cv.get_data(DataType::Id.to_id(&tm)), - &DataValue::Bytes(payload.to_vec()) - ); - } - - #[wasm_bindgen_test] - fn build_ping_frame_client_id_zero() { - let bytes = build_ping_frame(0, "zero-id", 0, &[]).expect("encode failed"); - let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - assert_eq!(cv.get_sender(), 0); - } - - #[wasm_bindgen_test] - fn build_demo_message_roundtrip() { - // The demo KEM-encrypts to the host's bundle, so a real host keypair is - // required; the client keyring only needs its Ed25519 signing key. - let keyring = Keyring::generate(); - let keyring_bytes = keyring.to_bytes(); - let host_bundle = Keyring::generate().public_key_bundle().as_bytes(); - - let result = build_demo_message(7, &keyring_bytes, &host_bundle); - assert!(result.is_ok()); - - let bytes = result.unwrap(); - let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - let tm = TypeMap::latest(); - - assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); - assert_eq!(cv.get_sender(), 7); - assert_eq!( - cv.get_data(DataType::Description.to_id(&tm)), - &DataValue::Str("MTP WASM Demo".into()) - ); - } - - #[wasm_bindgen_test] - fn build_demo_message_invalid_keyring() { - let host_bundle = Keyring::generate().public_key_bundle().as_bytes(); - let result = build_demo_message(1, b"not-a-valid-keyring", &host_bundle); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.as_string().unwrap().contains("invalid keyring")); - } - - #[wasm_bindgen_test] - fn parse_auth_response_success() { - let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(999)) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(42)) - .add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(12345)) - .to_bytes() - .expect("encode failed"); - - let result = parse_auth_response(&resp).expect("parse failed"); - - let connected = js_sys::Reflect::get(&result, &"connected".into()) - .ok() - .and_then(|v| v.as_bool()); - assert_eq!(connected, Some(true)); - - let id = js_sys::Reflect::get(&result, &"assignedId".into()) - .ok() - .and_then(|v| v.as_f64()); - assert_eq!(id, Some(42.0)); - } - - #[wasm_bindgen_test] - fn parse_auth_response_rejected() { - let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .to_bytes() - .expect("encode failed"); - - let result = parse_auth_response(&resp).expect("parse failed"); - - let connected = js_sys::Reflect::get(&result, &"connected".into()) - .ok() - .and_then(|v| v.as_bool()); - assert_eq!(connected, Some(false)); - - // rejected should have no assignedId - let has_id = js_sys::Reflect::has(&result, &"assignedId".into()).unwrap_or(false); - assert!(!has_id); - } - - #[wasm_bindgen_test] - fn parse_auth_response_with_signature() { - let sig_bytes = vec![0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]; - let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default(DataType::Signature, DataValue::Bytes(sig_bytes.clone())) - .to_bytes() - .expect("encode failed"); - - let result = parse_auth_response(&resp).expect("parse failed"); - - let has_sig = js_sys::Reflect::has(&result, &"signature".into()).unwrap_or(false); - assert!(has_sig); - } - - #[wasm_bindgen_test] - fn parse_auth_response_invalid_frame() { - let result = parse_auth_response(b"garbage-data"); - assert!(result.is_err()); - } -} diff --git a/wasm/src/subscription.rs b/wasm/src/subscription.rs new file mode 100644 index 0000000..5cad3f7 --- /dev/null +++ b/wasm/src/subscription.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; + +use wasm_bindgen::prelude::*; + +/// Minimal message router used by higher-level SDK subscription code. +#[wasm_bindgen] +pub struct WasmSubscriptionRouter { + handlers: HashMap, +} + +#[wasm_bindgen] +impl WasmSubscriptionRouter { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + #[wasm_bindgen] + pub fn subscribe(&mut self, message_type: String, callback: js_sys::Function) { + self.handlers.insert(message_type, callback); + } + + #[wasm_bindgen] + pub fn unsubscribe(&mut self, message_type: &str) -> bool { + self.handlers.remove(message_type).is_some() + } + + #[wasm_bindgen] + pub fn dispatch(&self, message_type: &str, message: JsValue) -> bool { + let Some(callback) = self.handlers.get(message_type) else { + return false; + }; + let _ = callback.call1(&JsValue::NULL, &message); + true + } +} + +impl Default for WasmSubscriptionRouter { + fn default() -> Self { + Self::new() + } +} diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index 795a6f4..c9cdd94 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -4,9 +4,9 @@ use std::rc::Rc; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; -use web_sys::{WebTransport, WebTransportHash, WebTransportOptions}; use crate::error::js_error; +use crate::frame::parse_frame_value; const CLOSE_FRAME_LEN: u32 = u32::MAX; @@ -40,42 +40,6 @@ enum FrameOutcome { Ended, } -/* - * Debug-log the exact bytes about to be written to the WebTransport stream. - * - * Wire layout (note the DOUBLE length prefix): - * [0..4] outer_len u32 BE - added by send_frame (= inner frame length) - * [4..8] inner_len u32 BE - added by CommunicationValue::to_bytes - * [8..10] comm_type u16 BE - e.g. Identification - * [10] flags u8 - * [11..] id/sender/receiver/signature/data, gated by `flags` - */ -fn log_frame_bytes(wire: &[u8]) { - let hex: String = wire - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(" "); - - let outer_len = wire - .get(0..4) - .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); - let inner_len = wire - .get(4..8) - .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); - let comm_type = wire.get(8..10).map(|b| u16::from_be_bytes([b[0], b[1]])); - let flags = wire.get(10).copied(); - - web_sys::console::log_1( - &format!( - "mtp-wasm send_frame: {len} bytes | outer_len={outer_len:?} inner_len={inner_len:?} \ - comm_type={comm_type:?} flags={flags:?}\n{hex}", - len = wire.len(), - ) - .into(), - ); -} - /* * WebTransport client transport. * @@ -90,7 +54,7 @@ fn log_frame_bytes(wire: &[u8]) { */ #[derive(Clone)] pub struct WasmTransport { - inner: WebTransport, + inner: JsValue, /// Reader over `incoming_unidirectional_streams()` (a singleton stream of streams). streams_reader: Rc>>, /// Reader over the host's current uni-directional stream, if one is open. @@ -101,28 +65,48 @@ pub struct WasmTransport { impl WasmTransport { pub async fn connect(url: &str, cert_hashes: Option>) -> Result { - let transport = match cert_hashes { - Some(hashes) => { - let opts = WebTransportOptions::new(); - let mut wt_hashes = Vec::new(); - for h in hashes { - if let Some((algo, hex_val)) = h.split_once(':') { - if let Ok(bytes) = hex::decode(hex_val) { - let hash = WebTransportHash::new(); - hash.set_algorithm(algo); - hash.set_value_u8_array(&js_sys::Uint8Array::from(&bytes[..])); - wt_hashes.push(hash); - } + let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport not available"))?; + let args = js_sys::Array::new(); + args.push(&JsValue::from_str(url)); + + if let Some(hashes) = cert_hashes { + let wt_hashes = js_sys::Array::new(); + for h in hashes { + if let Some((algo, hex_val)) = h.split_once(':') { + if let Ok(bytes) = hex::decode(hex_val) { + let hash = js_sys::Object::new(); + js_sys::Reflect::set( + &hash, + &JsValue::from_str("algorithm"), + &JsValue::from_str(algo), + )?; + js_sys::Reflect::set( + &hash, + &JsValue::from_str("value"), + &js_sys::Uint8Array::from(&bytes[..]), + )?; + wt_hashes.push(&hash); } } - if !wt_hashes.is_empty() { - opts.set_server_certificate_hashes(&wt_hashes); - } - WebTransport::new_with_options(url, &opts)? } - None => WebTransport::new(url)?, + if wt_hashes.length() > 0 { + let opts = js_sys::Object::new(); + js_sys::Reflect::set( + &opts, + &JsValue::from_str("serverCertificateHashes"), + &wt_hashes, + )?; + args.push(&opts); + } }; - JsFuture::from(transport.ready()) + + let transport = js_sys::Reflect::construct(&ctor, &args)?; + let ready = js_sys::Reflect::get(&transport, &JsValue::from_str("ready"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; + JsFuture::from(ready) .await .map_err(|e| js_error(&format!("WebTransport ready failed: {:?}", e)))?; Ok(Self { @@ -133,12 +117,21 @@ impl WasmTransport { }) } - pub fn inner(&self) -> &WebTransport { + pub fn inner(&self) -> &JsValue { &self.inner } pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> { - let stream_promise = self.inner.create_unidirectional_stream(); + let create_stream = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("createUnidirectionalStream"), + )? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream not a function"))?; + let stream_promise = create_stream + .call0(&self.inner)? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; let stream = JsFuture::from(stream_promise).await?; let writable_or_stream = resolve_stream_writable(&stream)?; @@ -155,8 +148,6 @@ impl WasmTransport { wire.extend_from_slice(&len.to_be_bytes()); wire.extend_from_slice(frame); - log_frame_bytes(&wire); - let chunk = js_sys::Uint8Array::from(&wire[..]); let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) @@ -185,7 +176,10 @@ impl WasmTransport { if let Some(reader) = self.streams_reader.borrow().clone() { return Ok(reader); } - let incoming = self.inner.incoming_unidirectional_streams(); + let incoming = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("incomingUnidirectionalStreams"), + )?; let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) .map_err(|_| js_error("missing getReader"))? .dyn_into::() @@ -341,10 +335,15 @@ impl WasmTransport { pub async fn receive_loop(&self, on_message: js_sys::Function, on_error: js_sys::Function) { loop { match self.next_frame().await { - Ok(FrameOutcome::Frame(frame)) => { - let arr = js_sys::Uint8Array::from(&frame[..]); - let _ = on_message.call1(&JsValue::NULL, &arr); - } + Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) { + Ok(parsed) => { + let _ = on_message.call1(&JsValue::NULL, &parsed); + } + Err(e) => { + let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); + let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); + } + }, Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break, Err(e) => { let _ = on_error.call1(&JsValue::NULL, &e); @@ -355,7 +354,10 @@ impl WasmTransport { } pub fn close(&self) { - let info = web_sys::WebTransportCloseInfo::new(); - let _ = self.inner.close_with_close_info(&info); + if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) + .and_then(|value| value.dyn_into::().map_err(Into::into)) + { + let _ = close.call1(&self.inner, &js_sys::Object::new()); + } } } diff --git a/wasm/types/mtp_wasm.d.ts b/wasm/types/mtp_wasm.d.ts new file mode 100644 index 0000000..532ea10 --- /dev/null +++ b/wasm/types/mtp_wasm.d.ts @@ -0,0 +1,173 @@ +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; +export type SyncInitInput = BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; +} + +export interface DisposableWasmObject { + free(): void; + [Symbol.dispose](): void; +} + +export type StateChangeCallback = (state: ConnectionState) => void; +export type MessageCallback = (frame: ParsedFrame) => void; +export type ErrorCallback = (error: string) => void; + +export interface Ed25519GenerateResult { + signer: WasmEd25519Signer; + secretKey: Uint8Array; + publicKey: Uint8Array; +} + +export interface AuthResponse { + connected: boolean; + clientNonce?: Uint8Array; + assignedId?: number; + timestamp?: number; + signature?: Uint8Array; +} + +export interface ParsedResponse { + _id?: number; + _type: string; + [field: string]: unknown; +} + +export interface ParsedFrame { + id?: number; + type: string; + sender?: bigint; + receiver?: bigint; + data: Record; + raw: Uint8Array; +} + +export class ConnectionConfig implements DisposableWasmObject { + constructor(url: string); + free(): void; + [Symbol.dispose](): void; + client_id: bigint; + server_certificate_hashes: string[]; + readonly url: string; +} + +export enum ConnectionState { + Disconnected = 0, + Connecting = 1, + Connected = 2, + Failed = 3, +} + +export enum WasmLogHint { + Info = 0, + Warning = 1, + Error = 2, +} + +export class WasmChaCha20Poly1305 implements DisposableWasmObject { + constructor(key: Uint8Array); + free(): void; + [Symbol.dispose](): void; + decrypt(ciphertext: Uint8Array, aad: Uint8Array): Uint8Array; + encrypt(plaintext: Uint8Array, aad: Uint8Array): Uint8Array; +} + +export class WasmClient implements DisposableWasmObject { + constructor( + on_state_change: StateChangeCallback, + on_message: MessageCallback, + on_error: ErrorCallback, + ); + free(): void; + [Symbol.dispose](): void; + auth_connect( + config: ConnectionConfig, + host_public_key_bytes: Uint8Array, + keyring_bytes: Uint8Array, + client_id: bigint, + ): Promise; + auth_register( + config: ConnectionConfig, + host_public_key_bytes: Uint8Array, + keyring_bytes: Uint8Array, + ): Promise; + connect(config: ConnectionConfig): Promise; + disconnect(): void; + request(frame: Uint8Array, response_type?: string | null): Promise; + send(frame: Uint8Array): Promise; + start_protocol_pings(interval_ms: number, client_id: bigint): void; + stop_protocol_pings(): void; + subscribe(message_type: string, callback: MessageCallback): number; + unsubscribe(id: number): boolean; + static is_supported(): boolean; + readonly state: ConnectionState; +} + +export class WasmEd25519Signer implements DisposableWasmObject { + constructor(secret_key: Uint8Array); + free(): void; + [Symbol.dispose](): void; + sign(message: Uint8Array): Uint8Array; + verify(message: Uint8Array, signature: Uint8Array): void; +} + +export class WasmKeyring implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + static from_bytes(bytes: Uint8Array): WasmKeyring; + public_key_bundle(): WasmPublicKeyBundle; + to_bytes(): Uint8Array; +} + +export class WasmPublicKeyBundle implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + static from_bytes(bytes: Uint8Array): WasmPublicKeyBundle; + to_bytes(): Uint8Array; + readonly kem_public_key: Uint8Array; + readonly sig_cl_public_key: Uint8Array; + readonly sig_pq_public_key: Uint8Array; +} + +export class WasmSubscriptionRouter implements DisposableWasmObject { + constructor(); + free(): void; + [Symbol.dispose](): void; + dispatch(message_type: string, message: unknown): boolean; + subscribe(message_type: string, callback: (message: unknown) => void): void; + unsubscribe(message_type: string): boolean; +} + +export function build_ping_frame( + client_id: bigint, + description: string, + timestamp: bigint, + data: Uint8Array, +): Uint8Array; + +export function build_frame(message_type: string, data: Record, options?: { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +}): Uint8Array; + +export function ed25519_generate(): Ed25519GenerateResult; +export function ed25519_verify(public_key: Uint8Array, message: Uint8Array, signature: Uint8Array): void; +export function format_frame(frame: Uint8Array): string; +export function keyring_from_ed25519(secret_key: Uint8Array, public_key: Uint8Array): Uint8Array; +export function main(): void; +export function parse_auth_response(response: Uint8Array): AuthResponse; +export function parse_frame(frame: Uint8Array): ParsedFrame; +export function wasm_derive_encryption_key(ikm: Uint8Array, salt: Uint8Array, context: Uint8Array): Uint8Array; +export function wasm_hkdf_expand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; +export function wasm_sha256(data: Uint8Array): Uint8Array; +export function wasm_sha256_double(data: Uint8Array): Uint8Array; + +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +export default function init( + module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise, +): Promise;