@msgtrans/client
v0.6.2-beta.0
Published
TypeScript client runtime for the msgtrans wire protocol — browser-first; WebSocket transport, zlib compression, heartbeat/reconnect helpers, experimental WebTransport.
Maintainers
Readme
@msgtrans/client
TypeScript client runtime for the msgtrans wire protocol.
@msgtrans/client is the language mapping of the msgtrans Rust client surface — same Packet semantics, same Request/Response correlation rules, same wire bytes. The wire format is defined by WIRE_FORMAT.md and conformance is verified by a shared cross-language fixture suite.
Status: 0.6.2-beta.0 — Connector/Session SPI with a serialized lifecycle supervisor: per-connection ownership at the public boundary, strict decode, transport-owned message ids, serialized inbound normalization, and working zlib compression (native streams), all fixture- and E2E-verified against the Rust 2.0 server.
Compatibility matrix
| @msgtrans/client | msgtrans (Rust) | Wire protocol | |---|---|---| | 0.2.x – 0.6.x | 2.0.x | v1 | | 0.1.x | 1.0.x / 2.0.x | v1 |
The version numbers deliberately do NOT lock step with the Rust crate: this is a browser, single-connection client, not a full mapping of the Rust server architecture. What IS aligned: the wire format, the request lifecycle, strict decoding, and the error semantics.
Install
npm install @msgtrans/client
# or
pnpm add @msgtrans/clientESM only. Requires Node 18+ for tooling; browsers need native WebSocket (any modern browser).
Quick start (browser)
import { TransportClient } from '@msgtrans/client';
const client = new TransportClient({
url: 'wss://gateway.example.com/ws',
defaultTimeoutMs: 10_000,
});
await client.connect();
console.log(client.isConnected()); // true
// Fire-and-forget OneWay
await client.send(new TextEncoder().encode('hello'), { bizType: 1 });
// Request / Response
const response = await client.request(
new TextEncoder().encode(JSON.stringify({ route: 'user.find', id: '42' })),
{ bizType: 100, timeoutMs: 10_000 },
);
console.log(new TextDecoder().decode(response));
await client.disconnect();Receiving server-pushed messages
client.on('message', (msg) => {
// Server-pushed OneWay event — always plaintext (inbound compression is
// undone before delivery).
handleEvent(msg.bizType, msg.data);
});
client.on('request', async (req) => {
// Server is asking us something; respond with the same messageId.
// Answers at most once, and only onto the connection generation it
// arrived on.
const answer = await handle(req.bizType, req.data);
await req.respond(answer);
});
client.on('messageEnqueued', ({ messageId }) => {
// Fired when a packet was ACCEPTED by the socket's outbound buffer.
// Deliberately NOT called "sent": a browser WebSocket cannot confirm a
// write reached the network, and this API does not pretend it can.
});
client.on('close', (event) => {
console.log('disconnected', event);
});
client.on('error', (e) => {
console.error('transport error', e);
});
// All four return an unsubscribe function:
const off = client.on('message', handler);
off(); // detach laterNode usage
Browsers expose WebSocket globally; in Node you inject the constructor through webSocketFactory:
import { TransportClient, type WebSocketLike } from '@msgtrans/client';
import WS from 'ws';
const client = new TransportClient({
url: 'wss://gateway.example.com/ws',
webSocketFactory: (url, protocols) =>
new WS(url, protocols) as unknown as WebSocketLike,
});The same hook lets you swap in a custom WebSocket (TLS-pinned, telemetry-wrapped, mocked for tests, etc.) without touching the rest of the client.
API surface
new TransportClient({
url: string; // required when using the default WebSocket connector
connector?: TransportConnector; // custom connection factory
protocols?: string | string[];
webSocketFactory?: WebSocketFactory;
onTextFrame?: 'error' | 'ignore'; // default: 'error'
defaultTimeoutMs?: number; // default: 10_000
});
// Lifecycle (state: idle / connecting / open / closing / stopped)
client.connect(): Promise<void>; // single-flight, abortable
client.disconnect(): Promise<void>; // ends the connection; reconnect allowed
client.shutdown(): Promise<void>; // TERMINAL: later connect() is refused
client.isConnected(): boolean;
client.state: TransportState;
// Sending
client.send(payload: Uint8Array, options?: SendOptions): Promise<void>;
client.request(payload: Uint8Array, options?: RequestOptions): Promise<Uint8Array>;
// There is deliberately NO lower-level packet send: message ids belong to
// the transport (a caller-numbered Request could complete a different
// pending waiter that drew the same id — the ABA the Rust 2.0 registry
// closed). `Packet` stays exported for codecs/fixtures/custom connectors.
// Events (each returns an unsubscribe function)
client.on('message', (msg: ClientMessage) => void): () => void;
client.on('request', (req: ClientRequest) => void): () => void;
client.on('messageEnqueued', (info: { messageId: number }) => void): () => void;
client.on('close', (event: TransportCloseEvent) => void): () => void;
client.on('error', (error: unknown) => void): () => void;interface SendOptions {
bizType?: number;
extHeader?: Uint8Array;
compression?: CompressionType; // Zlib works out of the box (native
// streams); Zstd via an injected codec.
// No codec => the send REJECTS — a raw
// payload is never shipped under a
// compressed header.
}
interface RequestOptions extends SendOptions {
timeoutMs?: number; // 0 disables timeout
}ClientMessage (OneWay) exposes payload/data, bizType, messageId, extHeader — and no respond at all: the split is typed, not a runtime flag. ClientRequest adds respond(payload, options?), which answers at most once, reuses the original messageId, and refuses to write onto a later connection generation (a stale response after a reconnect could complete a different server-side request that drew the same id).
Rust ↔ TypeScript mapping
@msgtrans/client is the language mapping of msgtrans::transport::TransportClient. Concept-level alignment is exact; the API surface uses TypeScript-idiomatic shapes (options objects + callback events) but each method has a clear Rust counterpart.
| Rust | TypeScript |
|---|---|
| TransportClientBuilder::new().with_protocol(WebSocketClientConfig::new(url)?).build().await? | new TransportClient({ url }) |
| client.connect().await? | await client.connect() |
| client.disconnect().await? | await client.disconnect() (terminal teardown: client.shutdown()) |
| client.is_connected().await | client.isConnected() (sync) |
| client.send_with_options(bytes, SendOptions::new().biz_type(5)) | client.send(bytes, { bizType: 5 }) |
| client.request_with_options(bytes, RequestOptions::new().timeout(d)) | client.request(bytes, { timeoutMs: 10_000 }) |
| ClientEvent::Message(msg) | client.on('message', msg => ...) |
| ClientEvent::Request(req) | client.on('request', req => ...) |
| ClientEvent::MessageSent { message_id } (write-confirmed) | client.on('messageEnqueued', ...) — enqueue-confirmed: a browser WebSocket offers no write completion, so the honest tier is named for what it proves |
| ClientEvent::Disconnected { reason } | client.on('close', ev => ...) |
| ClientEvent::Error { error } | client.on('error', e => ...) |
| Responder::respond(bytes) / respond_with_options | await req.respond(bytes, options?) |
Things deliberately not mirrored (TS-idiomatic instead):
- No
TransportClientBuilder— options object is more natural in TS - Event delivery is callback-based, not a
subscribe_events()stream — same information, JS-idiomatic shape - Browser
WebSocketcannot send raw ping/pong frames, so Rust'swith_ping_interval/with_pong_timeoutis not exposed; useHeartbeatMonitorwith a caller-supplied probe (any request/response the peer must answer)
Wire format
@msgtrans/client implements the v1 protocol defined in msgtrans/docs/WIRE_FORMAT.md:
- 16-byte fixed header, big-endian
- Three packet types:
OneWay/Request/Response - Compression is REAL, both directions: zlib via native streams by default, Zstd injectable via the codec SPI; inbound bodies are decompressed at the transport boundary (never handed to the app compressed), outbound compression is applied before enqueue
- Each WebSocket binary frame carries exactly one Packet — no length-prefix framing is needed at this layer
Cross-language conformance is enforced: every release runs the same fixture set on both the Rust crate and this package, and asserts byte-for-byte equality in both directions.
Transport scope
| Transport | Browser | Node | Status |
|---|---|---|---|
| WebSocket | ✅ native | ✅ via webSocketFactory (e.g. ws) | supported |
| WebTransport (HTTP/3) | ✅ in modern Chromium / Firefox / Safari | — | experimental — @msgtrans/client/experimental, pending server interop E2E |
| TCP | — | — | not in scope (browser cannot do raw TCP) |
| QUIC (raw) | — | — | not in scope (no browser API) |
WebTransportConnector exists today behind the experimental subpath (Rust-QUIC-mirror framing); it graduates to the root API once a real server interop E2E exists.
Roadmap
- [x] 0.1.0-beta — wire format, WebSocket transport, request/response, server-push handling
- [x] 0.2.0-beta — 2.0 semantic alignment: strict decode +
DecodeLimits, transport-owned ids (no wrap), socket & respond generations, serialized inbound normalization, zlib via native streams, codec SPI (zstd injectable), honest enqueue tier, projected backpressure, API surface shrink - [x] 0.3.0-beta —
HeartbeatMonitor(caller-supplied probe, idle-based),ReconnectManager(backoff + jitter, blunt stop-first contract),WebTransportConnector - [x] 0.4.0-beta — lifecycle hardening: idempotent
connect(), generation-bound + poisoned + bounded inbound normalization, cancel-safe connect/close, sealedClientMessage/ClientRequesthandles, strict config validation. WebTransport moved to@msgtrans/client/experimentaluntil a real server interop E2E exists. - [ ] later — Zstd codec package, WebTransport server interop E2E (needs a server endpoint)
