diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 8a56bbd..02be900 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -104,9 +104,6 @@ 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. | @@ -474,60 +471,6 @@ 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 499c44f..d3c31be 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -10,15 +10,6 @@ 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 { @@ -258,9 +249,7 @@ export interface MTPPublicKeyBundleKeys { sigClPublicKey: Uint8Array; } -export interface MTPClientOptions< - Registry extends MTPSchemaRegistry = MTPNoSchemas, -> { +export interface MTPClientOptions { url: string; descriptor?: string; hostPublicKey?: MTPKeyMaterialInput; @@ -293,12 +282,6 @@ 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 { @@ -579,8 +562,8 @@ export interface MTPAcceptEncryptedPipeOptions { signaturePolicy?: MTPSignatureVerificationPolicy; } -type NormalizedMTPClientOptions = Omit< - MTPClientOptions, +type NormalizedMTPClientOptions = Omit< + MTPClientOptions, "hostPublicKey" | "receiveLimits" > & { hostPublicKey?: Uint8Array; @@ -931,34 +914,14 @@ 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; - readonly #protocol: MTPProtocol | undefined; + #options: NormalizedMTPClientOptions; readonly #protectedReplayGuard = new InMemoryReplayGuard(); readonly #relayReplayGuard = new InMemoryReplayGuard(); readonly raw: MTPRaw; @@ -971,17 +934,10 @@ 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 = @@ -991,11 +947,7 @@ export class MTPClient { ); } - static async create< - const Registry extends MTPSchemaRegistry = MTPNoSchemas, - >( - options: MTPClientOptions, - ): Promise> { + static async create(options: MTPClientOptions): Promise { validateOptions(options); await MTPClient.init(options.wasm); @@ -1016,7 +968,7 @@ export class MTPClient { securityProfile: resolveSecurityProfile(options), }; - let sdk: MTPClient | undefined; + let sdk: MTPClient | undefined; const client = new WasmClient( (state) => emit(normalizedOptions.logger, { @@ -1052,7 +1004,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 = { @@ -1286,35 +1238,6 @@ 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 ( @@ -1356,11 +1279,6 @@ export class MTPClient { } async send(message: Uint8Array): Promise; - async send>( - type: Type, - data?: MTPRequestData, - options?: MTPSendOptions, - ): Promise; async send( type: MTPCommunicationType, data: Record, @@ -1368,14 +1286,10 @@ export class MTPClient { ): Promise; async send( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: unknown, + data?: Record, options?: MTPSendOptions, ): Promise { - const parsedData = - typeof typeOrFrame === "string" - ? await this.#parseRequestData(typeOrFrame, data) - : data; - const message = this.#buildFrame(typeOrFrame, parsedData, options); + const message = this.#buildFrame(typeOrFrame, data, options); try { const frame = this.raw.bindings.parse_frame(message); @@ -1431,11 +1345,6 @@ export class MTPClient { data?: never, options?: MTPRequestOptions, ): Promise; - async request>( - type: Type, - data?: MTPRequestData, - options?: MTPRequestOptions, - ): Promise>; async request( type: MTPCommunicationType, data: Record, @@ -1443,19 +1352,15 @@ export class MTPClient { ): Promise; async request( typeOrFrame: Uint8Array | MTPCommunicationType, - data?: unknown, + data?: Record, 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 parsedData = - typeof typeOrFrame === "string" - ? await this.#parseRequestData(typeOrFrame, data) - : data; - const frame = this.#buildFrame(typeOrFrame, parsedData, options); + const frame = this.#buildFrame(typeOrFrame, data, options); try { const parsed = this.raw.bindings.parse_frame(frame); emit( @@ -1486,25 +1391,16 @@ 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. - const response = await this.raw.client.request( + return 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: MTPFrame) => void | Promise, + handler: (message: ParsedFrame) => void, ): Unsubscribe { if (typeof type !== "string" || !type) { throw new TypeError("subscription type must be a non-empty string"); @@ -1512,25 +1408,8 @@ export class MTPClient { if (typeof handler !== "function") { throw new TypeError("subscription handler must be a function"); } - 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); - }; + const id = this.raw.client.subscribe(type, handler); + return () => this.raw.client.unsubscribe(id); } #handleFrame(frame) { diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 5e3a1e9..e93ca7e 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -5,4 +5,3 @@ * 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 deleted file mode 100644 index 9b9aaf6..0000000 --- a/src/sdk/schema.ts +++ /dev/null @@ -1,234 +0,0 @@ -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(); - }; - } -}