AsyncAPI for WebSockets
DaloyJS already turns every HTTP route into an OpenAPI 3.1 operation. 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
receiveoperation for messages the server receives from clients (always emitted, because clients can send messages after the upgrade), and - an optional
sendoperation for messages the server pushes to clients (emitted only when you declare an outbound schema).
- 01app.ws("/chat/:room")+ optional meta block
- 02generateAsyncAPI(app)built-in, dependency-free
- 03AsyncAPI 3.0 documentchannels + operations
- 04receive / send opsreceive always, send when declared
- 05UI / YAML / codegenGET /asyncapi, asyncapiToYAML, CLI
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.
Serving an interactive UI
You don't have to wire the spec up yourself. Set asyncapi: true on the app and DaloyJS auto-mounts the AsyncAPI surface, the WebSocket counterpart to docs: true for OpenAPI (Scalar / Swagger / Redoc):
GET /asyncapi: an interactive UI that renders the official AsyncAPI React component, loaded from a CDN via a<script>tag exactly like the OpenAPI viewers (no build step, no extra runtime dependency).GET /asyncapi.jsonandGET /asyncapi.yaml: the AsyncAPI 3.0 document, generated lazily soapp.ws()routes registered afterwards are included.
Like the Scalar/Swagger docs link, surface the URL in your startup banner so it shows up in the terminal:
The UI page ships the same hardened response as the OpenAPI docs: a strict Content-Security-Policy that only allows the CDN asset origin (jsDelivr by default) plus connect-src 'self' so the component can fetch the served spec, nosniff, and no-referrer. Pin Subresource Integrity hashes or point at self-hosted assets via the assets option (asyncapiScriptUrl / asyncapiScriptIntegrity / asyncapiStyleUrl / asyncapiStyleIntegrity) for supply-chain hardening, exactly as with the OpenAPI docs UIs. Use asyncapi: "auto" to mount everywhere except production. The object form behaves the same way unless you set enabled: true (since 1.5.5).
Mounting the AsyncAPI surface in production (asyncapi: true or enabled: true) logs an asyncapi.public_in_production warning at startup, the same way docs does: the routes are public and exempt from requireAuth, so anyone can read your channel map. Fine for a public API. Otherwise keep the default "auto" or put auth in front.
What protects the channels themselves
The AsyncAPI routes are ordinary HTTP routes, so your global hooks (rate limits, secure headers, WAF, logging) run on them like on any REST endpoint. The WebSocket channels they describe are different. The runtime adapter handles the RFC 6455 upgrade before the HTTP pipeline, so app-level hooks and middleware (app.use, rateLimit, auth hooks, WAF, IP restriction) do not run on an upgrade request. Each channel is protected by its own route options instead:
allowedOrigins(oracknowledgeCrossOriginUpgrade: true) against Cross-Site WebSocket Hijacking, required in production under secure defaults.beforeUpgradefor auth andwsRateLimit()for throttling (oracknowledgeUnauthenticated: truefor a public channel), also required in production.request.bodyfor inbound message validation, plusmaxPayloadLength,idleTimeout, and backpressure limits.
AsyncAPI generation is descriptive. It never mounts a socket and never relaxes any of these defenses. See WebSockets for the full list.
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'srequest.bodyschema (the same schema used for payload-size checks).send: a Standard Schema describing messages the server sends to clients. Adds asendoperation when present.operationId: overrides the channel key that is otherwise derived from the path.
Schemas that expose a toJSONSchema() method are converted to JSON Schema for the message payload. Zod 4 schemas expose that method directly. Other validators may need the same adapter they use for OpenAPI output. For anything else, the generated AsyncAPI payload falls back to a permissive {} placeholder rather than throwing.
Which schemas are enforced
The two inbound schemas look alike in the document but behave differently at runtime:
request.bodyis enforced (since 1.6.0). Every inbound message is parsed as JSON, validated, and handed tomessage()as its fourth argument. A message that fails is never delivered: the socket closes with1007(bad JSON or schema failure) or1003(binary frame). See Validating inbound messages.meta.receiveandmeta.sendare documentation only. Usemeta.receivewhen the wire format is not JSON (a binary protocol, for example) and you validate insidemessage()yourself.
Outbound messages are never validated, so meta.send describes what you intend to send, not what the runtime checks.
Generated document shape
A single app.ws("/chat/:room", ...) route with the meta above produces roughly:
YAML output
asyncapiToYAML(doc) renders the document as YAML 1.2 using the same dependency-free emitter shared with the OpenAPI generator.
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.
Notes
- When the app has no WebSocket routes the document still validates, with empty
channelsandoperationsmaps. - Channel keys are derived from the path (
/chat/:room/feed->chatRoomFeed). Collisions are de-duplicated with a numeric suffix. Setmeta.operationIdfor 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.