Heximon Logo
Realtime

Typed Clients

Browser and edge-safe clients for realtime transports — WsClient over a WebSocketContract and SseClient over a ServerSentEvents class, with WsTransport/SseTransport native/fetch/eventSource/mock modes, reconnect policies, and typed validation errors.

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

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.

src/chat-client.ts
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.

Shorthand: the contract instance is itself the client hub when constructed over a client transport, so you can skip the explicit 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.
For a contract that owns a parameterised path (like the flagship 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

PathOn failure
conn.send(kind, data) — outboundthrows WsValidationError; nothing sent
inbound on(kind) dispatchonError(WsValidationError); frame dropped; connection stays open
inbound unknown kindonError(WsUnknownKindError); frame dropped; connection stays open
non-JSON or malformed envelopeonError(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.

ModeFactoryWhen 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:

ErrorCauseStream stays open
SseValidationErrorThe frame's JSON payload failed its schema. Carries eventName + the Standard Schema issues array.Yes
SseUnknownEventErrorThe server sent an event name not in the declared schema map. Carries eventName.Yes
SseConnectionErrorA 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 WebSocketContract this page's WsClient types itself off, including the per-kind WebSocketMessage<C, "kind"> method binding on the server side.
  • Server-Sent Events — declare the ServerSentEvents({...}) class this page's SseClient validates against, including the durable cross-isolate broadcast tier.
  • REST client — the Contract self-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 real seat.map frame off the WebSocketDispatcher, and an SseClient (mock transport) decoding real seats.remaining frames off a live SSE response.
Copyright © 2026