nori_asyncapi
AsyncAPI 3.x code generation for Gleam. A satellite of nori.
Parses AsyncAPI specs (YAML or JSON) into a typed document, builds a codegen IR whose message payloads reuse nori’s type model, and emits a typed TypeScript client and Gleam server handlers from the same spec.
- One schema engine. Message payloads are plain JSON Schema, so they are
handed to nori (
parse_schema→schema_to_typedef/schema_to_typeref) rather than re-implementing schema handling. A message payload and a REST response body become the same generated type. - Client-correct TypeScript. AsyncAPI
actionis written from the server’s point of view; the TS generator inverts it, so a serversendbecomes a clienton*subscription and a serverreceivebecomes a clientsend*publish. - Verified output. Generated Gleam is compiled with the real compiler in the
test suite; generated TypeScript type-checks under
tsc --strict.
Install
gleam add nori_asyncapi
Requires nori >= 1.5.0 (pulled in automatically).
Quick start
# from a config file (auto-detected as ./asyncapi.config.yaml)
gleam run -m nori_asyncapi/cli -- generate
# or positional args, everything into one directory
gleam run -m nori_asyncapi/cli -- generate examples/chat.yaml ./out --stores
What gets generated
From an AsyncAPI spec with a channel, messages, and operations:
| file | target | contents |
|---|---|---|
client.ts | frontend | payload interfaces, a Transport runtime (WebSocket + SSE), one typed class per channel |
stores.ts | frontend (opt-in) | useSyncExternalStore-compatible observable per subscribe message |
types.gleam | backend | payload records/enums + JSON codecs (via nori’s emitter) |
handlers.gleam | backend | handle_* stubs for incoming (client→server) messages |
server.gleam | backend | transport-neutral dispatcher: decode incoming frames → typed handler callbacks, send_* encoders for outgoing messages, plus SSE resume helpers |
Example
Spec (examples/chat.yaml) — a bidirectional room channel:
channels:
room:
address: rooms/{roomId}
parameters:
roomId: { description: The room identifier. }
messages:
chatSent: { $ref: '#/components/messages/ChatSent' }
presence: { $ref: '#/components/messages/Presence' }
operations:
sendChat: { action: receive, channel: { $ref: '#/channels/room' }, messages: [ { $ref: '#/channels/room/messages/chatSent' } ] }
onPresence: { action: send, channel: { $ref: '#/channels/room' }, messages: [ { $ref: '#/channels/room/messages/presence' } ] }
Generated client.ts (excerpt):
export class RoomChannel {
private constructor(private readonly transport: Transport) {}
static connect(baseUrl: string, params: { roomId: string }): RoomChannel {
return new RoomChannel(new WebSocketTransport(`${baseUrl}/rooms/${params.roomId}`));
}
/** Publish a `ChatSent` message. */
sendChatSent(msg: ChatSent): void { this.transport.send(JSON.stringify(msg)); }
/** Subscribe to `Presence` messages. Returns an unsubscribe function. */
onPresence(handler: (msg: Presence) => void): () => void {
return this.transport.subscribe((data) => {
let env: { type?: string; payload?: unknown };
try {
env = JSON.parse(data);
} catch {
return;
}
if (env.type !== "Presence") return;
handler(env.payload as Presence);
});
}
close(): void { this.transport.close(); }
}
Generated handlers.gleam (excerpt):
/// Handle `sendChat` arriving on `rooms/{roomId}`.
pub fn handle_send_chat(msg: types.ChatSent) -> Nil { todo }
Outgoing (send) messages are not stubbed here — encode them with the send_* functions in server.gleam.
Full generated output for the chat spec lives in examples/generated/.
Server dispatch (Gleam)
server.gleam is a transport-neutral runtime. It decodes a
{ "type": "<MessageName>", "payload": <payload> } envelope, and routes it to a
Handlers callback record — you supply the callbacks, it does the decoding.
Because it takes a plain string frame, it plugs into any server (Mist, or
anything); it does not depend on a server library.
import generated/server.{Handlers}
let handlers =
Handlers(
on_chat_sent: fn(msg) { io.println("chat: " <> msg.text) },
on_chat_edited: fn(_msg) { Nil },
on_chat_deleted: fn(_msg) { Nil },
)
// on each received WebSocket text frame:
let _ = server.dispatch(handlers, frame)
// to push a message to the client, encode and send the returned string:
let frame = server.send_presence(types.Presence(user: "ada", status: types.Online))
Outgoing send_* encoders and incoming handlers are split by direction, so the
compiler stops you sending a receive-only message or handling a send-only one.
SSE resume
A browser EventSource reconnects on its own and echoes the last event id it
saw as the Last-Event-ID header. For any spec with send messages, server.gleam
emits helpers so the server replays only what a client missed instead of the whole
backlog: sse_event(id, frame) stamps the cursor onto a send_* frame, and
sse_backlog(resume, last_event_id) renders the missed events for a reconnecting
client. You supply the SseResume.replay_from lookup over your own event log.
let resume =
server.SseResume(replay_from: fn(last_id) {
// return the events after `last_id` as #(id, frame) pairs, oldest first
my_event_log.since(last_id)
})
// on (re)connect, before streaming live events:
let backlog = server.sse_backlog(resume, last_event_id)
// each live event carries its cursor:
let frame = server.sse_event(id, server.send_presence(presence))
Using it in React
The store layer fits useSyncExternalStore, so components need no useEffect.
Make the store a module singleton and read it:
import { useSyncExternalStore } from "react";
import { createPresenceStore } from "./generated/stores";
const presence = createPresenceStore("wss://chat.example.com", { roomId: "42" });
export function usePresence() {
return useSyncExternalStore(presence.subscribe, presence.getSnapshot);
}
The stores import nothing from React — they work equally with Vue, Svelte, Solid, or vanilla JS.
Configuration
The CLI auto-detects asyncapi.config.yaml, or takes --config=path. The point
of the config is that the two targets rarely share a directory — the TypeScript
client belongs in a frontend project, the Gleam handlers in a backend one.
spec: ./asyncapi.yaml
output:
gleam:
enabled: true
dir: ./backend/src/generated
types_module: generated/types # how handlers.gleam imports the types module
typescript:
enabled: true
dir: ./frontend/src/api
stores: true
stores_dir: ./frontend/src/api # defaults to `dir`
client_module: ./client # how stores.ts imports the client
Set enabled: false on a target to skip it. With no config, positional args
still work: generate <spec> [out-dir] [--stores].
See asyncapi.config.example.yaml for the
annotated reference.
Library API
import nori_asyncapi
pub fn main() {
let assert Ok(doc) = nori_asyncapi.parse_file("asyncapi.yaml")
let spec = nori_asyncapi.build_ir(doc)
let client = nori_asyncapi.generate_typescript(spec)
let stores = nori_asyncapi.generate_typescript_stores(spec, "./client")
let types = nori_asyncapi.generate_gleam_types(spec)
let handlers = nori_asyncapi.generate_gleam_handlers(spec, "generated/types")
}
| function | returns |
|---|---|
parse_yaml / parse_json / parse_file | typed Document |
build_ir(doc) | AsyncCodegenIR |
generate_typescript(spec) | neutral TS client |
generate_typescript_stores(spec, client_module) | neutral TS store layer |
generate_gleam_types(spec) | Gleam types + codecs |
generate_gleam_handlers(spec, types_module) | Gleam handler stubs |
generate_gleam_server(spec, types_module) | Gleam dispatcher (decode + route + encode) |
Architecture
YAML/JSON spec
↓ nori_asyncapi/yaml.gleam (taffy → JSON → decoder)
Document (typed AsyncAPI model)
↓ nori_asyncapi/ir_builder.gleam (payloads delegate to nori.parse_schema)
AsyncCodegenIR (channels/operations/messages; types reuse nori's TypeDef)
↓ codegen/typescript.gleam · codegen/gleam_types.gleam · codegen/gleam_handlers.gleam
Generated code
Scope
Supported: info, servers, channels, operations, messages (inline + $ref),
components (messages/schemas/channels/parameters), channel address parameters,
WebSocket + SSE transports.
Not yet: operation/message traits, correlationId, bindings, Kafka/NATS/AMQP/MQTT
transport codegen, multi-file $ref bundling, runtime payload validation.
License
Apache-2.0.