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 8feff5e..42eeb79 100644 Binary files a/wasm/pkg/mtp_wasm_bg.wasm and b/wasm/pkg/mtp_wasm_bg.wasm differ diff --git a/wasm/pkg/mtp_wasm_bg.wasm.d.ts b/wasm/pkg/mtp_wasm_bg.wasm.d.ts index f7dd026..99637c2 100644 --- a/wasm/pkg/mtp_wasm_bg.wasm.d.ts +++ b/wasm/pkg/mtp_wasm_bg.wasm.d.ts @@ -1,6 +1,11 @@ /* tslint:disable */ /* eslint-disable */ export const memory: WebAssembly.Memory; +export const build_frame: (a: number, b: number, c: any, d: any) => [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;