Search docs

Jump between documentation pages.

Browse docs

AsyncAPI for WebSockets

DaloyJS already turns every HTTP route into an OpenAPI 3.1 operation. As of 0.37.0 the same contract-first story extends to your real-time surfaces: the @daloyjs/core/asyncapi module emits a standards-compliant AsyncAPI 3.0 document for the WebSocket routes you register with app.ws(). It is built-in and dependency-free — the same posture as the OpenAPI generator — so it adds nothing to your runtime footprint.

Each app.ws() route becomes one AsyncAPI channel (the socket address plus any path parameters) and one or more operations:

  • a receive operation for messages the server receives from clients (always emitted — a socket can always be written to), and
  • an optional send operation for messages the server pushes to clients (emitted only when you declare an outbound schema).

Quick start

Call generateAsyncAPI(app, options) and you get a plain, JSON-serializable AsyncAPI document. Hand it to AsyncAPI Studio, write it to disk for codegen, or serve it from a route.

ts
import { App } from "@daloyjs/core";
import { generateAsyncAPI } from "@daloyjs/core/asyncapi";
import { writeFileSync } from "node:fs";

const app = new App();

app.ws("/chat/:room", {
  open(conn, ctx) {
    conn.data = { room: ctx.params.room };
  },
  message(conn, data) {
    conn.send(typeof data === "string" ? data.toUpperCase() : data);
  },
});

const doc = generateAsyncAPI(app, {
  info: { title: "Realtime API", version: "1.0.0" },
  servers: { production: { host: "api.example.com", protocol: "wss" } },
});

writeFileSync("./generated/asyncapi.json", JSON.stringify(doc, null, 2));

Describing the messages

WebSocket handlers accept an optional meta block that mirrors the HTTP route meta. It is purely descriptive — it never changes the RFC 6455 handshake or runtime behavior — and the AsyncAPI generator reads it to fill in summaries, tags, and message payloads.

  • summary / description / tags — surfaced on the generated channel and operations.
  • receive— a Standard Schema describing messages the server receives from clients. Falls back to the handler's request.body schema (the same schema used for payload-size checks).
  • send — a Standard Schema describing messages the server sends to clients. Adds a send operation when present.
  • operationId — overrides the channel key that is otherwise derived from the path.
ts
import { z } from "zod";

const ClientMessage = z.object({ text: z.string() });
const ServerMessage = z.object({ user: z.string(), text: z.string() });

app.ws("/chat/:room", {
  request: { body: ClientMessage },
  meta: {
    summary: "Room chat",
    description: "Bidirectional chat scoped to a room.",
    tags: ["chat"],
    send: ServerMessage,
  },
  open() {},
  message() {},
});

Schemas that expose a toJSONSchema() method (Zod 4, Valibot, ArkType, ...) are converted to JSON Schema for the message payload. Anything else falls back to a permissive {} placeholder rather than throwing, so generation never fails on an unconvertible schema.

Generated document shape

A single app.ws("/chat/:room", ...) route with the meta above produces roughly:

ts
{
  "asyncapi": "3.0.0",
  "info": { "title": "Realtime API", "version": "1.0.0" },
  "servers": { "production": { "host": "api.example.com", "protocol": "wss" } },
  "channels": {
    "chatRoom": {
      "address": "/chat/{room}",
      "summary": "Room chat",
      "parameters": { "room": { "description": "Path parameter `room`." } },
      "messages": {
        "receiveMessage": { "$ref": "#/components/messages/chatRoomReceive" },
        "sendMessage": { "$ref": "#/components/messages/chatRoomSend" }
      }
    }
  },
  "operations": {
    "chatRoomReceive": {
      "action": "receive",
      "channel": { "$ref": "#/channels/chatRoom" },
      "messages": [{ "$ref": "#/channels/chatRoom/messages/receiveMessage" }]
    },
    "chatRoomSend": {
      "action": "send",
      "channel": { "$ref": "#/channels/chatRoom" },
      "messages": [{ "$ref": "#/channels/chatRoom/messages/sendMessage" }]
    }
  },
  "components": { "messages": { /* ... payloads ... */ } }
}

YAML output

asyncapiToYAML(doc) renders the document as YAML 1.2 using the same dependency-free emitter shared with the OpenAPI generator.

ts
import { generateAsyncAPI, asyncapiToYAML } from "@daloyjs/core/asyncapi";

const yaml = asyncapiToYAML(generateAsyncAPI(app, {
  info: { title: "Realtime API", version: "1.0.0" },
}));

CLI

The daloy inspect command can print the AsyncAPI document for any app it can load, mirroring --openapi. Use --format yaml (or --yaml) for YAML output.

bash
daloy inspect --asyncapi > asyncapi.json
daloy inspect --asyncapi --format yaml > asyncapi.yaml

Notes

  • When the app has no WebSocket routes the document still validates, with empty channels and operations maps.
  • Channel keys are derived from the path (/chat/:room/feed chatRoomFeed); collisions are de-duplicated with a numeric suffix. Set meta.operationId for a stable, explicit key.
  • The generator is read-only: it never mounts a route or changes your socket's security posture. See the WebSocket primitives page for the CSWSH refuse-to-boot guards.