Widget Support Plan of Record#
Created: 2026-02-12
Goals#
Add Jupyter-style interactive widget support to js_top_worker, enabling OCaml code running in the toplevel to create interactive UI elements (sliders, buttons, dropdowns, etc.) that communicate bidirectionally with the frontend.
Design Principles#
-
Zero new dependencies in the worker - Every dependency compiled into the worker can conflict with libraries the user wants to load at runtime. Widget support must not add any new OCaml dependencies to the worker.
-
Broad OCaml version compatibility - The project currently targets OCaml
= 4.04. New features must not raise this floor unnecessarily.
-
Build on the message protocol, not RPC - The worker already uses the message-based protocol (
message.ml/js_top_worker_client_msg.ml). Widget communication extends this protocol rather than the legacy JSON-RPC layer. -
Remove, don't accumulate - The legacy rpclib-based communication layer should be removed as part of this work, reducing the dependency footprint.
Why Not CBOR?#
The architecture document previously listed CBOR as a planned transport format. After investigation, we've decided against it:
- Dependency risk: Even the lightweight
cboropam package brings inocplib-endian. Any dependency in the worker namespace can conflict with user-loaded libraries. - Unnecessary complexity: The existing message protocol uses
Js_of_ocaml's native JSON handling (Json.output/Json.unsafe_input), which has zero additional dependencies. - Binary data via Typed Arrays: For binary payloads (images, etc.),
js_of_ocaml'sTyped_arraymodule provides native browser typed array support without any extra libraries. - JSON is the browser's native format - No encoding/decoding overhead when
passing structured data via
postMessage.
Communication Architecture#
Current State (Two Parallel Layers)#
1. Legacy RPC (to be removed):
Client (js_top_worker_client.ml) <-> JSON-RPC <-> Server (Toplevel_api_gen)
Dependencies: rpclib, rpclib-lwt, rpclib.json, ppx_deriving_rpc
2. Message protocol (to be extended):
Client (js_top_worker_client_msg.ml) <-> JSON messages <-> Worker (worker.ml)
Dependencies: js_of_ocaml (already required)
Target State#
Client (js_top_worker_client_msg.ml) <-> JSON messages <-> Worker (worker.ml)
| |
|-- Request/Response (existing: eval, complete, errors, ...) |
|-- Push messages (existing: output_at streaming) |
|-- Widget messages (NEW: comm_open, comm_update, comm_msg, ...) |
All communication uses the existing message.ml protocol extended with widget
message types. No new serialization libraries.
Widget Protocol Design#
Message Types (Worker -> Client)#
CommOpen { comm_id; target; state } -- Widget created by OCaml code
CommUpdate { comm_id; state } -- Widget state changed
CommClose { comm_id } -- Widget destroyed
Message Types (Client -> Worker)#
CommMsg { comm_id; data } -- Frontend event (click, value change)
CommClose { comm_id } -- Frontend closed widget
Widget State Format#
Widget state is a JSON object with well-known keys, following the Jupyter widget convention where practical:
{
"widget_type": "slider",
"value": 50,
"min": 0,
"max": 100,
"step": 1,
"description": "Threshold",
"disabled": false
}
The widget_type field replaces Jupyter's _model_module / _model_name /
_view_module / _view_name quartet, since we don't need the npm module
indirection - our widget renderers are built into the client.
Alignment with Jupyter Protocol#
We adopt the concepts from the Jupyter widget protocol but simplify the implementation:
| Jupyter Concept | Our Equivalent |
|---|---|
| comm_open | CommOpen message |
| comm_msg method:"update" | CommUpdate message |
| comm_msg method:"custom" | CommMsg message |
| comm_close | CommClose message |
| _model_module + _model_name | widget_type string |
| buffer_paths (binary) | Typed_array via js_of_ocaml |
| Display message | CommOpen includes display flag |
We do not implement:
- echo_update (single frontend, no multi-client sync needed)
- request_state / request_states (state is authoritative in worker)
- Version negotiation (internal protocol, not cross-system)
OCaml Widget API#
User-facing API available as an OCaml library in the toplevel:
module Widget : sig
type t
(** Create a widget. Returns it and displays it. *)
val slider : ?min:int -> ?max:int -> ?step:int ->
?description:string -> int -> t
val button : ?style:string -> string -> t
val text : ?placeholder:string -> ?description:string -> string -> t
val dropdown : ?description:string -> options:string list -> string -> t
val checkbox : ?description:string -> bool -> t
val html : string -> t
(** Read current value *)
val get : t -> Yojson.Safe.t (* or a simpler JSON type *)
(** Update widget state *)
val set : t -> string -> Yojson.Safe.t -> unit
(** Register event handler *)
val on_change : t -> (Yojson.Safe.t -> unit) -> unit
val on_click : t -> (unit -> unit) -> unit
(** Display / close *)
val display : t -> unit
val close : t -> unit
end
Important: This API library (widget or similar) runs inside the toplevel
and must have minimal dependencies. It communicates with the frontend by pushing
messages through the same channel as Mime_printer.
Code Removal Plan#
Files to Remove#
| File | Reason |
|---|---|
idl/transport.ml, transport.mli |
JSON-RPC transport wrapper |
idl/js_top_worker_client.ml, .mli |
RPC-based Lwt client |
idl/js_top_worker_client_fut.ml |
RPC-based Fut client |
idl/_old/ directory |
Historical RPC reference code |
Dependencies to Remove#
| Package | Used By |
|---|---|
rpclib |
transport.ml, RPC clients |
rpclib-lwt |
impl.ml (IdlM module) |
rpclib.json |
transport.ml, RPC clients |
ppx_deriving_rpc |
toplevel_api.ml code generation |
xmlm |
transitive via rpclib |
cmdliner |
transitive via rpclib |
Files to Refactor#
| File | Change |
|---|---|
lib/impl.ml |
Remove IdlM / Rpc_lwt usage, use own types |
idl/toplevel_api.ml |
Keep type definitions, remove RPC IDL machinery |
idl/toplevel_api_gen.ml |
Replace with hand-written types (no ppx_deriving_rpc) |
idl/dune |
Remove rpclib library deps and ppx rules |
lib/dune |
Remove rpclib-lwt dep |
test/node/*.ml |
Migrate from RPC Server/Client to message protocol |
test/browser/client_test.ml |
Use message-based client |
example/unix_worker.ml |
Use message protocol over Unix socket |
example/unix_client.ml |
Use message protocol over Unix socket |
Impact on toplevel_api_gen.ml#
The generated file is 92k+ lines (from ppx_deriving_rpc). The types it defines
are used extensively in impl.ml and worker.ml. The plan:
- Extract the type definitions into a new lightweight module (no ppx)
- Hand-write any needed serialization for the message protocol
- Remove
ppx_deriving_rpcdependency entirely - Delete
toplevel_api_gen.ml
Testing Strategy#
Existing Test Infrastructure#
| Backend | Location | Framework |
|---|---|---|
| Unit tests | test/libtest/ |
ppx_expect |
| Node.js | test/node/ |
js_of_ocaml + Node |
| Unix (cram) | test/cram/ |
Cram tests with unix_worker |
| Browser | test/browser/ |
Playwright + Chromium |
Widget Testing Approach#
1. Unit Tests (test/libtest/)#
- Widget state management logic
- Message serialization/deserialization for widget messages
- CommManager state tracking (open/update/close lifecycle)
2. Node.js Tests (test/node/)#
- Widget creation produces correct CommOpen messages
- Widget state updates produce CommUpdate messages
- Event handler registration and dispatch
- Widget close cleanup
- Multiple simultaneous widgets
- Widget interaction with regular exec output (mime_vals + widgets)
3. Cram Tests (test/cram/)#
- Unix worker handles widget messages over socket
- Widget lifecycle via command-line client
- Widget messages interleaved with regular eval output
4. Browser Tests (test/browser/)#
- End-to-end widget rendering: OCaml creates widget -> message sent -> client renders DOM element -> user interaction -> event sent back -> OCaml handler fires
- Widget types: Test each widget type (slider, button, text, dropdown, checkbox, html)
- State synchronization: Frontend changes propagated to worker and back
- Multiple widgets: Several widgets active simultaneously
- Widget cleanup: Closing widgets removes DOM elements
- Integration with existing features: Widgets alongside code completion, error reporting, MIME output
Test Utilities#
A shared test helper module for widget testing:
(* test/test_widget_helpers.ml *)
val assert_comm_open : worker_msg -> comm_id:string -> widget_type:string -> unit
val assert_comm_update : worker_msg -> comm_id:string -> key:string -> unit
val assert_comm_close : worker_msg -> comm_id:string -> unit
val simulate_event : comm_id:string -> data:string -> client_msg
Example Widgets#
Priority 1: Core Widgets (Implement First)#
These are the most commonly used Jupyter widgets and cover the fundamental interaction patterns:
| Widget | State | Events | Jupyter Equivalent |
|---|---|---|---|
| IntSlider | value, min, max, step | on_change(int) | IntSlider |
| Button | description, style | on_click | Button |
| Text | value, placeholder | on_change(string) | Text |
| Dropdown | value, options | on_change(string) | Dropdown |
| Checkbox | value, description | on_change(bool) | Checkbox |
| HTML | value (html string) | none | HTML |
Priority 2: Composition Widgets#
| Widget | Purpose | Jupyter Equivalent |
|---|---|---|
| HBox / VBox | Layout containers | HBox / VBox |
| Output | Capture stdout/display | Output |
| FloatSlider | Decimal slider | FloatSlider |
Priority 3: Domain-Specific Widgets#
| Widget | Purpose | Inspired By |
|---|---|---|
| Plot | Simple 2D charts (SVG) | bqplot (simplified) |
| Table | Data grid display | ipydatagrid (read-only) |
| Image | Display image bytes | Image widget |
Example: Interactive Slider#
(* User code in toplevel *)
let threshold = Widget.slider ~min:0 ~max:100 ~description:"Threshold" 50;;
Widget.on_change threshold (fun v ->
let n = Widget.Int.of_json v in
Printf.printf "Threshold changed to: %d\n" n
);;
Example: Button with Output#
let count = ref 0;;
let label = Widget.html (Printf.sprintf "<b>Count: %d</b>" !count);;
let btn = Widget.button "Increment";;
Widget.on_click btn (fun () ->
incr count;
Widget.set label "value"
(`String (Printf.sprintf "<b>Count: %d</b>" !count))
);;
Example: Linked Widgets#
let slider = Widget.slider ~min:0 ~max:255 ~description:"Red" 128;;
let preview = Widget.html {|<div style="width:50px;height:50px"></div>|};;
Widget.on_change slider (fun v ->
let r = Widget.Int.of_json v in
Widget.set preview "value"
(`String (Printf.sprintf
{|<div style="width:50px;height:50px;background:rgb(%d,0,0)"></div>|} r))
);;
Implementation Phases#
Phase 1: RPC Removal & Type Cleanup#
Remove the legacy RPC layer and establish clean type definitions.
Phase 2: Widget Message Protocol#
Extend message.ml and worker.ml with widget message types.
Phase 3: Widget Manager (Worker Side)#
Implement the comm manager that tracks widget state and routes events.
Phase 4: OCaml Widget API#
Create the user-facing Widget module available in the toplevel.
Phase 5: JavaScript Widget Renderer#
Implement widget rendering in the JavaScript client.
Phase 6: Testing & Examples#
Full test coverage across all backends, example widgets, documentation.
Open Questions#
-
JSON representation in OCaml API: Use
Yojson.Safe.t? A custom minimal JSON type? RawJs.Unsafe.any? (Yojson adds a dependency; custom type is more work; Unsafe.any is untyped.) -
Widget library loading: Should the Widget module be preloaded in the worker, or loaded on demand via
#require "widget"? -
Layout model: How much of Jupyter's CSS-based layout model to support? Full flexbox control per-widget, or simpler HBox/VBox only?
-
Persistence: Should widget state survive cell re-execution? Jupyter widgets are destroyed and recreated; we could do the same or preserve state.