Typed Clients
A WebSocketContract or a ServerSentEvents({...}) class isn't only a server-side declaration — it's a
schema map, and a schema map is exactly what a client needs to validate what it sends and receives.
The @heximon/client/ws and @heximon/client/sse subpaths give you clients that type themselves off the same
class your server binds to: connect, get a fully typed send/on or subscribe/stream surface, and catch
a schema mismatch at the call site instead of in production.
Both clients are frontend-safe: no node: imports, and no runtime dependency on @heximon/runtime,
@heximon/websocket, or @heximon/sse — the contract's schema types are import type only and erase at
build time, so shipping the client to the browser never pulls the server package along.
pnpm add @heximon/client
# @heximon/websocket / @heximon/sse are optional peers — install them only if you pass
# a WebSocketContract / ServerSentEvents class (rather than a raw schema map) as the type source
npm install @heximon/client
# @heximon/websocket / @heximon/sse are optional peers — install them only if you pass
# a WebSocketContract / ServerSentEvents class (rather than a raw schema map) as the type source
yarn add @heximon/client
# @heximon/websocket / @heximon/sse are optional peers — install them only if you pass
# a WebSocketContract / ServerSentEvents class (rather than a raw schema map) as the type source
bun add @heximon/client
# @heximon/websocket / @heximon/sse are optional peers — install them only if you pass
# a WebSocketContract / ServerSentEvents class (rather than a raw schema map) as the type source
Typed WebSocket client (@heximon/client/ws)
Connect and exchange messages
WsClient<Contract> is generic over a WebSocketContract subclass (or a raw WebSocketContractLike pair).
Construct it with a transport and the contract, then connect to get a live WsConnection.
import { WsClient, WsTransport } from "@heximon/client/ws";
import { ChatContract } from "@my-org/api-contract"; // the shared contract package
const client = new WsClient(
WsTransport.native({ baseUrl: "wss://api.example.com" }),
new ChatContract(),
);
const conn = client.connect("/ws/chat/room-42");
conn.onOpen(() => console.log("connected"));
conn.on("broadcast", (data) => {
// data is typed: { id: string; text: string }
console.log(`${data.id}: ${data.text}`);
});
conn.send("message", { text: "hello" }); // validated against clientToServer["message"]
conn.close();
connect(path) returns a WsConnection<Contract> immediately in "connecting" state — register onOpen to
know when the handshake completes.
ChatContract above declares no path, so connect takes the concrete path string. A contract that does
own a path (declared with WebSocketContract({ path, ... })) takes different arguments, shaped by what it
declared: no argument at all for a static path, or a typed parameter record for a parameterised one —
connect()'s signature is generated from the contract, so a typo in a parameter name is a compile error, not
a runtime 404.
WsClient:const conn = new ChatContract(WsTransport.native({ baseUrl: "wss://api.example.com" })).connect(
"/ws/chat/room-42",
);
// conn.on("broadcast", …) / conn.send("message", …) — identical to the WsClient form above.
SeatMapContract, declared with
path: "/ws/events/:eventId/seats"), the same shorthand takes the typed parameter record instead of a path
string — connect interpolates it into the declared template:const conn = new SeatMapContract(WsTransport.native({ baseUrl: "wss://api.example.com" })).connect({
eventId: "evt-42",
});
// → connects to wss://api.example.com/ws/events/evt-42/seats
new WsClient(port, contract) stays as the explicit low-level form; both build the same WsConnection.Validation and errors
| Path | On failure |
|---|---|
conn.send(kind, data) — outbound | throws WsValidationError; nothing sent |
inbound on(kind) dispatch | onError(WsValidationError); frame dropped; connection stays open |
| inbound unknown kind | onError(WsUnknownKindError); frame dropped; connection stays open |
| non-JSON or malformed envelope | onError(WsClientError); frame dropped; connection stays open |
send validates synchronously and throws rather than returning a promise: there's no backpressure signal to
await on a raw WebSocket.send, so a failure has to surface at the call site instead of silently vanishing
into an unresolved promise. The connection never closes on a validation error — only on a manual
conn.close() or after the reconnect budget is exhausted.
Reconnect
The client reconnects automatically per the port's reconnect policy. The default is exponential back-off
capped at 30 s (WsReconnect.exponential). Use WsReconnect.none for a single attempt:
import { WsTransport, WsReconnect } from "@heximon/client/ws";
const port = WsTransport.native({
baseUrl: "wss://api.example.com",
reconnect: WsReconnect.none,
});
Query parameters
Pass a query factory on the port to inject auth tickets or other per-connection parameters. It is re-evaluated
on every connection attempt, so a rotating token is always fresh:
WsTransport.native({
baseUrl: "wss://api.example.com",
query: () => ({ ticket: getTicket() }),
});
Browser WebSockets cannot set arbitrary headers at connect time, so a query param is the cross-origin auth pattern (mint a short-lived ticket with a normal authenticated HTTP call, then pass it here).
Testing with a mock transport
import { WsClient, WsTransport } from "@heximon/client/ws";
const port = WsTransport.mock((url, protocols) => {
// return a WebSocket-compatible mock object; no real network I/O
return new MockWebSocket(url, protocols) as unknown as WebSocket;
});
const conn = new WsClient(port, new ChatContract()).connect("/ws/chat/test");
WsTransport.mock(factory) injects any WebSocket-compatible object so unit tests can assert typed dispatch,
validation errors, and reconnect behavior without a running server.
Typed SSE client (@heximon/client/sse)
Build a port and construct the client
SseTransport.fetch is the recommended default — it can attach auth headers cross-origin, and it surfaces
a non-2xx status as a typed SseConnectionError rather than silently retrying like the native EventSource.
import { SseClient, SseTransport } from "@heximon/client/sse";
import { FeedEvents } from "@my-org/api-contract"; // a ServerSentEvents({...}) class
const client = new SseClient(
SseTransport.fetch({
baseUrl: "https://api.example.com",
headers: () => ({ authorization: `Bearer ${getToken()}` }),
}),
FeedEvents,
);
Pass a raw EventSchemaMap instead of a ServerSentEvents class when you have no shared contract package:
import * as v from "valibot";
const client = new SseClient(
SseTransport.fetch({ baseUrl: "https://api.example.com" }),
{
"order.placed": v.object({ orderId: v.string(), amount: v.number() }),
"order.shipped": v.object({ orderId: v.string(), carrier: v.string() }),
} as const,
);
Subscribe with a callback
subscribe opens the connection, validates each frame, and delivers events to onEvent. It returns an
SseSubscription — call unsubscribe() to close at any time.
const sub = client.subscribe("/orders/feed", {
onEvent(e) {
if (e.event === "order.placed") {
console.log(e.data.orderId); // typed from the schema
}
if (e.event === "order.shipped") {
console.log(e.data.carrier);
}
},
onError(err) {
// SseValidationError, SseUnknownEventError, SseConnectionError
// Each leaves the connection open — only a SseConnectionError after all
// reconnect attempts have been exhausted causes the stream to close.
console.error(err.message);
},
onClose() {
console.log("stream closed");
},
});
// later:
sub.unsubscribe();
Stream with an async generator
stream yields one typed event per iteration. Terminate by returning from the loop, or by aborting a signal.
const controller = new AbortController();
for await (const event of client.stream("/orders/feed", { signal: controller.signal })) {
if (event.event === "order.placed") {
console.log(event.data.orderId);
}
}
Validation errors drop the individual frame but do not terminate the generator — only a connection failure that exhausts all reconnect attempts ends the loop.
Resume from a last event id
Pass lastEventId to resume from a known position after a reload or a dropped connection. The client sends it as
the Last-Event-ID header in fetch mode (the server's resumeFrom receives it), or as
?lastEventId=<id> in eventSource mode (the EventSource API cannot set custom headers).
const sub = client.subscribe("/orders/feed", {
lastEventId: localStorage.getItem("lastEventId") ?? undefined,
onEvent(e) {
if (e.id) localStorage.setItem("lastEventId", e.id);
// handle e ...
},
});
Transport modes and reconnect
SseTransport builds three modes. The mode is fixed per SsePort — one port per client construction.
| Mode | Factory | When to use |
|---|---|---|
"fetch" (default) | SseTransport.fetch({ baseUrl, headers?, credentials?, reconnect? }) | Any cross-origin stream that needs auth headers. |
"eventSource" | SseTransport.eventSource({ baseUrl, reconnect? }) | Same-origin streams relying on cookies; the browser manages reconnect natively. |
"mock" | SseTransport.mock(frames) | Tests — drive the parser with a pre-encoded iterable of frame strings, no network I/O. |
The reconnect field takes an SseReconnectPolicy. Two presets cover most needs:
import { SseReconnect } from "@heximon/client/sse";
SseReconnect.forever // indefinite, exponential back-off capped at 30 s (or the server's retry: hint)
SseReconnect.none // a single attempt, no retries
Supply a custom policy when you need a bounded retry count or a fixed delay:
const port = SseTransport.fetch({
baseUrl: "https://api.example.com",
reconnect: {
enabled: true,
maxAttempts: 5,
delay: (attempt) => Math.min(1_000 * attempt, 10_000),
},
});
Error types
Three typed errors surface through onError — none of them close the connection on their own:
| Error | Cause | Stream stays open |
|---|---|---|
SseValidationError | The frame's JSON payload failed its schema. Carries eventName + the Standard Schema issues array. | Yes |
SseUnknownEventError | The server sent an event name not in the declared schema map. Carries eventName. | Yes |
SseConnectionError | A network failure, a non-2xx status, or a transport-level abort. Carries an optional cause. | Until reconnect budget is exhausted |
import { SseValidationError, SseUnknownEventError, SseConnectionError } from "@heximon/client/sse";
onError(err) {
if (err instanceof SseValidationError) {
console.warn(`bad payload for ${err.eventName}`, err.issues);
} else if (err instanceof SseUnknownEventError) {
console.warn(`unknown event: ${err.eventName}`);
} else if (err instanceof SseConnectionError) {
console.error("connection failed", err.cause);
}
},
Testing with the mock transport
SseTransport.mock(frames) drives the full parser + validation path with a pre-encoded iterable of SSE frame
strings — identical to the fetch path, no network. Use SseReconnect.none so the generator terminates
when the frames are exhausted.
import { SseClient, SseTransport, SseReconnect } from "@heximon/client/sse";
const frames = [
"id: 1\nevent: order.placed\ndata: {\"orderId\":\"abc\",\"amount\":42}\n\n",
"id: 2\nevent: order.shipped\ndata: {\"orderId\":\"abc\",\"carrier\":\"DHL\"}\n\n",
];
const port = { ...SseTransport.mock(frames), reconnect: SseReconnect.none };
const client = new SseClient(port, testEvents);
for await (const event of client.stream("/feed")) {
console.log(event.event, event.data);
}
See also
- WebSocket Contracts — declare the
WebSocketContractthis page'sWsClienttypes itself off, including the per-kindWebSocketMessage<C, "kind">method binding on the server side. - Server-Sent Events — declare the
ServerSentEvents({...})class this page'sSseClientvalidates against, including the durable cross-isolate broadcast tier. - REST client — the
Contractself-client (new SomeApi(transport)) for request/response routes, the non-realtime sibling of the two clients here. - The flagship Cloudflare app
— a real end-to-end test where both clients parse actual server-produced wire frames: a
WsClient(mock transport) decoding a realseat.mapframe off theWebSocketDispatcher, and anSseClient(mock transport) decoding realseats.remainingframes off a live SSE response.
Server-Sent Events
Stream typed events server-to-client over plain HTTP with EventStream, EventChannel, heartbeats and Last-Event-ID resume, plus a durable-backed ServerSentEventsHandler for cross-isolate broadcast.
AsyncAPI
Generate an AsyncAPI 3.1 document from your WebSocket contracts and SSE event maps at runtime with AsyncApiGenerator, serving bidirectional WS send/receive operations and SSE receive-only operations, with the wire envelope documented at the message level.