nori_asyncapi

Package VersionHex Docstest

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.

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:

filetargetcontents
client.tsfrontendpayload interfaces, a Transport runtime (WebSocket + SSE), one typed class per channel
stores.tsfrontend (opt-in)useSyncExternalStore-compatible observable per subscribe message
types.gleambackendpayload records/enums + JSON codecs (via nori's emitter)
handlers.gleambackendhandle_* stubs for incoming (client→server) messages
server.gleambackendtransport-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")
}
functionreturns
parse_yaml / parse_json / parse_filetyped 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.