(feat): redesign WASM module, add TypeScript SDK, migrate to pnpm
Some checks failed
CI / rustfmt (push) Successful in 17s
CI / wasm build (push) Successful in 1m16s
CI / clippy (push) Successful in 1m28s
CI / test (push) Successful in 1m48s
CI / example (push) Successful in 1m31s
CI / duplicate code (push) Failing after 33s
CI / web client (push) Failing after 34s
CI / cargo-machete (push) Successful in 1m18s
CI / cargo-deny (push) Failing after 3m2s

This commit is contained in:
Alois 2026-06-27 23:44:27 +02:00
commit 5caa1c9d5f
49 changed files with 3717 additions and 1501 deletions

View file

@ -44,17 +44,7 @@ jobs:
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml"
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings \ cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
-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
test: test:
name: test name: test
@ -169,21 +159,21 @@ jobs:
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: https://data.forgejo.org/actions/checkout@v4
- name: Install system dependencies - 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: | run: |
curl -fsSL https://bun.sh/install | bash curl -fsSL https://get.pnpm.io/install.sh | SHELL=/bin/sh sh -
- name: Install dependencies - name: Install dependencies
run: | run: |
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.local/share/pnpm:$PATH"
bun install --frozen-lockfile pnpm install --frozen-lockfile
- name: Run duplicate detector - name: Run duplicate detector
run: | run: |
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.local/share/pnpm:$PATH"
bun run dup pnpm run dup
web-client: web-client:
name: web client name: web client
@ -192,7 +182,7 @@ jobs:
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: https://data.forgejo.org/actions/checkout@v4
- name: Install system dependencies - 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 - name: Install Rust
run: | run: |
@ -209,18 +199,24 @@ jobs:
env: env:
RUSTFLAGS: --cfg web_sys_unstable_apis RUSTFLAGS: --cfg web_sys_unstable_apis
- name: Install Bun - name: Install pnpm
run: | run: |
curl -fsSL https://bun.sh/install | bash curl -fsSL https://get.pnpm.io/install.sh | SHELL=/bin/sh sh -
- name: Install dependencies - name: Install dependencies
working-directory: example/web-client
run: | run: |
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.local/share/pnpm:$PATH"
bun install --frozen-lockfile 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 - name: Build web client
working-directory: example/web-client
run: | run: |
export PATH="$HOME/.bun/bin:$PATH" export PATH="$HOME/.local/share/pnpm:$PATH"
bun run build pnpm --filter mtp-web-client run build

2
.gitignore vendored
View file

@ -2,3 +2,5 @@
*.pem *.pem
*.key *.key
node_modules/ node_modules/
dist/
*.tgz

22
Cargo.lock generated
View file

@ -535,6 +535,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 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]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
@ -1035,7 +1044,6 @@ dependencies = [
name = "mtp-host" name = "mtp-host"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"log",
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
"mtp-crypto", "mtp-crypto",
@ -1070,6 +1078,7 @@ name = "mtp-wasm"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures-channel",
"getrandom 0.2.17", "getrandom 0.2.17",
"getrandom 0.4.3", "getrandom 0.4.3",
"hex", "hex",
@ -1081,7 +1090,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-bindgen-test", "wasm-bindgen-test",
"web-sys",
] ]
[[package]] [[package]]
@ -2159,16 +2167,6 @@ version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" 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]] [[package]]
name = "web-time" name = "web-time"
version = "1.1.0" version = "1.1.0"

View file

@ -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. 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 ## Getting Started

View file

@ -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=="],
}
}

View file

@ -18,10 +18,47 @@ fn unexpected_response_type_error(
pub struct ClientConfig { pub struct ClientConfig {
pub url: String, pub url: String,
pub server_cert: Option<Vec<u8>>, pub tls: ClientTlsConfig,
pub client_id: u64, pub client_id: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientTlsConfig {
SystemRoots,
PinnedPem(Vec<u8>),
}
impl ClientConfig {
pub fn new(url: impl Into<String>) -> 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<u8>) -> 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<Vec<u8>> {
match &self.tls {
ClientTlsConfig::SystemRoots => None,
ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()),
}
}
}
/* Established MTP connection with a single negotiated version. */ /* Established MTP connection with a single negotiated version. */
pub struct MTPConnection { pub struct MTPConnection {
pub version: Version, pub version: Version,
@ -52,7 +89,7 @@ impl MTPClient {
*/ */
pub async fn connect(config: ClientConfig) -> Result<MTPConnection, CommunicationError> { pub async fn connect(config: ClientConfig) -> Result<MTPConnection, CommunicationError> {
let (sender, receiver) = 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. // Build the initial identification message with the protocol version.
let version_str = format!("{}", PROTOCOL_VERSION); let version_str = format!("{}", PROTOCOL_VERSION);
@ -264,7 +301,7 @@ impl MTPClient {
use mtp_crypto::auth; use mtp_crypto::auth;
let (sender, receiver) = 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 tm = mtp_codec::TypeMap::latest();
let version_str = format!("{}", PROTOCOL_VERSION); let version_str = format!("{}", PROTOCOL_VERSION);
@ -337,7 +374,7 @@ impl MTPClient {
use mtp_crypto::auth; use mtp_crypto::auth;
let (sender, receiver) = 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 tm = mtp_codec::TypeMap::latest();
let version_str = format!("{}", PROTOCOL_VERSION); let version_str = format!("{}", PROTOCOL_VERSION);
@ -414,23 +451,20 @@ mod tests {
#[test] #[test]
fn test_client_config_url() { fn test_client_config_url() {
let config = ClientConfig { let config = ClientConfig::new("https://example.com:4433");
url: "https://example.com:4433".into(),
server_cert: None,
client_id: 0,
};
assert_eq!(config.url, "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] #[test]
fn test_client_config_with_cert() { fn test_client_config_with_cert() {
let config = ClientConfig { let config = ClientConfig::new("https://localhost:4433")
url: "https://localhost:4433".into(), .with_pinned_pem(vec![0x01, 0x02, 0x03])
server_cert: Some(vec![0x01, 0x02, 0x03]), .with_client_id(42);
client_id: 42, assert_eq!(
}; config.tls,
assert_eq!(config.server_cert, Some(vec![0x01, 0x02, 0x03])); ClientTlsConfig::PinnedPem(vec![0x01, 0x02, 0x03])
);
assert_eq!(config.client_id, 42); assert_eq!(config.client_id, 42);
} }

View file

@ -124,6 +124,10 @@ impl CommunicationValue {
pub fn get_data(&self, data_type: DataTypeId) -> &DataValue { pub fn get_data(&self, data_type: DataTypeId) -> &DataValue {
self.data.get(&data_type).unwrap_or(&DataValue::Null) self.data.get(&data_type).unwrap_or(&DataValue::Null)
} }
pub fn data(&self) -> &BTreeMap<DataTypeId, DataValue> {
&self.data
}
} }
impl CommunicationValue { impl CommunicationValue {

View file

@ -33,16 +33,17 @@ The host creates a QUIC server, manages the registry, and handles version negoti
### Initialization ### 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 ```rust
use mtp::host::{MTPHost, HostConfig}; use mtp::host::{HostConfig, MTPHost};
let config = HostConfig { let config = HostConfig::new(
port: 4433, "0.0.0.0".parse()?,
tls_fullchain: std::fs::read("cert.pem")?, 4433,
tls_key: std::fs::read("key.pem")?, std::fs::read("cert.pem")?,
}; std::fs::read("key.pem")?,
);
let mut host = MTPHost::new(config).await?; let mut host = MTPHost::new(config).await?;
``` ```
@ -50,7 +51,7 @@ let mut host = MTPHost::new(config).await?;
### Accepting Connections with Version Negotiation ### Accepting Connections with Version Negotiation
```rust ```rust
while let Some(conn) = host.accept().await { while let Some(conn) = host.accept().await? {
// conn.version is the negotiated version // conn.version is the negotiated version
// conn.codec is a VersionedCodec scoped to that version // conn.codec is a VersionedCodec scoped to that version
// conn.sender / conn.receiver for raw CommunicationValue I/O // conn.sender / conn.receiver for raw CommunicationValue I/O
@ -63,23 +64,23 @@ The host's `accept()` method:
1. Accepts a QUIC connection 1. Accepts a QUIC connection
2. If authentication is required (crypto feature): performs login/register handshake 2. If authentication is required (crypto feature): performs login/register handshake
3. Reads the first `CommunicationValue` (always encoded with reserved type IDs) 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])` 5. Calls `registry.negotiate(&[client_version])`
6. Returns `None` if the version is unsupported 6. Returns an `AcceptError` if the version is unsupported
7. Returns an `MTPConnection` with the negotiated version otherwise 7. Returns `Ok(Some(MTPConnection))` with the negotiated version otherwise
### Login/Register Handshake ### Login/Register Handshake
When `require_authentication` is set, the parties run a mutually-authenticated When `require_authentication` is set, the parties run a mutually-authenticated
**challenge-response**. The client speaks first with an *unsigned* hello: **challenge-response**. The client speaks first with an *unsigned* hello:
- **Login** (`CommunicationType::Identification`, ID 15): version, client ID - **Login** (`CommunicationType::Identification`, reserved ID 0): version, client ID
- **Register** (`CommunicationType::Register`, ID 17): version, public keys - **Register** (`CommunicationType::Register`, reserved ID 2): version, public keys
The host then issues a fresh random `server_challenge` in a signed `Challenge` 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 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. 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 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. The client connects to a host and uses a single compiled-in protocol version.
```rust ```rust
use mtp::client::{MTPClient, ClientConfig}; use mtp::client::{ClientConfig, MTPClient};
let config = ClientConfig { let config = ClientConfig::new("https://host.example.com:4433");
url: "https://host.example.com:4433".into(), let pinned = config.clone().with_pinned_pem(cert_pem_bytes);
server_cert: None, // or Some(cert_pem_bytes)
};
// Connect (unauthenticated, existing client) // Connect (unauthenticated, existing client)
let conn = MTPClient::connect(config, 8765).await?; let conn = MTPClient::connect(config.clone().with_client_id(8765)).await?;
// Authenticated login // 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) // 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. 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.

View file

@ -17,24 +17,22 @@ mtp = { path = "/path/to/mtp", features = ["client", "crypto"] }
## ClientConfig ## ClientConfig
```rust ```rust
use mtp::client::ClientConfig; use mtp::client::{ClientConfig, ClientTlsConfig};
let config = ClientConfig { let config = ClientConfig::new("https://host.example.com:4433")
url: "https://host.example.com:4433".into(), .with_tls(ClientTlsConfig::SystemRoots)
server_cert: None, // None = use system root certificates .with_client_id(0);
client_id: 0, // previously assigned ID or 0
};
``` ```
| Field | Type | Description | | Field | Type | Description |
|--------------|--------------------|-----------------------------------------------------| |--------------|--------------------|-----------------------------------------------------|
| `url` | `String` | `https://host:port` address of the MTP host | | `url` | `String` | `https://host:port` address of the MTP host |
| `server_cert`| `Option<Vec<u8>>` | `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`) | | `client_id` | `u64` | Client identifier (ignored during `auth_register`) |
### TLS Certificate Handling ### 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 native root certificate store** via `rustls_native_certs`. This works with
publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS
(Keychain), and Windows (Root Store). (Keychain), and Windows (Root Store).
@ -44,10 +42,7 @@ certificates:
```rust ```rust
let pem = std::fs::read("my-server-cert.pem")?; let pem = std::fs::read("my-server-cert.pem")?;
let config = ClientConfig { let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem);
server_cert: Some(pem),
// ...
};
``` ```
When pinned, **only** the given certificate(s) are trusted for the TLS When pinned, **only** the given certificate(s) are trusted for the TLS
@ -78,13 +73,9 @@ pub struct MTPConnection {
### Unauthenticated Connect ### Unauthenticated Connect
```rust ```rust
use mtp::client::{MTPClient, ClientConfig}; use mtp::client::{ClientConfig, MTPClient};
let config = ClientConfig { let config = ClientConfig::new("https://host.example.com:4433").with_client_id(42);
url: "https://host.example.com:4433".into(),
server_cert: None,
client_id: 42,
};
let conn = MTPClient::connect(config).await?; let conn = MTPClient::connect(config).await?;
``` ```
@ -101,10 +92,8 @@ use mtp::crypto::{Keyring, PublicKeyBundle};
let keys = Keyring::from_bytes(&saved_keyring_bytes)?; let keys = Keyring::from_bytes(&saved_keyring_bytes)?;
let host_pk = PublicKeyBundle::from_bytes(&saved_host_pk_bytes)?; let host_pk = PublicKeyBundle::from_bytes(&saved_host_pk_bytes)?;
let config = ClientConfig { let config = ClientConfig::new("https://host.example.com:4433")
client_id: 42, // must match the keyring's identity .with_client_id(42); // must match the keyring's identity
// ...
};
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?; let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
``` ```
@ -315,7 +304,11 @@ using `MTPClient`:
```rust ```rust
use mtp_transport::{connect, Policy}; 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 Then build and send the initial `Identification` frame manually to complete
@ -324,7 +317,7 @@ version negotiation.
## Version ## Version
The client's protocol version is baked in at compile time via the 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`. `protocol_version` field in your `type-maps.yaml`.
The client never imports the `registry` module; it uses a single compiled-in The client never imports the `registry` module; it uses a single compiled-in

View file

@ -21,25 +21,23 @@ mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
use mtp::host::HostConfig; use mtp::host::HostConfig;
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
let config = HostConfig { let config = HostConfig::new(
ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), IpAddr::V4(Ipv4Addr::UNSPECIFIED),
port: 4433, 4433,
tls_fullchain: std::fs::read("cert.pem")?, std::fs::read("cert.pem")?,
tls_key: std::fs::read("key.pem")?, std::fs::read("key.pem")?,
)
// Crypto fields (required when feature = "crypto"): .with_authentication(
require_authentication: true, /* Keyring */,
host_id: 1, |client_id: u64| -> Option<PublicKeyBundle> {
host_keyring: /* Keyring */,
get_existing_user: Box::new(|client_id: u64| -> Option<PublicKeyBundle> {
CLIENT_DB.lock().unwrap().get(&client_id).cloned() CLIENT_DB.lock().unwrap().get(&client_id).cloned()
}), },
complete_register: Box::new(|bundle: PublicKeyBundle| -> u64 { |bundle: PublicKeyBundle| -> u64 {
let id = next_id(); let id = next_id();
CLIENT_DB.lock().unwrap().insert(id, bundle); CLIENT_DB.lock().unwrap().insert(id, bundle);
id id
}), },
}; );
``` ```
| Field | Type | Description | | Field | Type | Description |
@ -49,10 +47,9 @@ let config = HostConfig {
| `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain | | `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain |
| `tls_key` | `Vec<u8>` | PEM-encoded TLS private key | | `tls_key` | `Vec<u8>` | PEM-encoded TLS private key |
| `require_authentication` | `bool` (crypto) | Enable login/register handshake | | `require_authentication` | `bool` (crypto) | Enable login/register handshake |
| `host_id` | `u64` (crypto) | Host identifier |
| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys | | `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys |
| `get_existing_user` | `Box<dyn Fn(u64) -> Option<PublicKeyBundle> + Send>` (crypto) | Lookup callback for login | | `get_existing_user` | `Fn(u64) -> Option<PublicKeyBundle> + Send + Sync` (crypto) | Lookup callback for login |
| `complete_register` | `Box<dyn Fn(PublicKeyBundle) -> u64 + Send>` (crypto) | Registration callback, returns new client ID | | `complete_register` | `Fn(PublicKeyBundle) -> u64 + Send + Sync` (crypto) | Registration callback, returns new client ID |
### TLS ### TLS
@ -67,7 +64,7 @@ use mtp::host::MTPHost;
let mut host = MTPHost::new(config).await?; let mut host = MTPHost::new(config).await?;
println!("Listening on {}", host.local_addr()); 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 // 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 1. Accept the QUIC connection
2. Read the client's first `CommunicationValue` (always encoded with reserved 2. Read the client's first `CommunicationValue` (always encoded with reserved
type IDs) 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")` `DataValue::Str("major.minor")`
4. Call `registry.negotiate(&[client_version])` to find the highest mutually 4. Call `registry.negotiate(&[client_version])` to find the highest mutually
supported version supported version
5. Return `None` (closing the connection) if no compatible version exists 5. Return an `AcceptError` (closing the connection) if no compatible version exists
6. Return an `MTPConnection` with the negotiated version 6. Return `Ok(Some(MTPConnection))` with the negotiated version
The `Registry` is built automatically from all type maps defined in your The `Registry` is built automatically from all type maps defined in your
`type-maps.yaml` via `Registry::builtin()`. `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 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 rejection response with `Connected=false` and closes the send stream, returning
`None` from `accept()`. `AcceptError::AuthenticationFailed` from `accept()`.
## Handling Messages ## Handling Messages
Use `conn.sender` and `conn.receiver` for bidirectional message exchange: Use `conn.sender` and `conn.receiver` for bidirectional message exchange:
```rust ```rust
while let Some(conn) = host.accept().await { while let Some(conn) = host.accept().await? {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match conn.receiver.receive().await { match conn.receiver.receive().await {
@ -265,9 +262,9 @@ verification. Must return `Some(PublicKeyBundle)` if the client ID is known,
or `None` to reject. or `None` to reject.
```rust ```rust
let get_existing_user = Box::new(|id: u64| -> Option<PublicKeyBundle> { let get_existing_user = |id: u64| -> Option<PublicKeyBundle> {
db.lock().unwrap().get(&id).cloned() db.lock().unwrap().get(&id).cloned()
}); };
``` ```
### complete_register ### complete_register
@ -277,16 +274,16 @@ assign a client ID. The returned `u64` becomes the client's permanent
identifier. identifier.
```rust ```rust
let complete_register = Box::new(|bundle: PublicKeyBundle| -> u64 { let complete_register = |bundle: PublicKeyBundle| -> u64 {
let mut db = db.lock().unwrap(); let mut db = db.lock().unwrap();
let id = next_id; let id = next_id;
next_id += 1; next_id += 1;
db.insert(id, bundle); db.insert(id, bundle);
id 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` invoked synchronously, so avoid long-running operations (or use `spawn_blocking`
if needed, though the callbacks are `Fn`, not `AsyncFn`). 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 Drop the `MTPHost` to stop accepting new connections. Active connections
continue until their `Sender`/`Receiver` are dropped or the peer disconnects. continue until their `Sender`/`Receiver` are dropped or the peer disconnects.

View file

@ -10,7 +10,18 @@ Type names are defined in a YAML config and turned into Rust enums at **compile
### Defining Type Maps ### 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 ### Using Generated Enums
@ -84,7 +95,18 @@ let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
## Customizing Type Maps in Downstream Projects ## 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 1. Create a `type-maps.yaml` in your project root
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`: 2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:

View file

@ -1,291 +1,282 @@
# MTP WASM Client # 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 ## Package Entry Points
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:
```typescript ```typescript
import init, { WasmClient } from 'mtp-wasm'; import { MTPClient } from "mtp";
import init, { WasmClient } from "mtp/raw";
await init(); 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 ```typescript
if (!WasmClient.is_supported()) { // vite.config.ts
// fall back or show an error 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 ```typescript
const config = new ConnectionConfig("https://host.example.com:4433"); import { MTPClient } from "mtp";
config.client_id = 12345n; // optional, for re-authentication
config.server_certificate_hashes = [ // optional, for certificate pinning
"sha-256:abc123...",
];
```
`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 await client.connectOrRegister();
**built-in root certificate store** to verify the server's TLS certificate, await client.send("SomeType", { value: "hello" });
just like any other HTTPS/WebSocket connection. This works with publicly-trusted
certificate authorities automatically.
For development or self-signed certificates, pin the server certificate by const response = await client.request(
providing its hash: "SomeRequestType",
{ id: "abc" },
```typescript { responseType: "SomeResponseType" },
config.server_certificate_hashes = [
"sha-256:abcd1234...", // hex-encoded hash value
];
```
The hash format is `"<algorithm>:<hex-encoded-hash>"`, 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
); );
```
### Connection States client.raw.client; // underlying WasmClient instance
client.raw.bindings; // generated raw WASM module exports
| Value | Name | unsubscribe();
|-------|--------------|
| 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
client.disconnect(); client.disconnect();
``` ```
Gracefully closes the WebTransport session. `MTPClient.isSupported()` checks whether the current browser exposes WebTransport:
## Building Frames
### `build_ping_frame`
```typescript ```typescript
function build_ping_frame( if (!MTPClient.isSupported()) {
clientId: bigint, throw new Error("WebTransport is not available in this browser");
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,
} }
``` ```
## 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 ```typescript
const result = ed25519_generate(); const storage = {
// result.signer -> WasmEd25519Signer getItem: (key: string) => localStorage.getItem(key),
// result.secretKey -> Uint8Array (32 bytes) setItem: (key: string, value: string) => localStorage.setItem(key, value),
// result.publicKey -> Uint8Array (32 bytes) 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 The storage contract is intentionally small and may be sync or async:
A `Keyring` bundles all key material for an identity. For Ed25519-only setups:
```typescript ```typescript
const keyringBytes = keyring_from_ed25519(secretKey, publicKey); interface MTPCredentialStorage {
// keyringBytes is ready for WasmClient.auth_register or WasmClient.auth_connect getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}
``` ```
Full keyring with KEM + ML-DSA requires constructing on the Rust side. The `credentials` can also be supplied directly:
serialized bytes are portable:
```typescript ```typescript
const keyring = WasmKeyring.from_bytes(keyringBytes); const client = await MTPClient.create({
const bundle = keyring.public_key_bundle(); url: "https://host.example.com:4433",
// bundle.kem_public_key -> Uint8Array hostPublicKey,
// bundle.sig_cl_public_key -> Uint8Array credentials: {
// bundle.sig_pq_public_key -> Uint8Array 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 ```typescript
const signer = new WasmEd25519Signer(secretKey); await MTPClient.create({
const sig = signer.sign(message); // Uint8Array url: "https://host.example.com:4433",
signer.verify(message, sig); // throws on mismatch serverCertificateHashes: ["sha-256:abcd1234..."],
});
// Standalone verification (no signer object needed):
ed25519_verify(publicKey, message, signature);
``` ```
### 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 ```typescript
const cipher = new WasmChaCha20Poly1305(key); // 32-byte key await client.send("SomeType", { value: "hello" });
const encrypted = cipher.encrypt(plaintext, aad); // nonce || ciphertext await client.send(rawFrameBytes);
const decrypted = cipher.decrypt(encrypted, aad);
``` ```
### 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 ```typescript
const hash = wasm_sha256(data); // 32 bytes await client.send("SomeType", { value: "hello" }, {
const double = wasm_sha256_double(data); // SHA-256(SHA-256(data)) id: 7,
sender: client.credentials?.clientId ?? 0n,
const derived = wasm_hkdf_expand(ikm, salt, info, len); });
const encKey = wasm_derive_encryption_key(ikm, salt, context); // 32 bytes
``` ```
## 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 ```typescript
them (e.g. in `localStorage`). On next visit, load the saved keyring and const response = await client.request(
call `auth_connect` instead of registering again. "SomeRequestType",
{ id: "abc" },
{ responseType: "SomeResponseType" },
);
```
2. **Ownership** -- call `config.free()` after connecting if the config object `subscribe` registers a message-type handler and returns an unsubscribe function:
is no longer needed. WASM objects (`WasmClient`, `WasmKeyring`, etc.) are
garbage-collected, but explicit `free()` or `dispose()` reclaims memory
sooner.
3. **Receive loop** -- once `connect`, `auth_connect`, or `auth_register` ```typescript
resolves, the receive loop is running in the background. Incoming frames const unsubscribe = client.subscribe("SomeType", (message) => {
arrive on the `on_message` callback. There is no need to poll. console.log(message.id, message.sender, message.data);
});
4. **Single active client** -- a `WasmClient` manages one WebTransport session. unsubscribe();
Create a new instance for each connection. ```
5. **State transitions** -- after `disconnect()` the client transitions to Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives:
`Disconnected`. The instance is reusable; call a connect method again to
open a new session. ```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<string, unknown>;
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.

1
example/Cargo.lock generated
View file

@ -952,7 +952,6 @@ dependencies = [
name = "mtp-host" name = "mtp-host"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"log",
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
"mtp-crypto", "mtp-crypto",

View file

@ -41,11 +41,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Connecting to 127.0.0.1:8080 ..."); println!("Connecting to 127.0.0.1:8080 ...");
let config = ClientConfig { let config = ClientConfig::new("https://127.0.0.1:8080").with_pinned_pem(cert_pem);
url: "https://127.0.0.1:8080".into(),
server_cert: Some(cert_pem),
client_id: 0,
};
let server_bundle = host_public_key.clone(); let server_bundle = host_public_key.clone();
let (conn, keyring) = let (conn, keyring) =

View file

@ -33,7 +33,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tls::export_webtransport_cert_hash(&cert_hash)?; tls::export_webtransport_cert_hash(&cert_hash)?;
println!("WebTransport certificate sha256: {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)?; keys::export_host_public_keys(&host_keyring)?;
// The keyring is moved into the host config; keep a copy for decrypting the // The keyring is moved into the host config; keep a copy for decrypting the
@ -44,7 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (clients, next_id) = clients::load_client_db("clients.json")?; let (clients, next_id) = clients::load_client_db("clients.json")?;
let clients_for_get = clients.clone(); let clients_for_get = clients.clone();
let get_existing_user = Box::new(move |id: u64| -> Option<mtp::crypto::PublicKeyBundle> { let get_existing_user = move |id: u64| -> Option<mtp::crypto::PublicKeyBundle> {
let result = clients_for_get.lock().unwrap().get(&id).cloned(); let result = clients_for_get.lock().unwrap().get(&id).cloned();
if result.is_some() { if result.is_some() {
println!("Auth lookup: client ID {id} found"); println!("Auth lookup: client ID {id} found");
@ -52,12 +52,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("Auth lookup: unknown client ID {id}"); eprintln!("Auth lookup: unknown client ID {id}");
} }
result result
}); };
let clients_for_register = clients.clone(); let clients_for_register = clients.clone();
let next_id_for_register = next_id.clone(); let next_id_for_register = next_id.clone();
let clients_path = "clients.json".to_string(); 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 db = clients_for_register.lock().unwrap();
let mut nid = next_id_for_register.lock().unwrap(); let mut nid = next_id_for_register.lock().unwrap();
let id = *nid; let id = *nid;
@ -72,26 +72,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
println!("Registered new client with ID: {}", id); println!("Registered new client with ID: {}", id);
id id
}); };
println!("Starting MTP server on port 8080 ..."); println!("Starting MTP server on port 8080 ...");
let config = HostConfig { let config = HostConfig::new(
ip: std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
port: 8080, 8080,
tls_fullchain: cert_pem, cert_pem,
tls_key: key_pem, key_pem,
require_authentication: true, )
host_id, .with_authentication(host_keyring, get_existing_user, complete_register);
host_keyring,
get_existing_user,
complete_register,
};
let mut host = MTPHost::new(config).await?; let mut host = MTPHost::new(config).await?;
println!("Server listening on {}", host.local_addr()); println!("Server listening on {}", host.local_addr());
while let Some(conn) = host.accept().await { while let Some(conn) = host.accept().await? {
println!( println!(
"\n--- New authenticated connection (version {}) ---", "\n--- New authenticated connection (version {}) ---",
conn.version conn.version

View file

@ -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=="],
}
}

View file

@ -23,11 +23,11 @@
<label for="host-public-key">Host public key bundle hex</label> <label for="host-public-key">Host public key bundle hex</label>
<textarea id="host-public-key" placeholder="Paste PublicKeyBundle bytes as hex"></textarea> <textarea id="host-public-key" placeholder="Paste PublicKeyBundle bytes as hex"></textarea>
<label for="client-public-key">Generated client public key bundle hex</label> <label for="client-credentials">Saved SDK credentials</label>
<textarea id="client-public-key" readonly></textarea> <textarea id="client-credentials" readonly></textarea>
<div> <div>
<button id="generate-keypair" type="button">Generate keypair</button> <button id="generate-keypair" type="button">Use new credentials</button>
<button id="connect" type="button" disabled>Connect</button> <button id="connect" type="button" disabled>Connect</button>
<button id="clear-keys" type="button">Clear saved keys</button> <button id="clear-keys" type="button">Clear saved keys</button>
</div> </div>

View file

@ -3,13 +3,17 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"packageManager": "pnpm@11.8.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": {
"mtp": "workspace:*"
},
"devDependencies": { "devDependencies": {
"typescript": "^5.4.0", "typescript": "^6.0.3",
"vite": "^5.4.0" "vite": "^8.1.0"
} }
} }

View file

@ -1,35 +1,34 @@
import init, { import { MTPClient } from "mtp";
WasmClient, import type { MTPCredentialStorage, MTPLogEvent, ParsedFrame } from "mtp";
ConnectionConfig,
ConnectionState,
WasmKeyring,
ed25519_generate,
keyring_from_ed25519,
build_demo_message,
format_frame,
} from "mtp-wasm";
const STATUS = document.getElementById("status")!; const STATUS = document.getElementById("status")!;
const KEY_STATUS = document.getElementById("key-status")!; const KEY_STATUS = document.getElementById("key-status")!;
const SERVER_URL = document.getElementById("server-url") as HTMLInputElement; const SERVER_URL = document.getElementById("server-url") as HTMLInputElement;
const HOST_PUBLIC_KEY = document.getElementById("host-public-key") as HTMLTextAreaElement; 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 GENERATE_KEYPAIR = document.getElementById("generate-keypair") as HTMLButtonElement;
const CONNECT = document.getElementById("connect") as HTMLButtonElement; const CONNECT = document.getElementById("connect") as HTMLButtonElement;
const CLEAR_KEYS = document.getElementById("clear-keys") 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 = { type SavedKeys = {
clientId: string | null; clientId: string | null;
keyring: number[]; keyring?: number[];
keyringBytes?: number[];
hostPublicKey?: number[]; hostPublicKey?: number[];
}; };
let keyringBytes: Uint8Array | null = null;
let clientId: bigint | null = null; let clientId: bigint | null = null;
let devCertHash = ""; 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 = "") { function log(msg: string, cls = "") {
const line = document.createElement("div"); const line = document.createElement("div");
line.textContent = msg; line.textContent = msg;
@ -37,6 +36,35 @@ function log(msg: string, cls = "") {
STATUS.appendChild(line); 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) { function setKeyStatus(msg: string) {
KEY_STATUS.textContent = msg; KEY_STATUS.textContent = msg;
} }
@ -57,37 +85,35 @@ function hexToBytes(value: string): Uint8Array {
return bytes; return bytes;
} }
function saveKeys() { function saveHostPublicKey() {
if (!keyringBytes) {
return;
}
let hostPublicKey: number[] | undefined;
try { try {
hostPublicKey = Array.from(hexToBytes(HOST_PUBLIC_KEY.value)); localStorage.setItem(HOST_PUBLIC_KEY_KEY, bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value)));
} catch { } 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() { 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) { 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; return;
} }
const data = JSON.parse(raw) as SavedKeys; const data = JSON.parse(raw) as SavedKeys;
keyringBytes = new Uint8Array(data.keyring);
clientId = data.clientId ? BigInt(data.clientId) : null; 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) { if (data.hostPublicKey) {
HOST_PUBLIC_KEY.value = bytesToHex(new Uint8Array(data.hostPublicKey)); HOST_PUBLIC_KEY.value = bytesToHex(new Uint8Array(data.hostPublicKey));
} }
@ -108,7 +134,7 @@ async function loadHostPublicKey() {
if (!hostPublicKey) return; if (!hostPublicKey) return;
HOST_PUBLIC_KEY.value = hostPublicKey; HOST_PUBLIC_KEY.value = hostPublicKey;
saveKeys(); saveHostPublicKey();
log(`Loaded host public key bundle (${hostPublicKey.length / 2} bytes).`); log(`Loaded host public key bundle (${hostPublicKey.length / 2} bytes).`);
} catch { } catch {
// Manual paste still works when the server has not exported the file yet. // Manual paste still works when the server has not exported the file yet.
@ -131,109 +157,82 @@ async function loadDevCertHash() {
async function initWasm() { async function initWasm() {
log("Loading WASM module..."); log("Loading WASM module...");
await init(); await MTPClient.create({ url: SERVER_URL.value, storage: credentialStorage, credentialsStorageKey: CREDENTIALS_KEY });
log(`WASM loaded. WebTransport supported: ${WasmClient.is_supported()}`); const supported = MTPClient.isSupported();
CONNECT.disabled = !WasmClient.is_supported(); log(`WASM loaded. WebTransport supported: ${supported}`);
} CONNECT.disabled = !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;
} }
async function connect() { async function connect() {
STATUS.textContent = ""; STATUS.textContent = "";
if (!WasmClient.is_supported()) { if (!MTPClient.isSupported()) {
log("WebTransport is not supported in this browser.", "error"); log("WebTransport is not supported in this browser.", "error");
return; return;
} }
if (!keyringBytes) {
log("Generate a client keypair first.", "error");
return;
}
const hostPk = hexToBytes(HOST_PUBLIC_KEY.value); const hostPk = hexToBytes(HOST_PUBLIC_KEY.value);
saveHostPublicKey();
await loadDevCertHash(); await loadDevCertHash();
const client = createClient();
const serverUrl = SERVER_URL.value.trim(); const serverUrl = SERVER_URL.value.trim();
const config = new ConnectionConfig(serverUrl); const serverCertificateHashes = devCertHash ? [`sha-256:${devCertHash}`] : undefined;
if (devCertHash) { if (serverCertificateHashes) {
log(`Pinning WebTransport certificate hash: sha-256:${devCertHash}`); log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`);
config.server_certificate_hashes = [`sha-256:${devCertHash}`];
} else { } else {
log("No WebTransport certificate hash loaded; relying on browser trust store.", "state"); log("No WebTransport certificate hash loaded; relying on browser trust store.", "state");
} }
try { try {
let activeClientId: bigint; const client = await MTPClient.create({
if (clientId !== null) { url: serverUrl,
log(`Using saved client ID ${clientId}...`); hostPublicKey: hostPk,
activeClientId = await client.auth_connect( storage: credentialStorage,
config, credentialsStorageKey: CREDENTIALS_KEY,
hostPk, serverCertificateHashes,
keyringBytes, pings: { intervalMs: 30_000 },
clientId, logger(event) {
); log(renderLoggerEvent(event), event.hint === "error" ? "error" : event.type === "state" ? "state" : "");
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}`);
}
log("\nSending demo message..."); client.subscribe("Pong", (frame: ParsedFrame) => {
const frame = build_demo_message(activeClientId, keyringBytes, hostPk); log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received");
log(`Sending: ${format_frame(frame)}`, "state"); });
await client.send(frame);
log(`Sent ${frame.length} bytes`); 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..."); log("\nClient running. Waiting for incoming messages...");
} finally { } catch (error) {
config.free(); log(`[error] ${error}`, "error");
} }
} }
GENERATE_KEYPAIR.addEventListener("click", () => { GENERATE_KEYPAIR.addEventListener("click", () => {
try { try {
keyringBytes = generateKeyringBytes();
clientId = null; clientId = null;
saveKeys(); localStorage.removeItem(CREDENTIALS_KEY);
CLIENT_PUBLIC_KEY.value = publicKeyHexFromKeyring(keyringBytes); CLIENT_CREDENTIALS.value = "";
setKeyStatus("Generated client keypair. Not registered yet."); setKeyStatus("Cleared saved credentials. The next connection will generate a new reusable keyring.");
log("Generated and saved a new client keypair."); log("Cleared saved SDK credentials.");
} catch (e) { } catch (e) {
log(`Key generation failed: ${e}`, "error"); log(`Credential reset failed: ${e}`, "error");
console.error(e); console.error(e);
} }
}); });
@ -242,7 +241,7 @@ CONNECT.addEventListener("click", () => {
connect().catch((e) => { connect().catch((e) => {
log(`Fatal error: ${e}`, "error"); log(`Fatal error: ${e}`, "error");
log( 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", "error",
); );
console.error(e); console.error(e);
@ -250,15 +249,15 @@ CONNECT.addEventListener("click", () => {
}); });
CLEAR_KEYS.addEventListener("click", () => { CLEAR_KEYS.addEventListener("click", () => {
keyringBytes = null;
clientId = null; clientId = null;
CLIENT_PUBLIC_KEY.value = ""; CLIENT_CREDENTIALS.value = "";
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(CREDENTIALS_KEY);
setKeyStatus("No client keypair generated yet."); localStorage.removeItem(HOST_PUBLIC_KEY_KEY);
log("Cleared saved client keys."); 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() initWasm()
.then(() => { .then(() => {

View file

@ -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());
}

View file

@ -8,11 +8,7 @@
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true
"paths": {
"mtp-wasm": ["../../wasm/pkg"]
},
"rootDir": "../.."
}, },
"include": ["src", "../../wasm/pkg"] "include": ["src"]
} }

View file

@ -1,6 +1,7 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { mtp } from 'mtp/vite';
const devCertDir = path.resolve(__dirname, '../dev-cert'); const devCertDir = path.resolve(__dirname, '../dev-cert');
const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem'); 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); const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
export default defineConfig({ export default defineConfig({
resolve: { plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' })],
alias: {
'mtp-wasm': path.resolve(__dirname, '../../wasm/pkg'),
},
},
server: { server: {
https: hasDevCert https: hasDevCert
? { ? {
@ -21,8 +18,5 @@ export default defineConfig({
key: fs.readFileSync(keyPath), key: fs.readFileSync(keyPath),
} }
: undefined, : undefined,
fs: {
allow: ['.', path.resolve(__dirname, '../../wasm/pkg')],
},
}, },
}); });

View file

@ -32,17 +32,7 @@
runtimeInputs = [rustToolchain]; runtimeInputs = [rustToolchain];
text = '' text = ''
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings \ cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
-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
''; '';
}; };
@ -56,21 +46,20 @@
buildAll = pkgs.writeShellApplication { buildAll = pkgs.writeShellApplication {
name = "mtp-build-all"; 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 = '' text = ''
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" 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 fmt --all --check
cargo b cargo b
cargo test --workspace --exclude mtp-wasm --all-features cargo test --workspace --exclude mtp-wasm --all-features
cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features
mtp-clippy mtp-clippy
mtp-machete mtp-machete
bun run dup pnpm run dup
RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release pnpm run build
bun install --cwd example/web-client --frozen-lockfile pnpm --filter mtp-web-client run build
bun run --cwd example/web-client build
''; '';
}; };
@ -91,6 +80,7 @@
rustToolchain rustToolchain
cargo-machete cargo-machete
wasm-pack wasm-pack
pnpm
pkg-config pkg-config
openssl openssl
]; ];
@ -140,9 +130,10 @@
name = "autoStart"; name = "autoStart";
buildInputs = with pkgs; [ buildInputs = with pkgs; [
mprocs mprocs
pnpm
]; ];
shellHook = '' 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 exit
''; '';
}; };

View file

@ -9,7 +9,6 @@ mtp-codec = { path = "../codec", features = ["registry"] }
mtp-transport = { path = "../transport", features = ["host"] } mtp-transport = { path = "../transport", features = ["host"] }
mtp-crypto = { path = "../crypto", optional = true } mtp-crypto = { path = "../crypto", optional = true }
rand = "0.8" rand = "0.8"
log = "0.4"
[features] [features]
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]

View file

@ -5,6 +5,7 @@ use mtp_codec::{
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
use mtp_transport::{Policy, Receiver, Sender}; use mtp_transport::{Policy, Receiver, Sender};
use std::net::IpAddr; use std::net::IpAddr;
use std::{error::Error, fmt};
/* Host configuration. */ /* Host configuration. */
pub struct HostConfig { pub struct HostConfig {
@ -16,15 +17,81 @@ pub struct HostConfig {
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub require_authentication: bool, pub require_authentication: bool,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub host_id: u64,
#[cfg(feature = "crypto")]
pub host_keyring: mtp_crypto::Keyring, pub host_keyring: mtp_crypto::Keyring,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub get_existing_user: Box<dyn Fn(u64) -> Option<mtp_crypto::PublicKeyBundle> + Send>, pub get_existing_user: Box<dyn Fn(u64) -> Option<mtp_crypto::PublicKeyBundle> + Send + Sync>,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub complete_register: Box<dyn Fn(mtp_crypto::PublicKeyBundle) -> u64 + Send>, pub complete_register: Box<dyn Fn(mtp_crypto::PublicKeyBundle) -> u64 + Send + Sync>,
} }
impl HostConfig {
pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec<u8>, tls_key: Vec<u8>) -> 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<mtp_crypto::PublicKeyBundle> + 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")] #[cfg(feature = "crypto")]
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthState { pub enum AuthState {
@ -80,16 +147,13 @@ impl MTPHost {
* Accept an incoming connection, negotiate the protocol version, * Accept an incoming connection, negotiate the protocol version,
* and return a ready-to-use `MTPConnection`. * and return a ready-to-use `MTPConnection`.
* *
* Returns `None` if the connection is closed or the version is * Returns `Ok(None)` if the listener is closed. Handshake and version
* incompatible. * negotiation failures are returned explicitly.
*/ */
pub async fn accept(&mut self) -> Option<MTPConnection> { pub async fn accept(&mut self) -> Result<Option<MTPConnection>, AcceptError> {
let (sender, receiver) = match self.transport.next().await { let (sender, receiver) = match self.transport.next().await {
Some(pair) => pair, Some(pair) => pair,
None => { None => return Ok(None),
log::warn!("accept: transport.next() returned None (listener closed)");
return None;
}
}; };
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
@ -100,10 +164,7 @@ impl MTPHost {
// Read the first message (always encoded with reserved types). // Read the first message (always encoded with reserved types).
let first_msg = match receiver.receive().await { let first_msg = match receiver.receive().await {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => return Err(AcceptError::Receive(e)),
log::warn!("accept: receive failed: {e:?}");
return None;
}
}; };
/* /*
@ -115,13 +176,7 @@ impl MTPHost {
*/ */
let client_version = match extract_version(&first_msg) { let client_version = match extract_version(&first_msg) {
Some(v) => v, Some(v) => v,
None => { None => return Err(AcceptError::MissingVersion),
log::warn!(
"accept: extract_version failed on msg type {:?}",
first_msg.get_type()
);
return None;
}
}; };
let negotiated = match self let negotiated = match self
@ -129,15 +184,12 @@ impl MTPHost {
.negotiate(std::slice::from_ref(&client_version)) .negotiate(std::slice::from_ref(&client_version))
{ {
Some(v) => v, Some(v) => v,
None => { None => return Err(AcceptError::UnsupportedVersion(client_version)),
log::warn!("accept: negotiate failed for client version {client_version:?}");
return None;
}
}; };
let codec = VersionedCodec::new(self.registry.clone()); let codec = VersionedCodec::new(self.registry.clone());
Some(MTPConnection { Ok(Some(MTPConnection {
version: negotiated, version: negotiated,
codec, codec,
sender, sender,
@ -148,7 +200,7 @@ impl MTPHost {
client_id: 0, client_id: 0,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
client_public_key: None, client_public_key: None,
}) }))
} }
pub fn local_addr(&self) -> std::net::SocketAddr { pub fn local_addr(&self) -> std::net::SocketAddr {
@ -180,7 +232,7 @@ impl MTPHost {
&mut self, &mut self,
sender: Sender, sender: Sender,
receiver: Receiver, receiver: Receiver,
) -> Option<MTPConnection> { ) -> Result<Option<MTPConnection>, AcceptError> {
use mtp_crypto::{ use mtp_crypto::{
Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519, Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519,
verify_ml_dsa, verify_ml_dsa,
@ -207,32 +259,36 @@ impl MTPHost {
.is_empty(); .is_empty();
// Sign `payload` with the host keys (Ed25519 always, ML-DSA when configured). // Sign `payload` with the host keys (Ed25519 always, ML-DSA when configured).
let host_sign = |payload: &[u8]| -> Option<(Vec<u8>, Vec<u8>)> { let host_sign = |payload: &[u8]| -> Result<(Vec<u8>, Vec<u8>), AcceptError> {
let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key).ok()?; let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key)
let sig = signer.sign(payload).ok()?; .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_sig = if pq_enabled {
let pq = MlDsaSigner::new( let pq = MlDsaSigner::new(
&self.config.host_keyring.sig_pq_secret_key, &self.config.host_keyring.sig_pq_secret_key,
&self.config.host_keyring.sig_pq_public_key, &self.config.host_keyring.sig_pq_public_key,
) )
.ok()?; .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
pq.sign(payload).ok()? pq.sign(payload)
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?
} else { } else {
Vec::new() Vec::new()
}; };
Some((sig, pq_sig)) Ok((sig, pq_sig))
}; };
// ===== Step 1: receive the client's unsigned hello ===== // ===== 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)) { let version_str = match hello.get_data(DataType::Version.to_id(&tm)) {
DataValue::Str(s) => s.clone(), DataValue::Str(s) => s.clone(),
_ => { _ => {
sender.close(); 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) = let (flow, response_type) =
if hello.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) { if hello.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) {
@ -241,7 +297,9 @@ impl MTPHost {
DataValue::UnsignedNumber(n) => *n as u64, DataValue::UnsignedNumber(n) => *n as u64,
_ => { _ => {
sender.close(); sender.close();
return None; return Err(AcceptError::AuthenticationFailed(
"missing client id".into(),
));
} }
}; };
let bundle = match (self.config.get_existing_user)(cid) { let bundle = match (self.config.get_existing_user)(cid) {
@ -253,7 +311,9 @@ impl MTPHost {
.add_typed_default(DataType::Connected, DataValue::BoolFalse); .add_typed_default(DataType::Connected, DataValue::BoolFalse);
let _ = sender.send(&rejection).await; let _ = sender.send(&rejection).await;
sender.close(); 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) { } else if hello.get_type() == mtp_codec::CommunicationType::Register.to_id(&tm) {
// REGISTER: the client presents the bundle it wants to register. // REGISTER: the client presents the bundle it wants to register.
let bundle = match hello.get_data(DataType::PublicKeys.to_id(&tm)) { 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(); sender.close();
return None; return Err(AcceptError::AuthenticationFailed(
"missing public keys".into(),
));
} }
}; };
let pk_bytes = bundle.as_bytes(); let pk_bytes = bundle.as_bytes();
@ -276,7 +340,9 @@ impl MTPHost {
) )
} else { } else {
sender.close(); sender.close();
return None; return Err(AcceptError::AuthenticationFailed(
"unexpected authentication message".into(),
));
}; };
// The id bound into the challenge (0 for register: none assigned yet). // The id bound into the challenge (0 for register: none assigned yet).
@ -300,26 +366,35 @@ impl MTPHost {
challenge_msg = challenge_msg challenge_msg = challenge_msg
.add_typed_default(DataType::PqSignature, DataValue::Bytes(chal_pq_sig)); .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 ===== // ===== 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) { if proof.get_type() != mtp_codec::CommunicationType::ChallengeResponse.to_id(&tm) {
sender.close(); sender.close();
return None; return Err(AcceptError::AuthenticationFailed(
"missing challenge response".into(),
));
} }
let client_nonce = match proof.get_data(DataType::ClientNonce.to_id(&tm)) { let client_nonce = match proof.get_data(DataType::ClientNonce.to_id(&tm)) {
DataValue::UnsignedNumber(n) => *n, DataValue::UnsignedNumber(n) => *n,
_ => { _ => {
sender.close(); sender.close();
return None; return Err(AcceptError::AuthenticationFailed(
"missing client nonce".into(),
));
} }
}; };
let sig_bytes = match proof.get_data(DataType::Signature.to_id(&tm)) { let sig_bytes = match proof.get_data(DataType::Signature.to_id(&tm)) {
DataValue::Bytes(b) => b.clone(), DataValue::Bytes(b) => b.clone(),
_ => { _ => {
sender.close(); sender.close();
return None; return Err(AcceptError::AuthenticationFailed(
"missing challenge signature".into(),
));
} }
}; };
let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature.to_id(&tm)) { let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature.to_id(&tm)) {
@ -355,7 +430,9 @@ impl MTPHost {
.add_typed_default(DataType::Connected, DataValue::BoolFalse); .add_typed_default(DataType::Connected, DataValue::BoolFalse);
let _ = sender.send(&rejection).await; let _ = sender.send(&rejection).await;
sender.close(); 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. // 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)); response.add_typed_default(DataType::PqSignature, DataValue::Bytes(host_pq_sig));
} }
sender.send(&response).await.ok()?; sender.send(&response).await.map_err(AcceptError::Send)?;
sender.finish_stream().await.ok()?; sender.finish_stream().await.map_err(AcceptError::Send)?;
// ===== Version negotiation ===== // ===== 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()); let codec = VersionedCodec::new(self.registry.clone());
Some(MTPConnection { Ok(Some(MTPConnection {
version: negotiated, version: negotiated,
codec, codec,
sender, sender,
@ -402,7 +482,7 @@ impl MTPHost {
auth_state: AuthState::Authenticated, auth_state: AuthState::Authenticated,
client_id: assigned_id, client_id: assigned_id,
client_public_key: Some(client_bundle), client_public_key: Some(client_bundle),
}) }))
} }
} }

View file

@ -1,29 +1,60 @@
{ {
"name": "mtp-wasm", "name": "mtp",
"version": "0.1.0", "version": "0.1.0",
"description": "Browser WASM interface for MTP.", "description": "MTP TypeScript SDK",
"type": "module", "type": "module",
"module": "./wasm/pkg/mtp_wasm.js", "packageManager": "pnpm@11.8.0",
"types": "./wasm/pkg/mtp_wasm.d.ts", "module": "./dist/sdk/index.js",
"types": "./dist/sdk/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./wasm/pkg/mtp_wasm.d.ts", "types": "./dist/sdk/index.d.ts",
"import": "./wasm/pkg/mtp_wasm.js", "import": "./dist/sdk/index.js",
"default": "./wasm/pkg/mtp_wasm.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": [ "files": [
"wasm/pkg/mtp_wasm.js", "Cargo.lock",
"wasm/pkg/mtp_wasm.d.ts", "dist/",
"wasm/pkg/mtp_wasm_bg.wasm", "README.md",
"wasm/pkg/mtp_wasm_bg.wasm.d.ts" "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": { "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", "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 ." "dup": "jscpd --pattern '**/*.rs' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips ."
}, },
"devDependencies": { "devDependencies": {
"jscpd": "5.0.11" "@types/node": "^26.0.1",
"jscpd": "5.0.11",
"typescript": "^6.0.3"
} }
} }

599
pnpm-lock.yaml generated Normal file
View file

@ -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

8
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,8 @@
packages:
- .
- example/web-client
onlyBuiltDependencies:
- esbuild
allowBuilds:
esbuild: true

2
src/raw/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { default } from "../../wasm/pkg/mtp_wasm.js";
export * from "../../wasm/pkg/mtp_wasm.js";

514
src/sdk/index.ts Normal file
View file

@ -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<StorageValue>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}
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<RawBindings.InitInput> | { module_or_path: RawBindings.InitInput | Promise<RawBindings.InitInput> };
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<MTPCredentials, "clientId" | "keyring" | "hostPublicKey"> & {
clientId: bigint | null;
keyringBytes: Uint8Array;
hostPublicKey?: Uint8Array;
};
type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
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<MTPClient> {
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<void> {
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<bigint> {
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<bigint> {
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<void> {
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<void>;
async send(type: MTPCommunicationType, data: Record<string, unknown>, options?: MTPSendOptions): Promise<void>;
async send(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record<string, unknown>, options?: MTPSendOptions): Promise<void> {
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<ParsedFrame>;
async request(type: MTPCommunicationType, data: Record<string, unknown>, options?: MTPRequestOptions): Promise<ParsedFrame>;
async request(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record<string, unknown>, options: MTPRequestOptions = {}): Promise<ParsedFrame> {
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 };

5
src/type-map/index.ts Normal file
View file

@ -0,0 +1,5 @@
export type MTPCommunicationType = string;
export type MTPDataType = string;
export const communicationTypes: readonly MTPCommunicationType[] = [];
export const dataTypes: readonly MTPDataType[] = [];

409
src/vite/index.ts Normal file
View file

@ -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<void>((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;

View file

@ -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<Duration>) -> Self {
self.keep_alive_interval = keep_alive_interval;
self
}
pub fn with_max_idle_timeout(mut self, max_idle_timeout: Option<Duration>) -> 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 { enum ReceivedFrame {
Message(CommunicationValue), Message(CommunicationValue),
ClosedByPeer, ClosedByPeer,

View file

@ -1,5 +1,4 @@
use crate::{ConnectionHandle, Policy, Receiver, Sender}; use crate::{ConnectionHandle, Policy, Receiver, Sender};
use log;
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
use rustls::pki_types::{PrivateKeyDer, pem::PemObject}; use rustls::pki_types::{PrivateKeyDer, pem::PemObject};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
@ -14,13 +13,7 @@ pub struct Host {
impl Host { impl Host {
pub async fn next(&mut self) -> Option<(Sender, Receiver)> { pub async fn next(&mut self) -> Option<(Sender, Receiver)> {
log::warn!("[transport Host::next] waiting on recv..."); self.incoming.recv().await
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
} }
pub fn local_addr(&self) -> std::net::SocketAddr { pub fn local_addr(&self) -> std::net::SocketAddr {
@ -66,19 +59,13 @@ pub async fn host(
let policy = Arc::new(policy); let policy = Arc::new(policy);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
log::warn!("[transport bg task] started");
loop { loop {
log::warn!("[transport bg task] waiting for connection...");
let incoming_session = endpoint.accept().await; let incoming_session = endpoint.accept().await;
log::warn!("[transport bg task] got incoming session");
let request = match incoming_session.await { let request = match incoming_session.await {
Ok(req) => { Ok(req) => req,
log::warn!("[transport bg task] got request");
req
}
Err(e) => { Err(e) => {
log::warn!("[transport bg task] incoming session error: {e}"); log::debug!("incoming WebTransport session failed: {e}");
continue; continue;
} }
}; };
@ -87,19 +74,15 @@ pub async fn host(
.accept_with_headers([("sec-webtransport-http3-draft02", "1")]) .accept_with_headers([("sec-webtransport-http3-draft02", "1")])
.await .await
{ {
Ok(conn) => { Ok(conn) => conn,
log::warn!("[transport bg task] connection accepted");
conn
}
Err(e) => { Err(e) => {
log::warn!("[transport bg task] accept error: {e}"); log::debug!("WebTransport request accept failed: {e}");
continue; continue;
} }
}; };
let incoming_tx = incoming_tx.clone(); let incoming_tx = incoming_tx.clone();
let policy = policy.clone(); let policy = policy.clone();
eprintln!("[transport bg task] spawning handle_connection");
tokio::spawn(handle_connection(connection, incoming_tx, policy)); tokio::spawn(handle_connection(connection, incoming_tx, policy));
} }
}); });

26
tsconfig.json Normal file
View file

@ -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"
]
}

View file

@ -13,22 +13,7 @@ crate-type = ["cdylib"]
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
js-sys = "0.3" js-sys = "0.3"
web-sys = { version = "0.3", features = [ futures-channel = "0.3"
"console",
"WebTransport",
"WebTransportOptions",
"WebTransportHash",
"WebTransportBidirectionalStream",
"WebTransportCloseInfo",
"WebTransportDatagramDuplexStream",
"WebTransportError",
"WebTransportReceiveStream",
"WebTransportSendStream",
"ReadableStream",
"ReadableStreamDefaultReader",
"WritableStream",
"WritableStreamDefaultWriter",
] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
hex = "0.4" hex = "0.4"

View file

@ -1,6 +1,17 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export interface ParsedFrame {
id?: number;
type: string;
sender?: bigint;
receiver?: bigint;
data: Record<string, unknown>;
raw: Uint8Array;
}
export class ConnectionConfig { export class ConnectionConfig {
free(): void; free(): void;
[Symbol.dispose](): void; [Symbol.dispose](): void;
@ -66,7 +77,12 @@ export class WasmClient {
disconnect(): void; disconnect(): void;
static is_supported(): boolean; static is_supported(): boolean;
constructor(on_state_change: Function, on_message: Function, on_error: Function); constructor(on_state_change: Function, on_message: Function, on_error: Function);
request(frame: Uint8Array, response_type?: string | null): Promise<any>;
send(frame: Uint8Array): Promise<void>; send(frame: Uint8Array): Promise<void>;
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; readonly state: number;
} }
@ -105,6 +121,15 @@ export class WasmKeyring {
to_bytes(): Uint8Array; 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 { export class WasmPublicKeyBundle {
private constructor(); private constructor();
free(): void; free(): void;
@ -117,25 +142,27 @@ export class WasmPublicKeyBundle {
} }
/** /**
* Build a demo Ping frame with encrypted and signed containers * Minimal message router used by higher-level SDK subscription code.
* (mirrors the Rust client example but uses only reserved data types).
*/ */
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; 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. * Generate a fresh Ed25519 keypair.
* *
@ -169,9 +196,9 @@ export function main(): void;
export function parse_auth_response(response: Uint8Array): any; 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`. * 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 { export interface InitOutput {
readonly memory: WebAssembly.Memory; 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_wasmchacha20poly1305_free: (a: number, b: number) => void;
readonly __wbg_wasmed25519signer_free: (a: number, b: number) => void; readonly __wbg_wasmed25519signer_free: (a: number, b: number) => void;
readonly __wbg_wasmkeyring_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_cl_public_key: (a: number) => [number, number];
readonly wasmpublickeybundle_sig_pq_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 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 __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_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_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_connect: (a: number, b: number) => any;
readonly wasmclient_disconnect: (a: number) => void; readonly wasmclient_disconnect: (a: number) => void;
readonly wasmclient_is_supported: () => number; readonly wasmclient_is_supported: () => number;
readonly wasmclient_new: (a: any, b: any, c: any) => 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_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_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 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__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__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_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_exn_store: (a: number) => void; readonly __wbindgen_exn_store: (a: number) => void;

View file

@ -227,6 +227,19 @@ export class WasmClient {
WasmClientFinalization.register(this, this.__wbg_ptr, this); WasmClientFinalization.register(this, this.__wbg_ptr, this);
return this; return this;
} }
/**
* @param {Uint8Array} frame
* @param {string | null} [response_type]
* @returns {Promise<any>}
*/
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 * @param {Uint8Array} frame
* @returns {Promise<void>} * @returns {Promise<void>}
@ -237,6 +250,16 @@ export class WasmClient {
const ret = wasm.wasmclient_send(this.__wbg_ptr, ptr0, len0); const ret = wasm.wasmclient_send(this.__wbg_ptr, ptr0, len0);
return ret; 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} * @returns {number}
*/ */
@ -244,6 +267,28 @@ export class WasmClient {
const ret = wasm.wasmclient_state(this.__wbg_ptr); const ret = wasm.wasmclient_state(this.__wbg_ptr);
return ret; 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; 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; 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 { export class WasmPublicKeyBundle {
static __wrap(ptr) { static __wrap(ptr) {
const obj = Object.create(WasmPublicKeyBundle.prototype); const obj = Object.create(WasmPublicKeyBundle.prototype);
@ -435,29 +490,79 @@ export class WasmPublicKeyBundle {
if (Symbol.dispose) WasmPublicKeyBundle.prototype[Symbol.dispose] = WasmPublicKeyBundle.prototype.free; if (Symbol.dispose) WasmPublicKeyBundle.prototype[Symbol.dispose] = WasmPublicKeyBundle.prototype.free;
/** /**
* Build a demo Ping frame with encrypted and signed containers * Minimal message router used by higher-level SDK subscription code.
* (mirrors the Rust client example but uses only reserved data types). */
* @param {bigint} client_id export class WasmSubscriptionRouter {
* @param {Uint8Array} keyring_bytes __destroy_into_raw() {
* @param {Uint8Array} host_bundle_bytes 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} * @returns {Uint8Array}
*/ */
export function build_demo_message(client_id, keyring_bytes, host_bundle_bytes) { export function build_frame(message_type, data, options) {
const ptr0 = passArray8ToWasm0(keyring_bytes, wasm.__wbindgen_malloc); const ptr0 = passStringToWasm0(message_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN; const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(host_bundle_bytes, wasm.__wbindgen_malloc); const ret = wasm.build_frame(ptr0, len0, data, options);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.build_demo_message(client_id, ptr0, len0, ptr1, len1);
if (ret[3]) { if (ret[3]) {
throw takeFromExternrefTable0(ret[2]); 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); 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 {bigint} client_id
* @param {string} description * @param {string} description
* @param {bigint} timestamp * @param {bigint} timestamp
@ -478,31 +583,6 @@ export function build_ping_frame(client_id, description, timestamp, data) {
return v3; 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. * 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 * @param {Uint8Array} frame
* @returns {string} * @returns {ParsedFrame}
*/ */
export function parse_response_frame(frame) { export function parse_frame(frame) {
let deferred3_0; const ptr0 = passArray8ToWasm0(frame, wasm.__wbindgen_malloc);
let deferred3_1; const len0 = WASM_VECTOR_LEN;
try { const ret = wasm.parse_frame(ptr0, len0);
const ptr0 = passArray8ToWasm0(frame, wasm.__wbindgen_malloc); if (ret[2]) {
const len0 = WASM_VECTOR_LEN; throw takeFromExternrefTable0(ret[1]);
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);
} }
return takeFromExternrefTable0(ret[0]);
} }
/** /**
@ -707,6 +776,10 @@ export function wasm_sha256_double(data) {
function __wbg_get_imports() { function __wbg_get_imports() {
const import0 = { const import0 = {
__proto__: null, __proto__: null,
__wbg_BigInt_ff69cca7a537413a: function(arg0, arg1) {
const ret = BigInt(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg___wbindgen_boolean_get_fa956cfa2d1bd751: function(arg0) { __wbg___wbindgen_boolean_get_fa956cfa2d1bd751: function(arg0) {
const v = arg0; const v = arg0;
const ret = typeof(v) === 'boolean' ? v : undefined; const ret = typeof(v) === 'boolean' ? v : undefined;
@ -740,6 +813,12 @@ function __wbg_get_imports() {
const ret = arg0 === undefined; const ret = arg0 === undefined;
return ret; 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) { __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) {
const obj = arg1; const obj = arg1;
const ret = typeof(obj) === 'string' ? obj : undefined; const ret = typeof(obj) === 'string' ? obj : undefined;
@ -751,6 +830,10 @@ function __wbg_get_imports() {
__wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
throw new Error(getStringFromWasm0(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) { __wbg__wbg_cb_unref_fffb441def202758: function(arg0) {
arg0._wbg_cb_unref(); arg0._wbg_cb_unref();
}, },
@ -762,21 +845,18 @@ function __wbg_get_imports() {
const ret = arg0.call(arg1, arg2); const ret = arg0.call(arg1, arg2);
return ret; return ret;
}, arguments); }, }, arguments); },
__wbg_close_23b01d38b065688a: function(arg0, arg1) { __wbg_call_e3b662382210db98: function() { return handleError(function (arg0, arg1, arg2, arg3) {
arg0.close(arg1); const ret = arg0.call(arg1, arg2, arg3);
},
__wbg_createUnidirectionalStream_9ff0e4127f40ed0d: function(arg0) {
const ret = arg0.createUnidirectionalStream();
return ret; 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) { __wbg_crypto_38df2bab126b63dc: function(arg0) {
const ret = arg0.crypto; const ret = arg0.crypto;
return ret; return ret;
}, },
__wbg_entries_015dc610cd81ede0: function(arg0) {
const ret = Object.entries(arg0);
return ret;
},
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
let deferred0_0; let deferred0_0;
let deferred0_1; let deferred0_1;
@ -788,32 +868,56 @@ function __wbg_get_imports() {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 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) { __wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1); arg0.getRandomValues(arg1);
}, arguments); }, }, arguments); },
__wbg_getRandomValues_cc7f052a444bb2ce: function() { return handleError(function (arg0, arg1) { __wbg_getRandomValues_cc7f052a444bb2ce: function() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments); }, }, arguments); },
__wbg_get_507a50627bffa49b: function(arg0, arg1) {
const ret = arg0[arg1 >>> 0];
return ret;
},
__wbg_get_78f252d074a84d0b: function() { return handleError(function (arg0, arg1) { __wbg_get_78f252d074a84d0b: function() { return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1); const ret = Reflect.get(arg0, arg1);
return ret; return ret;
}, arguments); }, }, arguments); },
__wbg_get_unchecked_6e0ad6d2a41b06f6: function(arg0, arg1) {
const ret = arg0[arg1 >>> 0];
return ret;
},
__wbg_has_8374cf06984d8bfc: function() { return handleError(function (arg0, arg1) { __wbg_has_8374cf06984d8bfc: function() { return handleError(function (arg0, arg1) {
const ret = Reflect.has(arg0, arg1); const ret = Reflect.has(arg0, arg1);
return ret; return ret;
}, arguments); }, }, arguments); },
__wbg_incomingUnidirectionalStreams_505a67efefd669d3: function(arg0) { __wbg_instanceof_Promise_4cb210c0b8f8c959: function(arg0) {
const ret = arg0.incomingUnidirectionalStreams; 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; return ret;
}, },
__wbg_isArray_0677c962b281d01a: function(arg0) { __wbg_isArray_0677c962b281d01a: function(arg0) {
const ret = Array.isArray(arg0); const ret = Array.isArray(arg0);
return ret; return ret;
}, },
__wbg_keys_58421f8f96795607: function(arg0) {
const ret = Object.keys(arg0);
return ret;
},
__wbg_length_1f0964f4a5e2c6d8: function(arg0) { __wbg_length_1f0964f4a5e2c6d8: function(arg0) {
const ret = arg0.length; const ret = arg0.length;
return ret; return ret;
@ -822,9 +926,6 @@ function __wbg_get_imports() {
const ret = arg0.length; const ret = arg0.length;
return ret; return ret;
}, },
__wbg_log_d267660666346fb3: function(arg0) {
console.log(arg0);
},
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) { __wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
const ret = arg0.msCrypto; const ret = arg0.msCrypto;
return ret; return ret;
@ -833,10 +934,10 @@ function __wbg_get_imports() {
const ret = new Error(); const ret = new Error();
return ret; return ret;
}, },
__wbg_new_a2778e1fb014b494: function() { return handleError(function (arg0, arg1) { __wbg_new_32b398fb48b6d94a: function() {
const ret = new WebTransport(getStringFromWasm0(arg0, arg1)); const ret = new Array();
return ret; return ret;
}, arguments); }, },
__wbg_new_cd45aabdf6073e84: function(arg0) { __wbg_new_cd45aabdf6073e84: function(arg0) {
const ret = new Uint8Array(arg0); const ret = new Uint8Array(arg0);
return ret; return ret;
@ -871,10 +972,6 @@ function __wbg_get_imports() {
const ret = new Uint8Array(arg0 >>> 0); const ret = new Uint8Array(arg0 >>> 0);
return ret; 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) { __wbg_node_84ea875411254db1: function(arg0) {
const ret = arg0.node; const ret = arg0.node;
return ret; return ret;
@ -883,10 +980,6 @@ function __wbg_get_imports() {
const ret = Date.now(); const ret = Date.now();
return ret; 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) { __wbg_process_44c7a14e11e9f69e: function(arg0) {
const ret = arg0.process; const ret = arg0.process;
return ret; return ret;
@ -894,6 +987,10 @@ function __wbg_get_imports() {
__wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) { __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(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) { __wbg_queueMicrotask_0ab5b2d2393e99b9: function(arg0) {
const ret = arg0.queueMicrotask; const ret = arg0.queueMicrotask;
return ret; return ret;
@ -904,10 +1001,6 @@ function __wbg_get_imports() {
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) { __wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1); arg0.randomFillSync(arg1);
}, arguments); }, }, arguments); },
__wbg_ready_e4dad560377c42e6: function(arg0) {
const ret = arg0.ready;
return ret;
},
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () { __wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
const ret = module.require; const ret = module.require;
return ret; return ret;
@ -920,15 +1013,6 @@ function __wbg_get_imports() {
const ret = Reflect.set(arg0, arg1, arg2); const ret = Reflect.set(arg0, arg1, arg2);
return ret; return ret;
}, arguments); }, }, 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) { __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
const ret = arg1.stack; const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); 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; const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); 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) { __wbg_subarray_3ed232c8a6baee09: function(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret; return ret;
@ -968,6 +1048,10 @@ function __wbg_get_imports() {
const ret = arg0.then(arg1); const ret = arg0.then(arg1);
return ret; return ret;
}, },
__wbg_toString_34387d7c1df9ca1e: function() { return handleError(function (arg0, arg1) {
const ret = arg0.toString(arg1);
return ret;
}, arguments); },
__wbg_versions_276b2795b1c6a219: function(arg0) { __wbg_versions_276b2795b1c6a219: function(arg0) {
const ret = arg0.versions; const ret = arg0.versions;
return ret; return ret;
@ -977,18 +1061,18 @@ function __wbg_get_imports() {
return ret; return ret;
}, },
__wbindgen_cast_0000000000000001: function(arg0, arg1) { __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); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000002: function(arg0, arg1) { __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`. // 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__h2faccefeed15993f); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3c511b580d027299);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000003: function(arg0, arg1) { __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`. // 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__h2faccefeed15993f_2); const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h99ead69e8d7f5bae);
return ret; return ret;
}, },
__wbindgen_cast_0000000000000004: function(arg0) { __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) { function wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584(arg0, arg1, arg2); const ret = wasm.wasm_bindgen__convert__closures_____invoke__h6588d25cdde23584(arg0, arg1, arg2);
if (ret[1]) { 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) { function wasm_bindgen__convert__closures_____invoke__h4bf2427f775cf424(arg0, arg1, arg2, arg3) {
wasm.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') const WasmPublicKeyBundleFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} } ? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_wasmpublickeybundle_free(ptr, 1)); : 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) { function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc(); const idx = wasm.__externref_table_alloc();
@ -1152,16 +1233,6 @@ function debugString(val) {
return className; 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) { function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0; ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);

Binary file not shown.

View file

@ -1,6 +1,11 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
export const memory: WebAssembly.Memory; 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_wasmchacha20poly1305_free: (a: number, b: number) => void;
export const __wbg_wasmed25519signer_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; 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_cl_public_key: (a: number) => [number, number];
export const wasmpublickeybundle_sig_pq_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 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 __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_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_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_connect: (a: number, b: number) => any;
export const wasmclient_disconnect: (a: number) => void; export const wasmclient_disconnect: (a: number) => void;
export const wasmclient_is_supported: () => number; export const wasmclient_is_supported: () => number;
export const wasmclient_new: (a: any, b: any, c: any) => 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_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_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 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__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__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_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void; export const __wbindgen_exn_store: (a: number) => void;

View file

@ -1,6 +1,9 @@
use std::cell::Cell; use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use futures_channel::oneshot;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION}; use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION};
@ -8,9 +11,116 @@ use mtp_type_map::CommunicationTypeId;
use mtp_crypto::SignatureScheme; use mtp_crypto::SignatureScheme;
use crate::config::ConnectionConfig;
use crate::error::js_error; use crate::error::js_error;
use crate::transport::WasmTransport; use crate::transport::WasmTransport;
struct PendingRequest {
response_type: Option<String>,
sender: oneshot::Sender<Result<JsValue, JsValue>>,
}
struct PingTimer {
id: i32,
closure: Closure<dyn FnMut()>,
}
fn frame_property(frame: &JsValue, key: &str) -> Option<JsValue> {
js_sys::Reflect::get(frame, &JsValue::from_str(key))
.ok()
.filter(|value| !value.is_null() && !value.is_undefined())
}
fn frame_id(frame: &JsValue) -> Option<u32> {
frame_property(frame, "id")
.and_then(|value| value.as_f64())
.map(|value| value as u32)
}
fn frame_type(frame: &JsValue) -> Option<String> {
frame_property(frame, "type").and_then(|value| value.as_string())
}
fn route_incoming_frame(
frame: &JsValue,
on_message: &js_sys::Function,
subscriptions: &Rc<RefCell<HashMap<u32, (String, js_sys::Function)>>>,
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
) {
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<RefCell<Option<PingTimer>>>) {
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::<js_sys::Function>().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<RefCell<HashMap<u32, PendingRequest>>>,
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 { fn raw_frame_preview(bytes: &[u8]) -> String {
let shown = bytes.len().min(256); let shown = bytes.len().min(256);
let mut preview = hex::encode(&bytes[..shown]); let mut preview = hex::encode(&bytes[..shown]);
@ -139,45 +249,6 @@ pub enum ConnectionState {
Failed = 3, Failed = 3,
} }
#[wasm_bindgen]
pub struct ConnectionConfig {
url: String,
server_certificate_hashes: Option<Vec<String>>,
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<String>) {
self.server_certificate_hashes = Some(hashes);
}
}
#[wasm_bindgen] #[wasm_bindgen]
pub struct WasmClient { pub struct WasmClient {
transport: Option<WasmTransport>, transport: Option<WasmTransport>,
@ -185,6 +256,10 @@ pub struct WasmClient {
on_state_change: js_sys::Function, on_state_change: js_sys::Function,
pub(crate) on_message: js_sys::Function, pub(crate) on_message: js_sys::Function,
pub(crate) on_error: js_sys::Function, pub(crate) on_error: js_sys::Function,
subscriptions: Rc<RefCell<HashMap<u32, (String, js_sys::Function)>>>,
next_subscription_id: Rc<Cell<u32>>,
pending_requests: Rc<RefCell<HashMap<u32, PendingRequest>>>,
ping_timer: Rc<RefCell<Option<PingTimer>>>,
} }
#[wasm_bindgen] #[wasm_bindgen]
@ -201,6 +276,10 @@ impl WasmClient {
on_state_change: on_state_change.clone(), on_state_change: on_state_change.clone(),
on_message: on_message.clone(), on_message: on_message.clone(),
on_error: on_error.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<u8>,
response_type: Option<String>,
) -> Result<JsValue, JsValue> {
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<dyn FnMut()>);
let set_interval =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))?
.dyn_into::<js_sys::Function>()?;
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::<js_sys::Function>().map_err(Into::into))
{
let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64));
}
drop(timer.closure);
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn disconnect(&mut self) { pub fn disconnect(&mut self) {
self.stop_protocol_pings();
if let Some(t) = &self.transport { if let Some(t) = &self.transport {
t.close(); t.close();
} }
self.transport = None; self.transport = None;
self.subscriptions.borrow_mut().clear();
self.reject_pending_requests("disconnected");
self.set_state(ConnectionState::Disconnected); self.set_state(ConnectionState::Disconnected);
} }
@ -479,12 +680,34 @@ impl WasmClient {
let state = self.state.clone(); let state = self.state.clone();
let on_msg = self.on_message.clone(); let on_msg = self.on_message.clone();
let on_err = self.on_error.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 { 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<dyn FnMut(JsValue)>);
loop_transport
.receive_loop(
route_frame
.as_ref()
.unchecked_ref::<js_sys::Function>()
.clone(),
on_err.clone(),
)
.await;
drop(route_frame);
state.set(ConnectionState::Disconnected); 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( async fn read_verified_challenge(
&self, &self,
transport: &WasmTransport, transport: &WasmTransport,

40
wasm/src/config.rs Normal file
View file

@ -0,0 +1,40 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct ConnectionConfig {
pub(crate) url: String,
pub(crate) server_certificate_hashes: Option<Vec<String>>,
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<String>) {
self.server_certificate_hashes = Some(hashes);
}
}

479
wasm/src/frame.rs Normal file
View file

@ -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<string, unknown>;
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::<f64>() {
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<JsValue, JsValue> {
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<DataValue, JsValue> {
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::<js_sys::BigInt>();
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::<i128>()
.map_err(|_| js_error("bigint out of range"))?;
return Ok(DataValue::SignedNumber(-n));
}
let n = as_string
.parse::<u128>()
.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<Option<u32>, 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<Option<u64>, 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::<js_sys::BigInt>();
let as_string = bigint
.to_string(10)?
.as_string()
.ok_or_else(|| js_error("failed to stringify bigint"))?;
return as_string
.parse::<u64>()
.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<JsValue, JsValue> {
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<Vec<u8>, 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<JsValue, JsValue> {
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<String, JsValue> {
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<JsValue, JsValue> {
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<Vec<u8>, 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());
}
}

View file

@ -1,7 +1,10 @@
pub mod client; pub mod client;
pub mod config;
pub mod crypto; pub mod crypto;
pub mod error; pub mod error;
pub mod message; pub mod frame;
pub mod logging;
pub mod subscription;
pub mod transport; pub mod transport;
#[cfg(not(test))] #[cfg(not(test))]
@ -11,5 +14,4 @@ use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)] #[wasm_bindgen(start)]
pub fn main() { pub fn main() {
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
web_sys::console::log_1(&"mtp-wasm: module loaded".into());
} }

10
wasm/src/logging.rs Normal file
View file

@ -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,
}

View file

@ -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<Vec<u8>, 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<Vec<u8>, 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<JsValue, JsValue> {
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<Vec<u8>, 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::<String>();
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<String, JsValue> {
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::<js_sys::Array>() {
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<String, JsValue> {
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());
}
}

44
wasm/src/subscription.rs Normal file
View file

@ -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<String, js_sys::Function>,
}
#[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()
}
}

View file

@ -4,9 +4,9 @@ use std::rc::Rc;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{WebTransport, WebTransportHash, WebTransportOptions};
use crate::error::js_error; use crate::error::js_error;
use crate::frame::parse_frame_value;
const CLOSE_FRAME_LEN: u32 = u32::MAX; const CLOSE_FRAME_LEN: u32 = u32::MAX;
@ -40,42 +40,6 @@ enum FrameOutcome {
Ended, 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::<Vec<_>>()
.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. * WebTransport client transport.
* *
@ -90,7 +54,7 @@ fn log_frame_bytes(wire: &[u8]) {
*/ */
#[derive(Clone)] #[derive(Clone)]
pub struct WasmTransport { pub struct WasmTransport {
inner: WebTransport, inner: JsValue,
/// Reader over `incoming_unidirectional_streams()` (a singleton stream of streams). /// Reader over `incoming_unidirectional_streams()` (a singleton stream of streams).
streams_reader: Rc<RefCell<Option<JsValue>>>, streams_reader: Rc<RefCell<Option<JsValue>>>,
/// Reader over the host's current uni-directional stream, if one is open. /// Reader over the host's current uni-directional stream, if one is open.
@ -101,28 +65,48 @@ pub struct WasmTransport {
impl WasmTransport { impl WasmTransport {
pub async fn connect(url: &str, cert_hashes: Option<Vec<String>>) -> Result<Self, JsValue> { pub async fn connect(url: &str, cert_hashes: Option<Vec<String>>) -> Result<Self, JsValue> {
let transport = match cert_hashes { let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))?
Some(hashes) => { .dyn_into::<js_sys::Function>()
let opts = WebTransportOptions::new(); .map_err(|_| js_error("WebTransport not available"))?;
let mut wt_hashes = Vec::new(); let args = js_sys::Array::new();
for h in hashes { args.push(&JsValue::from_str(url));
if let Some((algo, hex_val)) = h.split_once(':') {
if let Ok(bytes) = hex::decode(hex_val) { if let Some(hashes) = cert_hashes {
let hash = WebTransportHash::new(); let wt_hashes = js_sys::Array::new();
hash.set_algorithm(algo); for h in hashes {
hash.set_value_u8_array(&js_sys::Uint8Array::from(&bytes[..])); if let Some((algo, hex_val)) = h.split_once(':') {
wt_hashes.push(hash); 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::<js_sys::Promise>()
.map_err(|_| js_error("WebTransport.ready is not a Promise"))?;
JsFuture::from(ready)
.await .await
.map_err(|e| js_error(&format!("WebTransport ready failed: {:?}", e)))?; .map_err(|e| js_error(&format!("WebTransport ready failed: {:?}", e)))?;
Ok(Self { Ok(Self {
@ -133,12 +117,21 @@ impl WasmTransport {
}) })
} }
pub fn inner(&self) -> &WebTransport { pub fn inner(&self) -> &JsValue {
&self.inner &self.inner
} }
pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> { 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::<js_sys::Function>()
.map_err(|_| js_error("createUnidirectionalStream not a function"))?;
let stream_promise = create_stream
.call0(&self.inner)?
.dyn_into::<js_sys::Promise>()
.map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?;
let stream = JsFuture::from(stream_promise).await?; let stream = JsFuture::from(stream_promise).await?;
let writable_or_stream = resolve_stream_writable(&stream)?; 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(&len.to_be_bytes());
wire.extend_from_slice(frame); wire.extend_from_slice(frame);
log_frame_bytes(&wire);
let chunk = js_sys::Uint8Array::from(&wire[..]); let chunk = js_sys::Uint8Array::from(&wire[..]);
let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) 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() { if let Some(reader) = self.streams_reader.borrow().clone() {
return Ok(reader); 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")) let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader"))
.map_err(|_| js_error("missing getReader"))? .map_err(|_| js_error("missing getReader"))?
.dyn_into::<js_sys::Function>() .dyn_into::<js_sys::Function>()
@ -341,10 +335,15 @@ impl WasmTransport {
pub async fn receive_loop(&self, on_message: js_sys::Function, on_error: js_sys::Function) { pub async fn receive_loop(&self, on_message: js_sys::Function, on_error: js_sys::Function) {
loop { loop {
match self.next_frame().await { match self.next_frame().await {
Ok(FrameOutcome::Frame(frame)) => { Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) {
let arr = js_sys::Uint8Array::from(&frame[..]); Ok(parsed) => {
let _ = on_message.call1(&JsValue::NULL, &arr); 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, Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break,
Err(e) => { Err(e) => {
let _ = on_error.call1(&JsValue::NULL, &e); let _ = on_error.call1(&JsValue::NULL, &e);
@ -355,7 +354,10 @@ impl WasmTransport {
} }
pub fn close(&self) { pub fn close(&self) {
let info = web_sys::WebTransportCloseInfo::new(); if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close"))
let _ = self.inner.close_with_close_info(&info); .and_then(|value| value.dyn_into::<js_sys::Function>().map_err(Into::into))
{
let _ = close.call1(&self.inner, &js_sys::Object::new());
}
} }
} }

173
wasm/types/mtp_wasm.d.ts vendored Normal file
View file

@ -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<string, unknown>;
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<bigint>;
auth_register(
config: ConnectionConfig,
host_public_key_bytes: Uint8Array,
keyring_bytes: Uint8Array,
): Promise<bigint>;
connect(config: ConnectionConfig): Promise<void>;
disconnect(): void;
request(frame: Uint8Array, response_type?: string | null): Promise<ParsedFrame>;
send(frame: Uint8Array): Promise<void>;
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<string, unknown>, 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> } | InitInput | Promise<InitInput>,
): Promise<InitOutput>;