AsyncAPI
You described your real-time API once — a WebSocketContract carries the bidirectional schema for every
message kind, and a ServerSentEvents class carries the broadcast event map for every SSE channel. Point a
generator at them and you get a standards-compliant AsyncAPI 3.1
document whose message schemas read the same Standard Schemas your server validates, so your docs can't
drift from what the wire actually accepts.
Generation happens at runtime: AsyncApiGenerator is a plain class you construct and call from a
controller — there's no plugin to register and no compiler step.
Install the package
pnpm add @heximon/asyncapi
npm install @heximon/asyncapi
yarn add @heximon/asyncapi
bun add @heximon/asyncapi
There is nothing to add to vite.config.ts. The generator depends on @heximon/openapi for the
Standard-Schema → JSON-Schema converter it shares; @heximon/websocket and @heximon/sse are optional peers
accepted by structural duck-typing.
Generate the document
new AsyncApiGenerator(options) takes info (title + version, plus optional description), a channels
array of channel descriptors, and an optional custom converter. Call generate() to build the document — it
builds once and returns the same memoized object on every subsequent call.
import { AsyncApiGenerator } from "@heximon/asyncapi";
import { ChatContract } from "../chat/chat.contract";
import { FeedEvents } from "../feed/feed.events";
export const asyncApiGenerator = new AsyncApiGenerator({
info: {
title: "Chat API",
version: "1.0.0",
description: "WebSocket chat and SSE feed, documented with AsyncAPI 3.1.",
},
channels: [
// No `address`: each channel's address defaults from the contract/events value's own declared `path`.
{ kind: "websocket", contract: ChatContract },
{ kind: "sse", events: FeedEvents },
],
});
A channel's address is optional when the contract/events value declares its own path — the same
path a WebSocketContract({ path, ... }) or ServerSentEvents({ path, events }) class declares for the
server and client to share becomes the channel's address, so the address lives in exactly one place. An
explicit channel address still wins when both are supplied; a channel with neither an explicit
address nor a declared path throws at generation time.
generate() assembles one channel per descriptor and one or two operations per channel. A duplicate address
across descriptors produces an ambiguous key — keep channel addresses unique.
Supply channel descriptors
Each entry in channels is a discriminated union keyed by kind. The contract or events value is read
by structural duck-typing — a WebSocketContract subclass or a ServerSentEvents subclass both work
without installing either optional peer:
kind | Fields | What the generator reads |
|---|---|---|
"websocket" | contract, address? | contract.clientToServer (C2S schema map) and contract.serverToClient (S2C schema map); contract.path defaults the address |
"sse" | events, address? | events.events (the broadcast event-schema map); events.path defaults the address |
address is optional on both — omit it when the contract/events value declares its own path (see
Generate the document above).
A WebSocket channel produces two operations — ${channelName}Send (client-to-server) and
${channelName}Receive (server-to-client). An SSE channel produces one operation —
${channelName}Receive — because SSE is server-to-client only.
WebSocket channel
import { object, string } from "valibot";
import { WebSocketContract } from "@heximon/contract/websocket";
export class ChatContract extends WebSocketContract({
path: "/chat",
clientToServer: {
"chat.message": object({ text: string() }),
},
serverToClient: {
"chat.joined": object({ userId: string() }),
"chat.message": object({ userId: string(), text: string() }),
},
}) {}
import { AsyncApiGenerator } from "@heximon/asyncapi";
import { ChatContract } from "../chat/chat.contract";
export const generator = new AsyncApiGenerator({
info: { title: "Chat API", version: "1.0.0" },
channels: [
{ kind: "websocket", contract: ChatContract }, // address defaults from ChatContract.path
],
});
The generator reads ChatContract.clientToServer and ChatContract.serverToClient (the statics) and emits:
- Channel
chat(address/chat) carrying all messages from both directions. - Operation
chatSend—action: "send", references the client-to-server messages. - Operation
chatReceive—action: "receive", references the server-to-client messages.
SSE channel
import { object, string } from "valibot";
import { ServerSentEvents } from "@heximon/sse";
// The structured `{ path, events }` form lets the events class own the stream's address; the flat
// `ServerSentEvents({ name: schema })` form (no declared path) still works — pass an explicit `address` then.
export class FeedEvents extends ServerSentEvents({
path: "/feed",
events: {
announce: object({ body: string() }),
ping: object({ at: string() }),
},
}) {}
import { AsyncApiGenerator } from "@heximon/asyncapi";
import { FeedEvents } from "../feed/feed.events";
export const generator = new AsyncApiGenerator({
info: { title: "Feed API", version: "2.0.0" },
channels: [
{ kind: "sse", events: FeedEvents }, // address defaults from FeedEvents.path
],
});
The generator reads FeedEvents.events (the static map) and emits:
- Channel
feed(address/feed) carrying one message per event name. - Operation
feedReceive—action: "receive"(SSE emits no send operation).
The wire envelope
Every frame over a Heximon WebSocket or SSE channel carries the typed envelope
{"v":1,"kind":"<name>","data":<payload>}. The generator documents this at the message level — each
AsyncAPI message.name is the kind discriminator, and message.payload is the JSON Schema for the data
field. The channel address and channel-level notes record the envelope structure so a consumer knows how to
decode each frame without consulting out-of-band documentation.
Channel name sanitization
The generator derives each channel's key (used in channels and as the operation-name prefix) from the
address by removing the leading slash, splitting on /, -, and _, and joining as camelCase:
| Address | Channel key |
|---|---|
/chat | chat |
/feed | feed |
/api/events | apiEvents |
/live-feed | liveFeed |
Operations follow: a WebSocket channel at /chat produces chatSend and chatReceive; an SSE channel at
/feed produces feedReceive.
Serve the document
The document is a plain serializable object, so a standard inline controller serves it:
import type { Controller, Get } from "@heximon/http";
import { asyncApiGenerator } from "./asyncapi";
export class AsyncApiDocsController implements Controller {
// GET /asyncapi.json — the AsyncAPI 3.1 document (built once, then memoized)
public async document(action: Get<"/asyncapi.json">) {
return asyncApiGenerator.generate();
}
}
The owning module declares only the controller — the generator is a module-level value, not a DI provider:
import { Module } from "@heximon/runtime";
import { AsyncApiDocsController } from "./docs.controller";
export class DocsModule extends Module({
http: { controllers: [AsyncApiDocsController] },
}) {}
Document shape
A mixed WS + SSE app produces a document with this structure. The channels map holds all address + message
declarations; the operations map holds the intent (send vs. receive) pointing back at channel refs:
{
"asyncapi": "3.1.0",
"info": { "title": "Chat API", "version": "1.0.0" },
"channels": {
"chat": {
"address": "/chat",
"messages": {
"chatC2SChat.message": { "name": "chat.message", "payload": { "type": "object" } },
"chatS2CChat.joined": { "name": "chat.joined", "payload": { "type": "object" } },
"chatS2CChat.message": { "name": "chat.message", "payload": { "type": "object" } }
}
},
"feed": {
"address": "/feed",
"messages": {
"feedAnnounce": { "name": "announce", "payload": { "type": "object" } },
"feedPing": { "name": "ping", "payload": { "type": "object" } }
}
}
},
"operations": {
"chatSend": { "action": "send", "channel": { "$ref": "#/channels/chat" }, "messages": ["..."] },
"chatReceive": { "action": "receive", "channel": { "$ref": "#/channels/chat" }, "messages": ["..."] },
"feedReceive": { "action": "receive", "channel": { "$ref": "#/channels/feed" }, "messages": ["..."] }
}
}
AsyncAPI 3.1 separates channels (the address, where messages flow) from operations (the intent: who sends what).
That's why there is no operations key inside a channel — channels describe the wire; operations describe the
application's behavior.
Message keys are assembled by uppercasing only the first character of the kind — a dotted kind like
chat.message keeps its dot (chatC2SChat.message), it isn't converted to camelCase. Keep kinds dot-free
(chatMessage) if you want camelCase message keys.
Graceful schema degradation
A schema that carries no ~standard.jsonSchema converter degrades to an empty payload: {} rather than
failing the whole document — the channel and its operations are still emitted. For raw valibot schemas (which
do not carry the Standard Schema JSON Schema extension by default), supply a custom converter that chains
valibotJsonSchema from @heximon/schema/valibot:
import { valibotJsonSchema } from "@heximon/schema/valibot";
import { AsyncApiGenerator, StandardSchemaConverter } from "@heximon/asyncapi";
import { ChatContract } from "../chat/chat.contract";
export const generator = new AsyncApiGenerator({
info: { title: "Chat API", version: "1.0.0" },
channels: [
{ kind: "websocket", contract: ChatContract },
],
converter: (schema, direction) =>
valibotJsonSchema(schema, direction) ?? StandardSchemaConverter.convert(schema, direction),
});
valibotJsonSchema guards on ~standard.vendor === "valibot" and returns undefined on any failure, so
the fallback to {} is preserved for schemas that cannot be represented in JSON Schema.
See also
- WebSockets — the plain
WebSocketHandlera WS channel descriptor can point at. - WebSocket Contracts — the
WebSocketContractclass the WS channel descriptor points at; theclientToServer/serverToClientschema maps the generator reads. - Server-Sent Events — the
ServerSentEventsHandlerandServerSentEventsclass the SSE channel descriptor points at; theeventsmap the generator reads. - OpenAPI — the REST counterpart: generate an OpenAPI 3.1 document from a
Contract's route table, serve Scalar or Swagger UI. - The flagship Cloudflare app — a
WebSocketContract-backed seat-map channel and aServerSentEvents-backed feed channel, both documented in oneAsyncApiGenerator, served from a plain controller as an AsyncAPI 3.1 document.
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.
Durable Objects
Author a single-threaded stateful actor at the edge with DurableObject and a DI-injected DurableFactory, address it by name, and reach storage through DurableContext.