From eb18629209e93086f5a371cd9dad46f8c409de3f Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 27 Aug 2026 18:00:58 +0300 Subject: [PATCH 1/3] Update dependency jscpd to v5.0.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd4e7a1..55bf1e3 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ }, "devDependencies": { "@types/node": "^26.0.1", - "jscpd": "5.0.14", + "jscpd": "5.0.15", "typescript": "^7.0.0" }, "dependencies": { From bd5547ae6f631502ed38082428002ff0a5b006c3 Mon Sep 17 00:00:00 2001 From: Alois Date: Thu, 27 Aug 2026 19:29:53 +0200 Subject: [PATCH 2/3] feat(ts-sdk): add schemas --- docs/WASM-CLIENT.md | 57 +++++++++++ src/sdk/client.ts | 157 +++++++++++++++++++++++++---- src/sdk/index.ts | 1 + src/sdk/schema.ts | 234 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 src/sdk/schema.ts diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 02be900..8a56bbd 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -104,6 +104,9 @@ if (!MTPClient.isSupported()) { | `requestTimeoutMs` | 30 seconds | Default `request()` timeout. | | `pings` | `false` | Protocol pings, or an object with `intervalMs`. | | `logger` | No-op | Receives SDK state and error events. | +| `schemas` | None | Client-wide request and response schema registry. | +| `throwProtocolErrors` | `false` | Reject requests whose correlated response is an `Error*` frame. | +| `onValidationError` | No-op | Receives subscription validation failures. | | `sessionStorage` | In-memory | E2EE session state storage. | | `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. | | `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. | @@ -471,6 +474,60 @@ const unsubscribe = client.subscribe("SomeType", (message) => { unsubscribe(); ``` +### Zod request and response schemas + +Applications can provide their request and response schemas once when creating +the client. MTP uses `parseAsync`, so synchronous schemas, async refinements, +defaults, coercions, and transforms all work. MTP has no runtime dependency on +Zod; the application supplies its preferred Zod version. + +```typescript +import { z } from "zod"; +import { MTPClient, MTPValidationError } from "mtp"; + +const schemas = { + GetUser: { + request: z.object({ UserId: z.number().int().positive() }), + response: z.object({ + UserId: z.number().int().positive(), + Display: z.string(), + }), + }, +}; + +const client = await MTPClient.create({ + url, + schemas, + throwProtocolErrors: true, + onValidationError(error) { + console.error(error.messageType, error.cause); + }, +}); + +const response = await client.request("GetUser", { UserId: 42 }); +console.log(response.data.Display); +``` + +Request schemas run before frame encoding and transmission. Their transformed +output is sent. Response schemas run after request correlation, and their +transformed output replaces `frame.data`; `frame.raw`, when present, remains the +original wire frame. Invalid requests and responses reject with +`MTPValidationError`. Invalid subscription messages do not reach the handler +and are reported through `onValidationError`. + +`throwProtocolErrors: true` converts correlated `Error*` frames into +`MTPProtocolError`. It defaults to `false` for compatibility. + +`MTPProxyConnection` applies the same schema registry to another TypeScript +request/subscription transport, such as a Tauri command and event proxy: + +```typescript +const connection = new MTPProxyConnection(adapter, { + schemas, + throwProtocolErrors: true, +}); +``` + Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is: ```typescript diff --git a/src/sdk/client.ts b/src/sdk/client.ts index d3c31be..499c44f 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -10,6 +10,15 @@ import * as bindings from "mtp/raw"; import { unixTimeMillis, utf8Encode } from "./utils.js"; import type * as RawBindings from "../raw/index"; import type { MTPCommunicationType } from "../type-map/index"; +import { MTPProtocol } from "./schema.js"; +import type { + MTPMessageType, + MTPFrame, + MTPNoSchemas, + MTPRequestData, + MTPResponseFrame, + MTPSchemaRegistry, +} from "./schema.js"; import type { MTPSessionStorage, MTPSessionState } from "./session"; import { MTPSessionManager } from "./session.js"; import { @@ -249,7 +258,9 @@ export interface MTPPublicKeyBundleKeys { sigClPublicKey: Uint8Array; } -export interface MTPClientOptions { +export interface MTPClientOptions< + Registry extends MTPSchemaRegistry = MTPNoSchemas, +> { url: string; descriptor?: string; hostPublicKey?: MTPKeyMaterialInput; @@ -282,6 +293,12 @@ export interface MTPClientOptions { securityProfile?: MTPSecurityProfile; /** One receive resource policy shared by frame and protected-value opening. */ receiveLimits?: MTPReceiveLimits; + /** Application request and response schemas, keyed by communication type. */ + schemas?: Registry; + /** Reject `request()` when the correlated response is an `Error*` frame. */ + throwProtocolErrors?: boolean; + /** Receives subscription validation failures. Request failures reject normally. */ + onValidationError?: (error: import("./schema.js").MTPValidationError) => void; } export interface MTPSecurityProfile { @@ -562,8 +579,8 @@ export interface MTPAcceptEncryptedPipeOptions { signaturePolicy?: MTPSignatureVerificationPolicy; } -type NormalizedMTPClientOptions = Omit< - MTPClientOptions, +type NormalizedMTPClientOptions = Omit< + MTPClientOptions, "hostPublicKey" | "receiveLimits" > & { hostPublicKey?: Uint8Array; @@ -914,14 +931,34 @@ function validateOptions(options) { ) { throw new TypeError("requestTimeoutMs must be a positive safe integer"); } + if (options.schemas != null) { + if (typeof options.schemas !== "object" || Array.isArray(options.schemas)) { + throw new TypeError("schemas must be an object"); + } + for (const [type, pair] of Object.entries(options.schemas)) { + if ( + !pair || + typeof pair !== "object" || + typeof (pair as { request?: { parseAsync?: unknown } }).request + ?.parseAsync !== "function" || + typeof (pair as { response?: { parseAsync?: unknown } }).response + ?.parseAsync !== "function" + ) { + throw new TypeError( + `schemas.${type} must contain request and response schemas with parseAsync()`, + ); + } + } + } } -export class MTPClient { +export class MTPClient { static readonly crypto = crypto; static readonly codec = codec; #credentials: InternalCredentials | null; - #options: NormalizedMTPClientOptions; + #options: NormalizedMTPClientOptions; + readonly #protocol: MTPProtocol | undefined; readonly #protectedReplayGuard = new InMemoryReplayGuard(); readonly #relayReplayGuard = new InMemoryReplayGuard(); readonly raw: MTPRaw; @@ -934,10 +971,17 @@ export class MTPClient { readonly encryptedSecretProvider: MTPEncryptedSecretProvider; private constructor( - options: NormalizedMTPClientOptions, + options: NormalizedMTPClientOptions, client: RawBindings.WasmClient, ) { this.#options = options; + this.#protocol = options.schemas + ? new MTPProtocol({ + schemas: options.schemas, + throwProtocolErrors: options.throwProtocolErrors, + onValidationError: options.onValidationError, + }) + : undefined; this.#credentials = deserializeCredentials(options.credentials); this.raw = { client, bindings }; this.encryptedSecretProvider = @@ -947,7 +991,11 @@ export class MTPClient { ); } - static async create(options: MTPClientOptions): Promise { + static async create< + const Registry extends MTPSchemaRegistry = MTPNoSchemas, + >( + options: MTPClientOptions, + ): Promise> { validateOptions(options); await MTPClient.init(options.wasm); @@ -968,7 +1016,7 @@ export class MTPClient { securityProfile: resolveSecurityProfile(options), }; - let sdk: MTPClient | undefined; + let sdk: MTPClient | undefined; const client = new WasmClient( (state) => emit(normalizedOptions.logger, { @@ -1004,7 +1052,7 @@ export class MTPClient { setReceiveLimits.call(rawClient, normalizedOptions.receiveLimits); } - sdk = new MTPClient(normalizedOptions, client); + sdk = new MTPClient(normalizedOptions, client); await sdk.#loadStoredCredentials(); if (!sdk.#credentials) { sdk.#credentials = { @@ -1238,6 +1286,35 @@ export class MTPClient { }; } + async #parseRequestData( + type: MTPCommunicationType, + data: unknown, + ): Promise> { + if (!this.#protocol || !this.#protocol.schemas[type]) { + return (data ?? {}) as Record; + } + const parsed = await this.#protocol.parseRequest( + type as MTPMessageType, + data as never, + ); + return (parsed ?? {}) as Record; + } + + async #parseResponseData( + requestedType: MTPCommunicationType, + frame: ParsedFrame, + phase: "response" | "subscription" = "response", + ): Promise> { + if (!this.#protocol || !this.#protocol.schemas[requestedType]) { + return frame; + } + return await this.#protocol.parseResponse( + requestedType as MTPMessageType, + frame, + phase, + ); + } + #buildFrame(typeOrFrame, data, options) { if (typeOrFrame instanceof Uint8Array) { if ( @@ -1279,6 +1356,11 @@ export class MTPClient { } async send(message: Uint8Array): Promise; + async send>( + type: Type, + data?: MTPRequestData, + options?: MTPSendOptions, + ): Promise; async send( type: MTPCommunicationType, data: Record, @@ -1286,10 +1368,14 @@ export class MTPClient { ): Promise; async send( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, + data?: unknown, options?: MTPSendOptions, ): Promise { - const message = this.#buildFrame(typeOrFrame, data, options); + const parsedData = + typeof typeOrFrame === "string" + ? await this.#parseRequestData(typeOrFrame, data) + : data; + const message = this.#buildFrame(typeOrFrame, parsedData, options); try { const frame = this.raw.bindings.parse_frame(message); @@ -1345,6 +1431,11 @@ export class MTPClient { data?: never, options?: MTPRequestOptions, ): Promise; + async request>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, + ): Promise>; async request( type: MTPCommunicationType, data: Record, @@ -1352,15 +1443,19 @@ export class MTPClient { ): Promise; async request( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: Record, + data?: unknown, options: MTPRequestOptions = {}, - ): Promise { + ): Promise> { const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { throw new TypeError("request timeoutMs must be a positive safe integer"); } - const frame = this.#buildFrame(typeOrFrame, data, options); + const parsedData = + typeof typeOrFrame === "string" + ? await this.#parseRequestData(typeOrFrame, data) + : data; + const frame = this.#buildFrame(typeOrFrame, parsedData, options); try { const parsed = this.raw.bindings.parse_frame(frame); emit( @@ -1391,16 +1486,25 @@ export class MTPClient { // The WASM client owns request expiry and its late-response tombstones. // Keeping a second Promise timer here can reject the SDK call while the // protocol request is still allowed to complete successfully. - return await this.raw.client.request( + const response = await this.raw.client.request( frame, options.responseType ?? null, timeoutMs, ); + return typeof typeOrFrame === "string" + ? await this.#parseResponseData(typeOrFrame, response) + : response; } + subscribe>( + type: Type, + handler: ( + message: MTPResponseFrame, + ) => void | Promise, + ): Unsubscribe; subscribe( type: MTPCommunicationType, - handler: (message: ParsedFrame) => void, + handler: (message: MTPFrame) => void | Promise, ): Unsubscribe { if (typeof type !== "string" || !type) { throw new TypeError("subscription type must be a non-empty string"); @@ -1408,8 +1512,25 @@ export class MTPClient { 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); + let active = true; + const id = this.raw.client.subscribe(type, (message) => { + if (!this.#protocol || !this.#protocol.schemas[type]) { + void handler(message); + return; + } + void this.#parseResponseData(type, message, "subscription").then( + (parsed) => { + if (active) void handler(parsed); + }, + (error) => { + this.#protocol?.reportValidationError(error); + }, + ); + }); + return () => { + active = false; + this.raw.client.unsubscribe(id); + }; } #handleFrame(frame) { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index e93ca7e..5e3a1e9 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -5,3 +5,4 @@ * keeps the package's historical exports stable. */ export * from "./client.js"; +export * from "./schema.js"; diff --git a/src/sdk/schema.ts b/src/sdk/schema.ts new file mode 100644 index 0000000..9b9aaf6 --- /dev/null +++ b/src/sdk/schema.ts @@ -0,0 +1,234 @@ +import type { MTPRequestOptions, ParsedFrame, Unsubscribe } from "./client.js"; +import type { MTPCommunicationType } from "../type-map/index.js"; + +export interface MTPSchema { + readonly _input: Input; + readonly _output: Output; + parseAsync(value: unknown): Promise; +} + +export interface MTPSchemaPair< + Request extends MTPSchema = MTPSchema, + Response extends MTPSchema = MTPSchema, +> { + request: Request; + response: Response; +} + +export type MTPSchemaRegistry = Record; +export type MTPNoSchemas = Record; + +export type MTPSchemaInput = Schema["_input"]; +export type MTPSchemaOutput = Schema["_output"]; +export type MTPMessageType = + keyof Registry & string; + +export type MTPFrame = { + id?: number; + type: string; + data: Data; + sender?: ParsedFrame["sender"]; + receiver?: ParsedFrame["receiver"]; + raw?: ParsedFrame["raw"]; +}; + +export type MTPTypedFrame = MTPFrame; + +export type MTPResponseFrame< + Registry extends MTPSchemaRegistry, + Type extends MTPMessageType, +> = MTPTypedFrame>; + +export type MTPRequestData< + Registry extends MTPSchemaRegistry, + Type extends MTPMessageType, +> = MTPSchemaInput; + +export type MTPRequestFunction = < + Type extends MTPMessageType, +>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, +) => Promise>; + +export type MTPSubscriptionFunction = < + Type extends MTPMessageType, +>( + type: Type, + handler: (message: MTPResponseFrame) => void | Promise, +) => Unsubscribe; + +export class MTPValidationError extends Error { + readonly phase: "request" | "response" | "subscription"; + readonly messageType: string; + readonly frame?: MTPFrame; + + constructor( + phase: MTPValidationError["phase"], + messageType: string, + cause: unknown, + frame?: MTPFrame, + ) { + super(`${phase} validation failed for ${messageType}`, { cause }); + this.name = "MTPValidationError"; + this.phase = phase; + this.messageType = messageType; + this.frame = frame; + } +} + +export class MTPProtocolError extends Error { + readonly type: string; + readonly id: number | undefined; + readonly communicationType: string; + readonly requestId: number | undefined; + readonly errorType: string | undefined; + readonly frame: MTPFrame; + + constructor(frame: MTPFrame) { + const errorType = + frame.data && + typeof frame.data === "object" && + !Array.isArray(frame.data) && + typeof (frame.data as Record).ErrorType === "string" + ? ((frame.data as Record).ErrorType as string) + : undefined; + super(errorType ? `${frame.type}: ${errorType}` : frame.type); + this.name = "MTPProtocolError"; + this.type = frame.type; + this.id = frame.id; + this.communicationType = frame.type; + this.requestId = frame.id; + this.errorType = errorType; + this.frame = frame; + } +} + +export interface MTPProtocolOptions { + schemas: Registry; + throwProtocolErrors?: boolean; + onValidationError?: (error: MTPValidationError) => void; +} + +function isErrorFrame(frame: MTPFrame): boolean { + return frame.type.startsWith("Error"); +} + +export class MTPProtocol { + readonly schemas: Registry; + readonly #throwProtocolErrors: boolean; + readonly #onValidationError: + | ((error: MTPValidationError) => void) + | undefined; + + constructor(options: MTPProtocolOptions) { + this.schemas = options.schemas; + this.#throwProtocolErrors = options.throwProtocolErrors ?? false; + this.#onValidationError = options.onValidationError; + } + + async parseRequest>( + type: Type, + data: MTPRequestData | undefined, + ): Promise> { + try { + return await this.schemas[type].request.parseAsync(data); + } catch (error) { + throw new MTPValidationError("request", type, error); + } + } + + async parseResponse>( + requestedType: Type, + frame: MTPFrame, + phase: "response" | "subscription" = "response", + ): Promise> { + if (isErrorFrame(frame)) { + if (phase === "response" && this.#throwProtocolErrors) { + throw new MTPProtocolError(frame); + } + return frame as MTPResponseFrame; + } + + const schema = + this.schemas[frame.type]?.response ?? + this.schemas[requestedType].response; + try { + const data = await schema.parseAsync(frame.data); + return { ...frame, data } as MTPResponseFrame; + } catch (error) { + throw new MTPValidationError( + phase, + frame.type || requestedType, + error, + frame, + ); + } + } + + reportValidationError(error: unknown): void { + if (error instanceof MTPValidationError) { + this.#onValidationError?.(error); + } + } +} + +export interface MTPProxyAdapter { + request( + type: MTPCommunicationType, + data: Record, + options?: MTPRequestOptions, + ): Promise; + subscribe( + type: MTPCommunicationType, + handler: (message: MTPFrame) => void, + ): Unsubscribe; +} + +export class MTPProxyConnection { + readonly #adapter: MTPProxyAdapter; + readonly #protocol: MTPProtocol; + + constructor(adapter: MTPProxyAdapter, options: MTPProtocolOptions) { + this.#adapter = adapter; + this.#protocol = new MTPProtocol(options); + } + + async request>( + type: Type, + data?: MTPRequestData, + options?: MTPRequestOptions, + ): Promise> { + const parsed = await this.#protocol.parseRequest(type, data); + const response = await this.#adapter.request( + type, + (parsed ?? {}) as Record, + options, + ); + return await this.#protocol.parseResponse(type, response); + } + + subscribe>( + type: Type, + handler: ( + message: MTPResponseFrame, + ) => void | Promise, + ): Unsubscribe { + let active = true; + const unsubscribe = this.#adapter.subscribe(type, (message) => { + void this.#protocol.parseResponse(type, message, "subscription").then( + (parsed) => { + if (active) void handler(parsed); + }, + (error) => { + this.#protocol.reportValidationError(error); + }, + ); + }); + return () => { + active = false; + unsubscribe(); + }; + } +} From 697f746035a73ef0675e437060797c25f361efdb Mon Sep 17 00:00:00 2001 From: Rasensprenger Date: Thu, 27 Aug 2026 21:01:26 +0300 Subject: [PATCH 3/3] Update dependency jscpd to v5.0.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd4e7a1..55bf1e3 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ }, "devDependencies": { "@types/node": "^26.0.1", - "jscpd": "5.0.14", + "jscpd": "5.0.15", "typescript": "^7.0.0" }, "dependencies": {