mtp/docs/WASM-CLIENT.md
Alex Emmet c148314742
Some checks failed
CI / checks (push) Failing after 1m38s
[WIP] Pings, Pongs & Streams
2026-07-14 00:14:53 +02:00

9.8 KiB

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.

Package Entry Points

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/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.

// vite.config.ts
import { defineConfig } from "vite";
import { mtp } from "mtp/vite";

export default defineConfig({
  plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});

You do not need to publish, fork, or copy an app-specific generated WASM package.

SDK Quick Start

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:

if (!MTPClient.isSupported()) {
  throw new Error("WebTransport is not available in this browser");
}

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.

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:

interface MTPCredentialStorage {
  getItem(key: string): string | null | Promise<string | null>;
  setItem(key: string, value: string): void | Promise<void>;
  removeItem(key: string): void | Promise<void>;
}

credentials can also be supplied directly:

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:

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.

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:

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 deliberately 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:

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:

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. responseType is validated after the id match:

const response = await client.request(
  "SomeRequestType",
  { id: "abc" },
  { responseType: "SomeResponseType" },
);

subscribe registers a message-type handler and returns an unsubscribe function:

const unsubscribe = client.subscribe("SomeType", (message) => {
  console.log(message.id, message.sender, message.data);
});

unsubscribe();

Protocol pings are real MTP Ping frames sent by the WASM client, not just transport keepalives:

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:

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:

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:

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)

The SDK export also exposes the same frame codec through codec:

import { codec } from "mtp";

const frame = codec.encode("SomeType", { value: "hello" });
const parsed = codec.decode(frame);
const display = codec.format(frame);

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()
  • 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:

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.