OCaml web programming with OO DOM types and Eio direct-style async.
Alpha, pre-0.1. Not yet on opam. The public surface is settling but not frozen.
Brr binds the browser into OCaml as a flat module-and-function API. Eio gives OCaml 5 direct-style async with structured cancellation. The two compose, but keeping them separate leaves the seams visible to application code. Brio is the opinionated upper layer:
- The DOM is presented as an OO type hierarchy with row
polymorphism (
#element,#html_element, …) — subtype passing without explicit upcasts. - Every cancellable JS API is wrapped to return a value directly,
with cancellation flowing back through Eio switches to the
underlying
AbortController/clearTimeout/ IndexedDB transaction. - Lifecycles ride on switches. There is no separate
unmountcall — closing the switch tears the component down (DOM listener removal, in-flight fetch abort, child detach all run onSwitch.on_release).
Brio depends on Brr for Jv interop and Jstr strings; it does not
vendor it.
open Brio
let () =
Run.start @@ fun () ->
Eio.Switch.run @@ fun sw ->
let count = ref 0 in
let label = El.div ~children:[El.txt' "0"] () in
let inc = El.button ~children:[El.txt' "+1"] () in
(Document.body ())#append
(El.div ~children:[ (label :> node); (inc :> node) ] ());
Ev.iter ~sw Ev.click inc (fun _ ->
incr count;
label#set_text_content (Jstr.v (string_of_int !count)))opam install . --deps-only # eio, brr, note, dune, js_of_ocaml
dune buildEach example builds to <name>.bc.js and is copied to <name>.js.
To run one:
dune build examples/hello
cd _build/default/examples/hello && python3 -m http.serverApps work the same way:
dune build apps/notes
cd _build/default/apps/notes && python3 -m http.server- lib/ — the library
- examples/ — small single-concept demos (30–150 LOC each)
- hello/ — runtime starts
- fibers/ — two fibers cooperating
- form/ — OO DOM hierarchy and downcasts
- editor/ — typed events
- fetch_demo/ — cancellable fetch with AbortSignal
- spa/ — hash routing, localStorage, the
raise Exitpattern - todomvc/ — TodoMVC
- streams/ — eXene-flavored channels via
Brio.Stream - PITFALLS.md — structural traps when wiring Eio to the DOM
- apps/
- notes/ — markdown notes app, imperative
- notes-reactive/ — the same app via
Brio.Signal(Note FRP), for ergonomics comparison
event_target → node → element → html_element, then per-tag
classes (html_input_element, html_button_element,
html_div_element, html_anchor_element, html_textarea_element,
html_label_element). Subtype polymorphism via OCaml's #class row
notation removes the need for upcasts in argument positions:
let dump_tag (e : #element) = Brr.Console.(log [e#tag_name])
in
dump_tag input; (* html_input_element *)
dump_tag button; (* html_button_element *)
dump_tag form; (* html_div_element *)The one explicit coercion that does happen is at heterogeneous
children sites: a node list for ~children requires (:> node)
on each child since OCaml lists are homogeneous. Single-child
parent#append child needs no coercion.
Polymorphic methods quantify over class types only (e.g.
append's argument is #node); polymorphism over type variables
(e.g. event types) lives in module functions instead — that's why
Ev.next / Ev.iter are functions, not methods.
let r = Brio.Fetch.url ~signal (Jstr.v "https://api.example.com/data") in
match Brio.Fetch.Response.json r with
| Ok j -> ...
| Error e -> ...Fetch.url returns a value, not a future. Cancellation flows from
the surrounding switch through to the underlying AbortController.
Same shape for Idb.Store.get, Time.sleep_ms, Anim.next_frame
— all built on Run.await ~setup ~cancel.
Components own a switch. Switch closes → on_release callbacks fire
→ DOM listeners removed, timers cleared, fetches aborted, child
nodes detached. There is no explicit unmount. The component layer
exposes:
Component.run— entry point that mounts and blocks onEio.Fiber.await_cancel; use for app-level entry instead of hand-rollingRun.start + Switch.run.Component.scope— an actor handle with explicitshutdown.Component.mount_into— appends a component to a parent and registers cleanup on a switch.
Brio gives three first-class ways to handle DOM events. Pick the one matching the information shape; they compose freely.
| Idiom | Best for |
|---|---|
Ev.iter ~sw typ target f |
Single-widget callback handlers, the imperative default |
Signal.dom_event + Signal.bind_* |
Reactive data flow with derivations, many-to-many wiring |
Ev.stream + Brio.Stream.take + Eio.Fiber.any |
Multi-source coordination, multi-step flows, eXene-style selective receive |
The third idiom needs a brief note: Brio.Stream is not
Eio.Stream. Eio's stream uses fiber-only synchronization
primitives that fail when add is called from a non-fiber context
like a DOM listener callback. Brio.Stream is the equivalent that's
safe to push to from any JS context — it's the streaming complement
of Run.await. See PITFALLS.md case 4 for
the long version.
Eio.Switch.run waits for forked fibers without auto-cancelling
them, and Eio's synchronization primitives can't be called from JS
event-dispatch callbacks. Both are real, both have shown up in code
that looked correct on first reading. See
examples/PITFALLS.md for the four cases and
their fixes.
- Server-side rendering / hydration. Brio is a client-only library.
- A virtual DOM diffing engine. Reactivity is fine-grained
(
Signal.bind_*); list re-renders are full unless the caller diffs explicitly. - A high-level widget toolkit. Brio's surface is the DOM with structured concurrency. A higher-level widget layer is being scoped as a separate package.
- OCaml 5.2+
- Dune 3.9+
eio≥ 1.0brr≥ 0.0.6note≥ 0.0.3 (used only insideBrio.Signal)js_of_ocamlwith--enable=effects(transitively, for executables)
ISC. See individual files for adapted-code attribution; notably lib/run.ml is adapted from eio_js_backend.