Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 3m19s

This commit is contained in:
Alex Emmet 2026-07-03 16:43:47 +02:00
commit eff96ed72f
7 changed files with 87 additions and 19 deletions

View file

@ -62,9 +62,7 @@ const client = await MTPClient.create({
client.subscribe("SomeType", (message) => console.log(message)); client.subscribe("SomeType", (message) => console.log(message));
const clientId = client.credentials?.clientId == null const clientId = await client.auth();
? await client.register()
: (await client.connect(), client.credentials.clientId);
await client.send("SomeType", { value: "hello" }); await client.send("SomeType", { value: "hello" });
console.log("Connected MTP client", clientId, client.state); 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 `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 ## Getting Started
Add the `mtp` crate with your desired features: Add the `mtp` crate with your desired features:

View file

@ -258,6 +258,16 @@ Raw message helpers that remain available include:
- `format_frame(frame)` - `format_frame(frame)`
- `parse_auth_response(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: Raw crypto and key helpers include:
- `ed25519_generate()` - `ed25519_generate()`

View file

@ -199,10 +199,7 @@ async function connect() {
log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received");
}); });
const existingClientId = client.credentials?.clientId; const activeClientId = await client.auth();
const activeClientId = existingClientId == null
? await client.register()
: (await client.connect(), BigInt(existingClientId));
clientId = activeClientId; clientId = activeClientId;
loadKeys(); loadKeys();
log(`Connected as client ${activeClientId}`); log(`Connected as client ${activeClientId}`);

View file

@ -75,6 +75,36 @@ export interface MTPRaw {
export type MTPBytesInput = Uint8Array | number[]; export type MTPBytesInput = Uint8Array | number[];
export interface MTPCodecOptions {
id?: number;
sender?: bigint | number;
receiver?: bigint | number;
}
export interface MTPCodec {
encode(type: MTPCommunicationType, data: Record<string, unknown>, options?: MTPCodecOptions): Uint8Array;
decode(frame: MTPBytesInput): ParsedFrame;
format(frame: MTPBytesInput): string;
}
export function encode(type: MTPCommunicationType, data: Record<string, unknown>, 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 { export interface MTPCredentials {
clientId: bigint | string | number | null; clientId: bigint | string | number | null;
keyring: MTPBytesInput; keyring: MTPBytesInput;
@ -93,6 +123,7 @@ export interface MTPClientCredentials {
export interface MTPClientOptions { export interface MTPClientOptions {
url: string; url: string;
descriptor?: string;
hostPublicKey?: MTPBytesInput | string; hostPublicKey?: MTPBytesInput | string;
credentials?: MTPCredentials | string | null; credentials?: MTPCredentials | string | null;
credentialsStorageKey?: string; credentialsStorageKey?: string;
@ -128,6 +159,7 @@ type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
}; };
const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; const DEFAULT_CREDENTIALS_KEY = "mtp:credentials";
let wasmInitPromise: Promise<Awaited<ReturnType<typeof initWasm>>> | undefined;
function emit(logger, event) { function emit(logger, event) {
if (typeof logger === "function") { if (typeof logger === "function") {
@ -295,6 +327,9 @@ function validateOptions(options) {
if (typeof options.url !== "string" || !options.url.trim()) { if (typeof options.url !== "string" || !options.url.trim()) {
throw new TypeError("MTPClient.create requires a non-empty url"); 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) { if (options.storage) {
for (const method of ["getItem", "setItem", "removeItem"]) { for (const method of ["getItem", "setItem", "removeItem"]) {
if (typeof options.storage[method] !== "function") { if (typeof options.storage[method] !== "function") {
@ -330,12 +365,14 @@ async function withTimeout(promise, timeoutMs, message) {
export class MTPClient { export class MTPClient {
static readonly crypto = crypto; static readonly crypto = crypto;
static readonly codec = codec;
#credentials: InternalCredentials | null; #credentials: InternalCredentials | null;
#options: NormalizedMTPClientOptions; #options: NormalizedMTPClientOptions;
readonly raw: MTPRaw; readonly raw: MTPRaw;
readonly crypto = MTPClient.crypto; readonly crypto = MTPClient.crypto;
readonly codec = MTPClient.codec;
private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) { private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) {
this.#options = options; this.#options = options;
@ -394,7 +431,8 @@ export class MTPClient {
} }
static async init(wasm?: MTPClientOptions["wasm"]): Promise<Awaited<ReturnType<typeof initWasm>>> { static async init(wasm?: MTPClientOptions["wasm"]): Promise<Awaited<ReturnType<typeof initWasm>>> {
return await initWasm(wasm); wasmInitPromise ??= initWasm(wasm);
return await wasmInitPromise;
} }
get credentials(): MTPClientCredentials | null { get credentials(): MTPClientCredentials | null {
@ -425,12 +463,15 @@ export class MTPClient {
if (this.#options.maxMessageSize != null) { if (this.#options.maxMessageSize != null) {
config.max_message_size = this.#options.maxMessageSize; config.max_message_size = this.#options.maxMessageSize;
} }
if (this.#options.descriptor != null) {
config.description = this.#options.descriptor;
}
return config; return config;
} }
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { if (this.#credentials?.clientId != null && this.#options.hostPublicKey) {
await this.#connectAuthenticated(); await this.auth();
return; return;
} }
@ -447,6 +488,15 @@ export class MTPClient {
} }
} }
async auth(): Promise<bigint> {
if (!this.#options.hostPublicKey) {
throw new Error("MTPClient.auth requires hostPublicKey");
}
return this.#credentials?.clientId == null
? await this.register()
: await this.#connectAuthenticated();
}
async #connectAuthenticated() { async #connectAuthenticated() {
if (!this.#options.hostPublicKey) { if (!this.#options.hostPublicKey) {
throw new Error("MTPClient.connect requires hostPublicKey for authenticated connections"); throw new Error("MTPClient.connect requires hostPublicKey for authenticated connections");

View file

@ -705,19 +705,19 @@ impl WasmClient {
let loop_pending_requests = pending_requests.clone(); let loop_pending_requests = pending_requests.clone();
let ping_timer = self.ping_timer.clone(); let ping_timer = self.ping_timer.clone();
wasm_bindgen_futures::spawn_local(async move { 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<dyn FnMut(JsValue)>);
loop_transport loop_transport
.receive_loop( .receive_loop(
route_frame move |frame: JsValue| {
.as_ref() route_incoming_frame(
.unchecked_ref::<js_sys::Function>() &frame,
.clone(), &on_msg,
&subscriptions,
&loop_pending_requests,
);
},
on_err.clone(), on_err.clone(),
) )
.await; .await;
drop(route_frame);
state.set(ConnectionState::Disconnected); state.set(ConnectionState::Disconnected);
stop_ping_timer(&ping_timer); stop_ping_timer(&ping_timer);
reject_pending_requests(&pending_requests, "disconnected"); reject_pending_requests(&pending_requests, "disconnected");

View file

@ -347,12 +347,15 @@ impl WasmTransport {
/// Background loop: deliver every incoming frame to `on_message` until the /// Background loop: deliver every incoming frame to `on_message` until the
/// connection closes. Shares reader state with `read_one_frame`, so frames /// connection closes. Shares reader state with `read_one_frame`, so frames
/// buffered during the handshake are not lost. /// 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<F>(&self, mut on_message: F, on_error: js_sys::Function)
where
F: FnMut(JsValue),
{
loop { loop {
match self.next_frame().await { match self.next_frame().await {
Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) { Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) {
Ok(parsed) => { Ok(parsed) => {
let _ = on_message.call1(&JsValue::NULL, &parsed); on_message(parsed);
} }
Err(e) => { Err(e) => {
let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); let message = e.as_string().unwrap_or_else(|| format!("{:?}", e));

View file

@ -48,6 +48,7 @@ export class ConnectionConfig implements DisposableWasmObject {
free(): void; free(): void;
[Symbol.dispose](): void; [Symbol.dispose](): void;
client_id: bigint; client_id: bigint;
description: string | undefined;
max_message_size: number; max_message_size: number;
server_certificate_hashes: string[]; server_certificate_hashes: string[];
readonly url: string; readonly url: string;