Heximon Logo
Realtime

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.

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

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.

src/docs/asyncapi.ts
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:

kindFieldsWhat 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

src/chat/chat.contract.ts
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() }),
  },
}) {}
src/docs/asyncapi.ts
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 chatSendaction: "send", references the client-to-server messages.
  • Operation chatReceiveaction: "receive", references the server-to-client messages.

SSE channel

src/feed/feed.events.ts
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() }),
  },
}) {}
src/docs/asyncapi.ts
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 feedReceiveaction: "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:

AddressChannel key
/chatchat
/feedfeed
/api/eventsapiEvents
/live-feedliveFeed

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:

src/docs/docs.controller.ts
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:

src/docs/docs.module.ts
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:

src/docs/asyncapi.ts
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 WebSocketHandler a WS channel descriptor can point at.
  • WebSocket Contracts — the WebSocketContract class the WS channel descriptor points at; the clientToServer / serverToClient schema maps the generator reads.
  • Server-Sent Events — the ServerSentEventsHandler and ServerSentEvents class the SSE channel descriptor points at; the events map 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 a ServerSentEvents-backed feed channel, both documented in one AsyncApiGenerator, served from a plain controller as an AsyncAPI 3.1 document.
Copyright © 2026