Channels & the contract
A Channel is the contract between server and client. It's a chainable builder; each method adds a typed capability and returns a new, widened channel type. The same value drives server-side validation, the client's inferred types, and an emitted AsyncAPI 3.0 document.
Each channel is its own WebSocket route (path-per-channel — no multiplexing).
import { Channel } from "ws-asyncapi";
import { z } from "zod";
export const chat = new Channel("/chat/:room", "chat");
// ^ path pattern ^ namePath parameters
Path params (:room) are captured and available to handlers. The client must connect to a concrete path that matches the pattern.
const channel = new Channel("/doc/:id", "doc");
// client connects to "/doc/42"Declaring messages
| Method | Direction | Reply? | On the client |
|---|---|---|---|
.serverMessage(name, schema) | server → client | — | client.onEvent(name, cb) |
.clientMessage(name, handler, schema) | client → server | no | client.call(name, data) |
.rpc(name, in, out, handler, errors?) | client → server | yes | await client.request(name, in) |
.serverRpc(name, in, out) | server → client | yes | client.onRequest(name, handler) |
.stream(name, in, out, handler) | server → client (many) | — | for await (… of client.stream(name, in)) |
export const chat = new Channel("/chat/:room", "chat")
.serverMessage("message", z.object({ from: z.string(), text: z.string() }))
.clientMessage(
"typing",
({ ws, message }) => ws.publish("room", "typing", message),
z.object({ on: z.boolean() }),
)
.rpc(
"send",
z.object({ text: z.string() }),
z.object({ id: z.string() }),
async ({ message }) => ({ id: crypto.randomUUID() }),
);Names must be unique across serverMessage / clientMessage / rpc / serverRpc / stream within a channel — the builder rejects collisions when the AsyncAPI document is generated.
Validators
Any Standard Schema validator works — zod, valibot, arktype. The schema's input shape validates inbound payloads; its output (parsed) shape is what handlers and the client see, so transforms and defaults flow through.
Connection lifecycle & context
new Channel("/chat/:room", "chat")
.onOpen(({ ws, request }) => ws.subscribe("room"))
.onClose(({ ws }) => {/* cleanup */})
// accumulate typed context (auth, db handles, per-connection state)
.derive(({ request }) => ({ userId: request.query.token ?? "anon" }))
.resolve(async ({ userId }) => ({ user: await loadUser(userId) }));.derive() / .resolve() add fields to the context object every handler receives (Elysia-style). Use them for auth, dependency injection, and per-connection state. See RPC & acknowledgements for how errors in this pipeline surface to callers.
Plugins (.use)
channel.use(plugin) applies a function (channel) => extended channel. Since every builder method threads the channel's generics, the plugin's return type is the widened channel — so a plugin can package reusable setup (context, hooks, even contract additions) as a unit.
Inline (fully typed for any contribution):
const chat = new Channel("/chat/:room", "chat")
.use((c) => c.derive(({ request }) => ({ token: request.query.token })))
.use((c) => c.serverMessage("message", z.object({ text: z.string() })));Reusable hook plugins (rate-limit, logging, metrics, guards) compose by reference with full typing — they use .beforeMessage/.onError/.onOpen/ .onClose, which return the same channel type:
const rateLimit = (opts: { max: number }) => <C extends AnyChannel>(c: C) =>
c.beforeMessage(({ ws }) => { if (over(ws.id, opts)) throw new RpcError("OVERLOADED", "slow down"); });
channel.use(rateLimit({ max: 100 }));Reusable context/contract plugins — to package reusable context or contract (a derived user, an rpc, events) and keep full typing, author the plugin as a channel built on a base, and .use() it. Its contributions are merged into the host with complete inference:
// a reusable plugin, authored as a channel
const auth = new Channel("/", "auth")
.derive(() => ({ user: getUser() }))
.rpc("login", z.object({ pw: z.string() }), z.object({ ok: z.boolean() }), async () => ({ ok: true }))
.serverMessage("kicked", z.object({ reason: z.string() }));
const chat = new Channel("/chat/:room", "chat")
.serverMessage("message", z.object({ text: z.string() }))
.use(auth); // ← `user`, the `login` rpc, and the `kicked` event are all merged + typedMerging combines events/commands/rpc/server-rpc/streams and derived context (plugin contributions run after the host's); single lifecycle hooks compose; presence/auth/query/headers are adopted only if the host hasn't set them.
Which form?
Inline (c => c....) and channel (new Channel(...).…) plugins are both fully typed for any contribution. The reusable function form (<C>(c: C) => c.…) is fully typed only for hook-only plugins (rate-limit, logging) — for reusable context/contract, use the channel form above.
Named, idempotent plugins — wrap a plugin with definePlugin to give it a stable name; .use() then applies it at most once per channel, so a shared sub-plugin used by several plugins runs a single time:
import { definePlugin } from "ws-asyncapi";
const withCors = definePlugin({
name: "app/cors",
setup: (c) => c.onOpen(({ ws }) => {/* … */}),
});
channel.use(withCors).use(withCors); // setup runs onceServer-level plugins — for cross-channel infrastructure (metrics, tracing, audit logging) that observes rather than shapes the contract, pass a ServerPlugin to the adapter. It taps the lifecycle of every channel (onConnection/onDisconnect/onMessage/onError), fire-and-forget and isolated:
import type { ServerPlugin } from "ws-asyncapi";
const metrics: ServerPlugin = {
onConnection: ({ channel, socketId }) => {/* … */},
onMessage: ({ channel, kind, name }) => {/* … */},
onError: ({ channel, error }) => {/* … */},
};
createNodeWsServer([chat, board], { port: 3000, plugins: [metrics] });Presence, history & auth
These are covered in depth in their own guides, but they're declared on the same builder:
new Channel("/doc/:id", "doc")
// typed per-room presence roster
.presence(z.object({ name: z.string(), cursor: z.object({ x: z.number(), y: z.number() }).nullable() }))
// retain the last 50 "message" events per room for rewind/backlog
.history("message", { keep: 50 })
// mid-connection credential refresh (token rotation without a reconnect)
.onAuth(z.object({ token: z.string() }), ({ credentials }) => ({ userId: verify(credentials.token) }));See Presence & live cursors for the roster and cursor APIs.
Two ways to get a typed client
Inference (recommended)
createClient<typeof channel>() infers the entire client surface directly from the channel value's type. No build step, nothing generated into your repo.
import { createClient } from "@ws-asyncapi/client";
import type { chat } from "./server";
const client = createClient<typeof chat>("ws://localhost:3000", "/chat/general");AsyncAPI + CLI codegen
Every channel also produces a standard AsyncAPI 3.0 document, and the CLI generates an equivalent typed client from it. Use this when client and server live in separate repos, or when you want a published, language-agnostic contract.
import { getAsyncApiDocument } from "ws-asyncapi";
const doc = getAsyncApiDocument([chat]);
// serve `doc` at e.g. GET /asyncapi.json# generate a typed client from a running server's document
bunx @ws-asyncapi/cli http://localhost:3000/asyncapi.jsonThe generated client is typed for the full surface — events, commands, RPC, server-RPC, streams, auth credentials, presence state, and history — matching the inference path.