nori_asyncapi

Package Version Hex Docs test

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.

Search Document