diff --git a/README.md b/README.md index a5df4d2..048b882 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,7 @@ const client = await MTPClient.create({ client.subscribe("SomeType", (message) => console.log(message)); -const clientId = client.credentials?.clientId == null - ? await client.register() - : (await client.connect(), client.credentials.clientId); +const clientId = await client.auth(); await client.send("SomeType", { value: "hello" }); console.log("Connected MTP client", clientId, client.state); @@ -74,6 +72,15 @@ console.log("Connected MTP client", clientId, client.state); Use `MTPClient.crypto` for SDK-level crypto helpers such as `generateKeyring()`, `generateEd25519()`, `keyringFromEd25519()`, `verifyEd25519()`, `sha256()`, `sha256Double()`, `hkdfExpand()`, and `deriveEncryptionKey()`. +Use `codec` to encode and decode MTP frames from the main SDK export: + +```typescript +import { codec } from "mtp"; + +const frame = codec.encode("SomeType", { value: "hello" }); +const parsed = codec.decode(frame); +``` + ## Getting Started Add the `mtp` crate with your desired features: diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index b70ab3a..3cd4699 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -258,6 +258,16 @@ Raw message helpers that remain available include: - `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); +``` + Raw crypto and key helpers include: - `ed25519_generate()` diff --git a/example/web-client/src/main.ts b/example/web-client/src/main.ts index 07f24bd..2e8da5c 100644 --- a/example/web-client/src/main.ts +++ b/example/web-client/src/main.ts @@ -199,10 +199,7 @@ async function connect() { log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); }); - const existingClientId = client.credentials?.clientId; - const activeClientId = existingClientId == null - ? await client.register() - : (await client.connect(), BigInt(existingClientId)); + const activeClientId = await client.auth(); clientId = activeClientId; loadKeys(); log(`Connected as client ${activeClientId}`); diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 7006849..eabcc8a 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -75,6 +75,36 @@ export interface MTPRaw { export type MTPBytesInput = Uint8Array | number[]; +export interface MTPCodecOptions { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +} + +export interface MTPCodec { + encode(type: MTPCommunicationType, data: Record, options?: MTPCodecOptions): Uint8Array; + decode(frame: MTPBytesInput): ParsedFrame; + format(frame: MTPBytesInput): string; +} + +export function encode(type: MTPCommunicationType, data: Record, options?: MTPCodecOptions): Uint8Array { + return bindings.build_frame(type, data, options ?? {}); +} + +export function decode(frame: MTPBytesInput): ParsedFrame { + return bindings.parse_frame(bytesFrom(frame, "frame")); +} + +export function format(frame: MTPBytesInput): string { + return bindings.format_frame(bytesFrom(frame, "frame")); +} + +export const codec: MTPCodec = { + encode, + decode, + format, +}; + export interface MTPCredentials { clientId: bigint | string | number | null; keyring: MTPBytesInput; @@ -93,6 +123,7 @@ export interface MTPClientCredentials { export interface MTPClientOptions { url: string; + descriptor?: string; hostPublicKey?: MTPBytesInput | string; credentials?: MTPCredentials | string | null; credentialsStorageKey?: string; @@ -128,6 +159,7 @@ type NormalizedMTPClientOptions = Omit & { }; const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; +let wasmInitPromise: Promise>> | undefined; function emit(logger, event) { if (typeof logger === "function") { @@ -295,6 +327,9 @@ function validateOptions(options) { if (typeof options.url !== "string" || !options.url.trim()) { throw new TypeError("MTPClient.create requires a non-empty url"); } + if (options.descriptor != null && typeof options.descriptor !== "string") { + throw new TypeError("descriptor must be a string"); + } if (options.storage) { for (const method of ["getItem", "setItem", "removeItem"]) { if (typeof options.storage[method] !== "function") { @@ -330,12 +365,14 @@ async function withTimeout(promise, timeoutMs, message) { export class MTPClient { static readonly crypto = crypto; + static readonly codec = codec; #credentials: InternalCredentials | null; #options: NormalizedMTPClientOptions; readonly raw: MTPRaw; readonly crypto = MTPClient.crypto; + readonly codec = MTPClient.codec; private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) { this.#options = options; @@ -394,7 +431,8 @@ export class MTPClient { } static async init(wasm?: MTPClientOptions["wasm"]): Promise>> { - return await initWasm(wasm); + wasmInitPromise ??= initWasm(wasm); + return await wasmInitPromise; } get credentials(): MTPClientCredentials | null { @@ -425,12 +463,15 @@ export class MTPClient { if (this.#options.maxMessageSize != null) { config.max_message_size = this.#options.maxMessageSize; } + if (this.#options.descriptor != null) { + config.description = this.#options.descriptor; + } return config; } async connect(): Promise { if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { - await this.#connectAuthenticated(); + await this.auth(); return; } @@ -447,6 +488,15 @@ export class MTPClient { } } + async auth(): Promise { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.auth requires hostPublicKey"); + } + return this.#credentials?.clientId == null + ? await this.register() + : await this.#connectAuthenticated(); + } + async #connectAuthenticated() { if (!this.#options.hostPublicKey) { throw new Error("MTPClient.connect requires hostPublicKey for authenticated connections"); diff --git a/wasm/src/client.rs b/wasm/src/client.rs index 7aef180..16c33eb 100644 --- a/wasm/src/client.rs +++ b/wasm/src/client.rs @@ -705,19 +705,19 @@ impl WasmClient { let loop_pending_requests = pending_requests.clone(); let ping_timer = self.ping_timer.clone(); wasm_bindgen_futures::spawn_local(async move { - let route_frame = Closure::wrap(Box::new(move |frame: JsValue| { - route_incoming_frame(&frame, &on_msg, &subscriptions, &loop_pending_requests); - }) as Box); loop_transport .receive_loop( - route_frame - .as_ref() - .unchecked_ref::() - .clone(), + move |frame: JsValue| { + route_incoming_frame( + &frame, + &on_msg, + &subscriptions, + &loop_pending_requests, + ); + }, on_err.clone(), ) .await; - drop(route_frame); state.set(ConnectionState::Disconnected); stop_ping_timer(&ping_timer); reject_pending_requests(&pending_requests, "disconnected"); diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index df59c30..1bdf39c 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -347,12 +347,15 @@ impl WasmTransport { /// Background loop: deliver every incoming frame to `on_message` until the /// connection closes. Shares reader state with `read_one_frame`, so frames /// buffered during the handshake are not lost. - pub async fn receive_loop(&self, on_message: js_sys::Function, on_error: js_sys::Function) { + pub async fn receive_loop(&self, mut on_message: F, on_error: js_sys::Function) + where + F: FnMut(JsValue), + { loop { match self.next_frame().await { Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) { Ok(parsed) => { - let _ = on_message.call1(&JsValue::NULL, &parsed); + on_message(parsed); } Err(e) => { let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); diff --git a/wasm/types/mtp_wasm.d.ts b/wasm/types/mtp_wasm.d.ts index 4ecec6b..b7ac775 100644 --- a/wasm/types/mtp_wasm.d.ts +++ b/wasm/types/mtp_wasm.d.ts @@ -48,6 +48,7 @@ export class ConnectionConfig implements DisposableWasmObject { free(): void; [Symbol.dispose](): void; client_id: bigint; + description: string | undefined; max_message_size: number; server_certificate_hashes: string[]; readonly url: string;