Skip to content

Latest commit

 

History

History
197 lines (155 loc) · 6.86 KB

File metadata and controls

197 lines (155 loc) · 6.86 KB

Brio

OCaml web programming with OO DOM types and Eio direct-style async.

Status

Alpha, pre-0.1. Not yet on opam. The public surface is settling but not frozen.

Why

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 unmount call — closing the switch tears the component down (DOM listener removal, in-flight fetch abort, child detach all run on Switch.on_release).

Brio depends on Brr for Jv interop and Jstr strings; it does not vendor it.

Quick taste

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)))

Building

opam install . --deps-only      # eio, brr, note, dune, js_of_ocaml
dune build

Each 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.server

Apps work the same way:

dune build apps/notes
cd _build/default/apps/notes && python3 -m http.server

Layout

  • 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 Exit pattern
    • 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

Concepts

DOM as an OO hierarchy

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.

Direct-style async

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.

Structured concurrency

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 on Eio.Fiber.await_cancel; use for app-level entry instead of hand-rolling Run.start + Switch.run.
  • Component.scope — an actor handle with explicit shutdown.
  • Component.mount_into — appends a component to a parent and registers cleanup on a switch.

Three event idioms

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.

Pitfalls worth reading

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.

Non-goals (currently)

  • 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.

Dependencies

  • OCaml 5.2+
  • Dune 3.9+
  • eio ≥ 1.0
  • brr ≥ 0.0.6
  • note ≥ 0.0.3 (used only inside Brio.Signal)
  • js_of_ocaml with --enable=effects (transitively, for executables)

License

ISC. See individual files for adapted-code attribution; notably lib/run.ml is adapted from eio_js_backend.