# MTP WASM Client 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. ## Browser Compatibility The SDK requires the browser to expose `WebTransport`. `MTPClient.isSupported()` is the runtime check. A browser without WebTransport cannot connect through this client. | Requirement | Check | | --- | --- | | WebTransport API | `MTPClient.isSupported()` | | Certificate trust | Browser validation or `serverCertificateHashes` | | Secure context | Serve the application from HTTPS where required by the browser | | Generated bindings | Run the Vite integration during development and build | ## Package Entry Points ```typescript import { MTPClient } from "mtp"; import init, { WasmClient } from "mtp/raw"; import { mtp } from "mtp/vite"; ``` - `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. ## Vite Type-Map Workflow Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev and 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. Configuration: [Type Map](TYPE-MAP.md). The browser build uses the map named by `protocol_version` and includes the reserved MTP names. It does not advertise application names from other map versions, because the generated WASM client is compiled for that one protocol version. The selected version must exist in `type_maps`. You do not need to publish, fork, or copy an app-specific generated WASM package. The [web client example](../example/web-client/src/main.ts) shows the entry point. Its [Vite configuration](../example/web-client/vite.config.ts) shows the generated binding integration. ## SDK Quick Start ```typescript import { MTPClient } from "mtp"; const client = await MTPClient.create({ url: "https://host.example.com:4433", hostPublicKey, credentials, storage, serverCertificateHashes: ["sha-256:abcd1234..."], maxMessageSize: 1_000_000, authTimeoutMs: 30_000, pings: true, logger: (event) => console.log(event), }); const unsubscribe = client.subscribe("SomeType", (message) => { console.log(message.type, message.data); }); if (client.credentials?.clientId == null) { await client.register(); } else { await client.connect(); } await client.send("SomeType", { value: "hello" }); const response = await client.request( "SomeRequestType", { id: "abc" }, { responseType: "SomeResponseType" }, ); client.raw.client; // underlying WasmClient instance client.raw.bindings; // generated raw WASM module exports unsubscribe(); client.disconnect(); ``` `MTPClient.isSupported()` checks whether the current browser exposes WebTransport: ```typescript if (!MTPClient.isSupported()) { throw new Error("WebTransport is not available in this browser"); } ``` ## MTPClient Options | Option | Default | Purpose | | --- | --- | --- | | `url` | Required | WebTransport endpoint. | | `descriptor` | None | Client label sent during connection setup. | | `hostPublicKey` | None | Host public key bundle for authenticated login or registration. | | `credentials` | None | Existing client ID and serialized keyring. | | `credentialsStorageKey` | `mtp:credentials` | Key used by configured credential storage. | | `storage` | None | Sync or async credential storage adapter. | | `serverCertificateHashes` | Omitted | WebTransport certificate pins. | | `maxMessageSize` | 16 MiB | Inbound and outbound frame limit. Values below frame overhead are rejected by the transport. | | `authTimeoutMs` | No SDK timeout | Login and registration timeout. `undefined` leaves the promise pending until transport or peer failure. | | `requestTimeoutMs` | 30 seconds | Default `request()` timeout. | | `pings` | `false` | Protocol pings, or an object with `intervalMs`. | | `logger` | No-op | Receives SDK state and error events. | | `sessionStorage` | In-memory | E2EE session state storage. | | `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. | | `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. | `wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options. ## Differences from Native Client The browser SDK uses WebTransport and JavaScript promises. The native client uses Rust futures, direct QUIC configuration, and `MTPConnection` handles. Browser pipes expose promise-based readers and writers; native pipes implement Tokio I/O traits. ### Native and Browser Credential Persistence The `storage` option supplies the credential adapter. The adapter stores the client ID and serialized keyring after registration and returns them for later connections. The SDK does not select `localStorage` or IndexedDB for an application. Treat the serialized keyring as private key material. `sessionStorage` and `encryptedSecretProvider` are separate caller-managed stores. The latter exchanges `MTPEncryptedSecretRecord` values through `set`, `get`, and `delete`; the `MTPClient` convenience methods are named `setEncryptedSecret`, `getEncryptedSecret`, and `deleteEncryptedSecret`. `MTPSessionManager` does not automatically route session state through the provider. If session material must be encrypted at rest, the caller must make that coordination explicit in its `MTPSessionStorage` implementation. Secret IDs are opaque to MTP, so a caller can map its own state to the ID while choosing the backing store and protecting its wrapping key. ### Direct Protected Messages Use `sendProtected` when the destination is the frame receiver and no intermediate relay needs a separately encrypted metadata layer. It keeps the application communication type on the outer frame and encrypts an MTP-owned signed envelope for the exact recipient bundles supplied by the caller. The envelope authenticates `ProtectedVersion`, `MessageType`, `FinalRecipientId`, `MessageId`, `CreatedAt`, and `Content`. The opening operation checks the authenticated type and final recipient against the outer frame. ```typescript await client.sendProtected("ProtectedMessage", { Content: "hello" }, { receiverId: recipientId, recipients: [recipientPublicKey], signaturePurpose: 0x40, encryptionPurpose: 0x41, exposeSender: false, }); ``` The protection purposes are application-defined domain-separation values. `exposeSender` controls only the outer frame sender; the protected value remains signed in either case. If `identity` is omitted, the SDK uses stored registered credentials and rejects the operation when no usable protection identity is available. An unauthenticated connection can still send a protected value when the caller provides an explicit `identity` with the signer ID and keyring. The connection's authentication state and the protected signer's identity are independent. When `signatureSuite` is omitted, protected send helpers use Ed25519 even when the signing keyring also contains post-quantum keys. This matches the default receiver policy. Use `signatureSuite: "dual"` together with `signaturePolicy: "dual"` when both sides explicitly require hybrid signatures. Open a direct protected frame with the recipient keyring and a resolver that receives the claimed, unverified signer ID only as a trusted-key lookup key: ```typescript const message = await client.openProtected(frame, { recipient: { id: recipientId, keyring: recipientKeyring, keyringHistory: previousRecipientKeyrings, }, expectedReceiverId: recipientId, expectedSignerId: signerId, resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [], signaturePolicy: "dual", signaturePurpose: 0x40, encryptionPurpose: 0x41, replayGuard, }); console.log(message.type, message.signerId, message.messageId, message.data); ``` `protectedVersion`, `finalRecipientId`, `signerId`, `messageId`, and `createdAt` are taken from the verified protected envelope. `outerSender`, when present, must equal the authenticated signer. Protected application data may be any supported MTP `DataValue`, including scalar, byte, array, and container values. Direct opening uses a bounded process-local duplicate-suppression guard by default. The bounded cache can evict old entries, so supply a durable `replayGuard` keyed by authenticated signer and message ID when replay protection must survive eviction, reloads, or multiple receiver processes. The guard also receives authenticated `createdAt` metadata, which is not part of the replay key. `subscribeProtected` uses the same opening and verification path: ```typescript const unsubscribe = client.subscribeProtected( "ProtectedMessage", (message, frame) => handleMessage(message.data, frame), { recipient: { id: recipientId, keyring: recipientKeyring }, resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [], signaturePolicy: "dual", signaturePurpose: 0x40, encryptionPurpose: 0x41, }, ); ``` Each `subscribeProtected` registration owns its own bounded default replay guard, so multiple handlers receive the same raw frame through the WASM fan-out dispatcher. Pass the same caller-owned `replayGuard` deliberately when several subscriptions should share replay state. ### Sealed Relay Messages `sendSealedRelay` uses the reserved opaque `Relay` communication type. Its inner message type must be an application communication type, not an MTP control type. The outer frame contains no sender and exposes only the next-hop receiver. The signed relay metadata contains the generic `signerId`, `finalRecipientId`, `messageId`, `createdAt`, application `metadata`, and an opaque encrypted content value. `createdAt` is generated as Unix epoch milliseconds. For example, `2026-08-11T12:00:00.000Z` is `1786449600000`. ```typescript const data = { Content: "hello" }; await client.sendSealedRelay("ProtectedMessage", data, { finalRecipientId, nextHopId, metadataRecipients: [ relayPublicKey, recipientPublicKey, ], contentRecipients: [ recipientPublicKey, ], metadata: { ExampleMetadata: "routing context", }, }); client.subscribeSealedRelay( "ProtectedMessage", (message, frame) => handleMessage(message.data, frame), { recipient: { id: finalRecipientId, keyring: recipientKeyring, }, expectedSignerId: signerId, resolveSignerPublicKeys: () => [senderPublicKey], }, ); ``` The caller supplies the exact metadata and content recipient sets; the SDK does not infer application topology. Set `signaturePolicy: "dual"` to require hybrid signatures explicitly, and install a durable `replayGuard` so a valid `(signerId, messageId)` is dispatched only once. Each sealed-relay or metadata subscription likewise gets an independent bounded default guard. This preserves fan-out when multiple handlers inspect the same outer `Relay` frame; an explicitly supplied guard is shared by the subscriptions that receive it. Applications choose between direct protected delivery and sealed relay based on topology and metadata-access requirements. Prefer `sendProtected` for a direct destination. Use `sendSealedRelay` when a next hop must route or store a message and the application needs metadata recipients to differ from content recipients. Neither construction requires connection authentication, although the host can associate an authenticated connection with its registered MTP identity. For metadata-only access, call `openRelayMetadata` or subscribe with `subscribeRelayMetadata`. These operations authenticate the metadata and expose `encryptedContent` for forwarding without attempting content decryption. A final recipient calls `openRelayContent` after metadata verification; the returned `MTPVerifiedRelayContent` includes the application type and data plus `signerId`, `finalRecipientId`, `messageId`, `createdAt`, and generic metadata fields. These are authenticated protected identities, not the clear outer sender and next-hop receiver. Relay content inherits the authenticated metadata's `signaturePolicy` when no content override is supplied. A different content policy is rejected so the two relay layers cannot be verified under conflicting rules. Metadata passed to a `subscribeRelayMetadata` handler is callback-scoped and is disposed after the handler resolves. Do not retain it for a later `openRelayContent` call; use `openRelayMetadata` directly when a longer-lived verified capability is needed, and call `dispose()` when finished. When signer key history is used, `signerPublicKeys` exposes the trusted candidates, `matchedSignerKeyIndex` identifies the key that verified the metadata, and `matchedSignerPublicKey` returns that exact bundle. Protected receive operations accept an optional `recipient` decryption identity. Its `keyring` controls decryption and its optional `id` is used only for final-recipient validation. The identity is independent from connection authentication. Metadata opening does not require the identity ID to match the clear next-hop receiver, so a forwarded frame can be opened by a metadata recipient or final recipient with the appropriate keyring. When `recipient` is omitted, stored registered credentials remain the convenience fallback. To open values encrypted for a rotated recipient, provide `keyringHistory` on the decryption identity. The current `keyring` is tried first, followed by history entries from newest to oldest. Exact duplicate byte sequences are removed without changing the caller's input arrays. An empty current keyring or an empty history entry is rejected. Generic MTP `DataValue` inputs accept `bigint` for exact integer values. An integral JavaScript `number` outside the safe-integer range is rejected, so it cannot silently become an imprecise float. Use `bigint` for large signed or unsigned integers. For streams, prefer `createEncryptedPipe` and `acceptEncryptedPipe`; they bind the actual pipe ID and local identity automatically. The lower-level `initiateMTPPipeSession` API also accepts multiple recipient bundles for a group bootstrap. Group membership changes require a fresh session ID and recipient set. Live calls that need forward secrecy can use the exported duplex `initiateMTPForwardSecurePipeSession` and `acceptMTPForwardSecurePipeSession` helpers. The convenience pipe methods intentionally require registered client credentials because they use the connection's registered identity as the endpoint identity. Use the lower-level session functions when transport authentication and cryptographic endpoint identity must remain independent. Receive-side signature policy is independent from the recipient keyring. Use `signaturePolicy` on protected receive and encrypted-pipe accept operations, or configure `defaultSignatureVerificationPolicy` on the client. The sender's `signatureSuite` selects how local values are signed and is a separate choice. Both sender and receiver default to Ed25519; `dual` is always an explicit choice on each side. ### Native and Browser Certificate Checks WebTransport certificate pins must match the server certificate hash. A pin mismatch is a TLS failure, not an MTP authentication failure. Check the browser network panel, endpoint origin, and WebTransport CONNECT path before inspecting frames. ## Credentials And Storage Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration. ```typescript const storage = { getItem: (key: string) => localStorage.getItem(key), setItem: (key: string, value: string) => localStorage.setItem(key, value), removeItem: (key: string) => localStorage.removeItem(key), }; const client = await MTPClient.create({ url: "https://host.example.com:4433", hostPublicKey, storage, }); const clientId = client.credentials?.clientId == null ? await client.register() : (await client.connect(), client.credentials.clientId); ``` The storage contract is intentionally small and may be sync or async: ```typescript interface MTPCredentialStorage { getItem(key: string): string | null | Promise; setItem(key: string, value: string): void | Promise; removeItem(key: string): void | Promise; } ``` `credentials` can also be supplied directly: ```typescript const client = await MTPClient.create({ url: "https://host.example.com:4433", hostPublicKey, credentials: { clientId: 42n, keyring: savedKeyringBytes, }, }); await client.connect(); ``` `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. - `disconnect()` stops protocol pings and closes the underlying WebTransport session. For certificate pinning, pass WebTransport certificate hashes: ```typescript await MTPClient.create({ url: "https://host.example.com:4433", serverCertificateHashes: ["sha-256:abcd1234..."], }); ``` If hashes are omitted, the browser uses its normal TLS root store. `maxMessageSize` caps inbound and outbound MTP frames before buffering/sending. `authTimeoutMs` bounds connect/login/register promises at the SDK layer. `requestTimeoutMs` sets the default timeout for `request()` calls; a request can override it with `timeoutMs` in its options. ## Streams The browser client uses one WebTransport session per `MTPClient` instance. `send()`, `request()`, and `subscribe()` all operate over that session; the SDK does not expose browser stream objects directly. Use the normal message APIs to send and receive over that session: ```typescript const client = await MTPClient.create({ url, hostPublicKey }); await client.connect(); const unsubscribe = client.subscribe("SomeType", (message) => { console.log(message.data); }); await client.send("SomeType", { value: "hello" }); unsubscribe(); ``` Internally, each outbound MTP frame is written to a new WebTransport unidirectional stream as a four-byte big-endian length followed by the frame, then that stream is closed. Incoming frames are read from the session's incoming unidirectional streams. The reader accepts both one-frame streams and native peers that place several frames on a persistent stream, so browser and native clients interoperate without stream configuration. The SDK owns stream lifetime and framing. Do not create browser streams for MTP frames yourself through the SDK. For direct generated bindings, use `client.raw.client` or import `WasmClient` from `mtp/raw`; a `WasmClient` still owns one active WebTransport session, so create another instance for an independent connection. ## Sending, Requests, Subscriptions, And Pings `send` accepts either a typed message or a prebuilt raw frame: ```typescript await client.send("SomeType", { value: "hello" }); await client.send(rawFrameBytes); ``` 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 await client.send("SomeType", { value: "hello" }, { id: 7, sender: client.credentials?.clientId ?? 0n, }); ``` `request` sends one frame and resolves with the parsed response carrying the same frame id. If the matching response has a different `responseType`, the promise rejects with a response-type error. A timeout rejects the promise and removes the pending request: ```typescript const response = await client.request( "SomeRequestType", { id: "abc" }, { responseType: "SomeResponseType" }, ); ``` `subscribe` registers a message-type handler and returns an unsubscribe function: ```typescript const unsubscribe = client.subscribe("SomeType", (message) => { console.log(message.id, message.sender, message.data); }); unsubscribe(); ``` Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is: ```typescript await MTPClient.create({ url: "https://host.example.com:4433", pings: { intervalMs: 30_000 }, }); ``` Use `pings: true` for the default interval. ## Pipes Pipes are byte-oriented streams over WebTransport. The `PipeRequest` type and description are clear transport metadata; raw stream bytes are not protected by MTP. For sensitive calls, files, or application streams, wrap the accepted pipe with `MTPEncryptedPipeWriter` or `MTPEncryptedPipeReader`. ### Outgoing Pipes `createPipe` sends a `PipeRequest` frame and returns a handle. Call `wait()` to block until the remote peer accepts or denies: ```typescript const handle = await client.createPipe("file-transfer"); const writer = await handle.wait(); if (writer == null) { console.log("host denied the pipe"); return; } await writer.write(new Uint8Array([0x01, 0x02, 0x03])); await writer.write(chunk); await writer.close(); ``` `writer.close()` sends a QUIC stream FIN. `writer.abort()` resets the stream abruptly. Each `write` resolves when the chunk has been handed to the transport; it does not wait for the peer to consume it. ### Encrypted Pipe Records `initiateMTPPipeSession` and `acceptMTPPipeSession` perform the signed/KEM protected pipe-session offer and return the encrypted record wrapper. The offer binds the session ID, pipe ID, endpoint IDs, direction, and purpose. Do not derive the initial chain key from the clear description or pipe ID alone. ```typescript import { initiateMTPPipeSession, } from "mtp"; const encryptedWriter = await initiateMTPPipeSession( writer, { sessionId: new TextEncoder().encode(`file-transfer/${writer.pipeId}`), pipeId: writer.pipeId, senderId: ownClientId, recipientId: hostClientId, purpose: 0x40, direction: 0, }, ownKeyring, hostPublicKeyBundle, ); await encryptedWriter.writeRecord(chunk); await encryptedWriter.close(); ``` `writeRecord` and `readRecord` use XChaCha20-Poly1305 with ordered sequence numbers bound to the session context. Each record advances an HKDF chain and uses a one-use message key. Record insertion, removal, reordering, or modification fails authentication. The wrapper is intentionally separate from the raw `PipeWriter`/`PipeReader` transport primitives. The handle and writer expose `pipeId` and `description`: ```typescript console.log(handle.pipeId, handle.description); console.log(writer.pipeId); ``` ### Incoming Pipes Set a handler to receive pipe requests from the remote peer: ```typescript client.setOnPipeRequest((request) => { console.log("incoming pipe", request.pipeId, request.description); // accept or deny asynchronously }); ``` Accept a request to receive a `PipeReader`: ```typescript client.setOnPipeRequest(async (request) => { if (request.description === "file-transfer") { const reader = await client.acceptPipe(request.pipeId); while (true) { const chunk = await reader.read(); if (chunk == null) break; // stream closed by peer processChunk(chunk); } } else { await client.denyPipe(request.pipeId); } }); ``` `reader.read()` resolves with a `Uint8Array` or `null` when the peer closes the stream. The reader exposes `pipeId` and `description`: ```typescript console.log(reader.pipeId, reader.description); ``` ### Pipe Handshake 1. The initiator calls `createPipe(description)`; the SDK sends a `PipeRequest` frame with a random `pipeId` and the description. 2. The receiver's `setOnPipeRequest` callback fires with `{ pipeId, description }`. 3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for byte transport. 4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream. Sensitive applications then perform their signed/encrypted session-key setup and construct an encrypted record wrapper. 5. If the receiver calls `denyPipe(pipeId)`, `handle.wait()` resolves with `null`. Pipes share the same WebTransport session as message frames; they do not need a separate connection. ## 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 ParsedEncryptedValue { kind: "encrypted"; encryptionType: number; purpose: number; recipientCount: number; encoded: Uint8Array; } interface ParsedSignedValue { kind: "signed"; signatureType: number; purpose: number; signerId: bigint; value: ParsedDataValue; } type ParsedDataValue = | boolean | number | bigint | string | Uint8Array | ParsedDataValue[] | { [key: string]: ParsedDataValue } | ParsedEncryptedValue | ParsedSignedValue | null; interface ParsedFrame { id?: number; type: string; sender?: bigint; receiver?: bigint; data: ParsedDataValue; raw: Uint8Array; } ``` ### Frames 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)` The SDK export also exposes the same frame codec through `codec`: ```typescript import { codec } from "mtp"; const frame = codec.encode("SomeType", { value: "hello" }); const parsed = codec.decode(frame); const display = codec.format(frame); ``` ### Crypto Raw crypto and key helpers include: - `ed25519_generate()` - `ed25519_verify(publicKey, message, signature)` - `keyring_generate()` - `keyring_from_ed25519(secretKey, publicKey)` - `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()` - `keyring.validate_encryption()` for envelope decryption roles - `keyring.validate_full()` for complete hybrid identities - `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()` - `WasmEd25519Signer` - `WasmChaCha20Poly1305` - `sign_data_value_with_keyring` and `verify_data_value_with_policy` (both require an explicit signature suite), plus `encrypt_data_value`, `encrypt_data_value_for_recipients`, and `decrypt_data_value` - `parse_data_value` and `encode_data_value` - `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, ); ``` ### Pipes The raw `WasmClient` exposes the same pipe operations as the SDK wrapper. The shared lifecycle is in [Pipes](PIPES.md); raw bindings use snake_case names. ```typescript rawClient.set_on_pipe_request((event) => { void rawClient.accept_pipe(event.pipeId); }); const handle = await rawClient.create_pipe("file-transfer"); const writer = await handle.wait(); if (writer) { await writer.write(chunk); await writer.close(); } ``` 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. ### State Management A `WasmClient` owns one active WebTransport session. Create a separate client for each independent connection. Call `free()` or `[Symbol.dispose]()` on raw WASM objects when the application no longer needs them. SDK session and encrypted secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption).