brote — reactive UIs in OCaml

brote lets you write a rich browser client entirely in OCaml, for the screens where server-rendered HTML is not enough. The UI updates itself as your data changes, and the same OCaml types describe both the server and the client, so the two ends cannot disagree. Every snippet below is cut from the brote repository and its trading-floor example.

When you reach for brote

araara's default frontend is hypermedia: a route returns HTML, htmx swaps it into the page, and there is exactly one rendering of your domain — the server's. The frontend guide makes the case for that as the starting point, and for a large class of applications it is the right one.

brote is for the rest: a canvas, a live dashboard, an editor, anything where the client holds genuine state and updates many times a second. Here the server does not ship finished HTML. It ships state deltas — small messages describing only what changed. Each client keeps its own copy of the data and computes its own view from it, in OCaml compiled to JavaScript. You stay in one language, with one set of types, from the matching engine to the order ticket.

It is not part of araara — it is an independent library that pairs with it. The brote_hcs adapter serves live collections over an hcs WebSocket, and brote_hive replicates them across a hive cluster, so the same server that renders your htmx pages can also feed a brote client.

The packages

brote is five packages with a clean dependency story: a stdlib-only core, and adapters that pull in a browser, a server, or a cluster only when you use them.

Package Depends on What it is
brote stdlib only the engine (Signal) and the data layer (Net, Sync, Collab, Crdt_map, App, Json)
brote_web brote, brr reactive HTML/SVG on the live DOM, plus an auto-reconnecting WebSocket transport
brote_hcs brote, hcs, simdjsont server adapter — serve a Sync/Collab collection over an hcs WebSocket
brote_hive brote, hive, swim cluster adapter — replicate a Sync/Collab collection across nodes
ppx_brote_json ppxlib, brote [@@deriving brote_json] — derive to_json/of_json over Brote.Json.value

The engine itself knows nothing about how its output is displayed. It never mentions a specific kind of UI element; each adapter decides what an element is — a browser DOM node, for instance. That is why the same brote core builds for both native and JavaScript without change.

The model

brote's model rests on three things — a source, a signal, and a node — and one habit that keeps them fast.

  • A signal ('a Signal.t) is a value that changes over time. Anything built from a signal recomputes automatically when it changes, so you describe how a value is derived once and brote keeps it up to date. You build new signals from existing ones with map / both. By default a recomputation propagates even when the result is unchanged; a cutoff (~equal) tells brote to skip the rest of the work when the new value equals the old one.
  • A source ('a Signal.source) is an input signal — one you push new values into yourself. It is the only place change enters; everything else is derived from it.
  • A node is a piece of rendered UI. You build it once from signals, leaving reactive holes that patch themselves in place as those signals change. There is no virtual DOM and no full re-render.

One habit keeps this fast: let the shape of your UI stay fixed and let only the values inside it change. The tempting mistake is to use bind to swap whole branches of UI in and out as data changes — but every swap throws away the real elements and rebuilds them, which is slow and loses local state like focus, scroll position, and selection. Instead, branch on data with signals, and vary the items in a collection with assoc / Brote_web.keyed, which reuse the elements that did not change. Updates then cost only as much as what actually changed.

Data flows one way; events flow back as callbacks that set a source. A counter is the whole model in one screen:

open Brote_web
open Brote.Signal.Syntax

let counter () =
  let n = Brote.Signal.source 0 in
  div [ class_ "counter" ] [
    button [ on_click (fun () -> Brote.Signal.update n ~f:pred) ] [ txt' "-" ];
    span [] [ txt (let+ v = Brote.Signal.get n in string_of_int v) ];
    button [ on_click (fun () -> Brote.Signal.update n ~f:succ) ] [ txt' "+" ];
  ]

let () = run counter

txt takes a signal and patches just that text node when n changes; the surrounding div and buttons are built once. let+ / and+ are the applicative syntax for composing signals.

Live data over WebSockets

Brote.Net turns a transport into reactive state. A transport is just three things: send, receive, and status. Because brote itself depends on nothing but the stdlib, anything that offers those three can act as the transport — a real browser socket, a fake one you write for tests, or a link to another node in a cluster. Brote.Net treats them all the same. On top of it:

  • a typed Channel carries one logical stream of values;
  • a Store is an Elm-style reducer over incoming messages (a CRDT is a store whose reduce is its merge);
  • an Rmap is a local reactive keyed map with one fine-grained source per key, which feeds keyed for high-fan-out feeds;
  • a Mux multiplexes several logical channels over one transport, length-framed and tagged, so one WebSocket carries many streams.

Two higher-level collections sit on that layer:

  • Sync — one-writer replicated keyed collections. A Sync.Server is authoritative; Sync.Client is a read-only mirror that exposes entries, find, and a values_signal.
  • Collab — multi-writer collaborative keyed collections backed by a last-writer-wins Crdt_map. Every Collab.Client can set / remove, and replicas converge.

In the browser you connect a stream or a collection to a transport and get a client back:

val connect_stream :
  transport:Brote.Net.Transport.t -> ('k, 'v) Brote.App.stream -> ('k, 'v) Brote.Sync.Client.t

val connect_collection :
  transport:Brote.Net.Transport.t -> ('k, 'v) Brote.App.collection -> ('k, 'v) Brote.Collab.Client.t

Brote_web.Ws.transport url provides the browser WebSocket. It reconnects automatically — exponential backoff with jitter — and buffers sends while it is down. Because a reconnect is a fresh server connection, brote_hcs re-ships its snapshot, so Sync / Collab clients resync with no code on your part. Removed keys are tombstones in the CRDT; bound their growth with Crdt_map.gc / Collab.Server.gc for long-lived collections.

ppx_brote_json — one type, both ends

The wire types are ordinary OCaml records and variants with [@@deriving brote_json], which generates to_json / of_json over the backend-neutral Brote.Json.value. Compile the module to both native and JavaScript and the two ends share one definition — they cannot drift.

type tick = {
  last : int;  (* last trade price, cents *)
  bid : int;  (* best bid in cents, 0 when the side is empty *)
  ask : int;  (* best ask in cents, 0 when the side is empty *)
}
[@@deriving brote_json]

type order_response =
  | Accepted_response of { filled : int; avg : int; resting : int; cash : int } [@json.name "accepted"]
  | Routed_response of { owner : string } [@json.name "routed"]
  | Rejected_response of { reason : string } [@json.name "rejected"]
[@@deriving brote_json]

It handles records, string enums, payload variants (with [@json.name] to control the tag), recursive and mutually-recursive types, and optional fields (a None is omitted, not written as null).

An App descriptor names a stream, collection, or command and pairs it with its key and value codecs — the single typed handle both ends refer to:

let value to_json of_json = App.Value.make ~to_json ~of_json

let quotes = App.stream ~name:"floor.quotes" ~key:App.Key.string ~value:(value tick_to_json tick_of_json) ()
let chat   = App.collection ~name:"floor.chat" ~key:App.Key.string ~value:(value chat_msg_to_json chat_msg_of_json) ()
let control = App.command ~name:"floor.control" ~value:(value control_to_json control_of_json) ()

brote_hcs — serving live state

brote_hcs exposes a Sync or Collab server as an hcs WebSocket handler. The simplest form, serve_sync, ships the snapshot, streams every delta, and routes inbound messages to an on_command callback:

val serve_sync :
  ?max_message_bytes:int ->
  server:('k, 'v) Brote.Sync.Server.t ->
  on_command:(string -> unit) ->
  Hcs.Websocket.t ->
  unit

Each connection gets its own writer fiber draining a bounded mailbox, so a slow client cannot stall the producer feeding everyone else; a client that falls too far behind is disconnected. Authentication and origin checks belong to the WebSocket upgrade policy at the endpoint, not here.

When one socket must carry several streams, drop to serve, which hands you the connection as a Net.Transport.t to multiplex yourself. This is how the trading floor runs six streams — quotes, leaderboard, trades, a private portfolio, chat, and control — over a single WebSocket:

let handler ~exchange ~sim ~room ~router ws =
  Brote_hcs.serve ws ~setup:(fun transport ->
      let mux = Mux.create transport in
      let q = Mux.channel mux "q" in
      let detach_q = Sync.Server.attach (Exchange.quotes_server exchange) ~send:q.T.send () in
      q.T.on_message (fun raw ->
          match App.decode_command Wire.control raw with Some c -> Sim.apply_control sim c | None -> ());
      let c = Mux.channel mux "c" in
      let client = Collab.Server.attach (Chatroom.room room) ~send:c.T.send in
      c.T.on_message (Collab.Server.receive (Chatroom.room room) client);
      fun () -> detach_q (); Collab.Server.detach (Chatroom.room room) client)

brote_hive — replicating across a cluster

For a clustered deployment, brote_hive replicates a Sync or Collab server across the nodes of a joined hive.cluster.swim membership. A Sync stream replicates from its single writer to read-only mirrors on every node; a Collab collection merges as a CRDT, so every node is a writer and they converge.

ignore (Brote_hive.replicate_collection Wire.chat ~server:(Floor.Chatroom.room room) ~node ~name:"floor-chat");
ignore (Brote_hive.replicate_stream Wire.quotes ~server:(Floor.Exchange.quotes_server exchange) ~node ~name:"floor-quotes");

Replication is loop-free (it gossips only local ops, dedups by sequence, and merges idempotently), so a browser connected to any node sees one converging market and one shared chat log. The trading-floor tutorial ends by switching the same application from single-node to clustered with exactly these few lines.

In a full application

Where this fits: brote is a peer to the hypermedia kit, not a replacement. A typical araara app renders most pages on the server with htmx and reaches for a brote client only on the screens that need one; the brote_hcs adapter lets the same hcs endpoint serve both. The frontend guide covers the default path and the other options; the tutorial builds a complete brote application end to end.