Datastar SDK for Clojerl on the BEAM VM.
| Component | Version |
|---|---|
| Clojerl | 0.9.1 |
| Erlang/OTP | 27 or 28 |
| Cowboy | 2.13 |
| Datastar | 1.0.2 |
| rebar3 | newer than 3.14 |
| rebar3_clojerl | 0.8.8 |
Starbeam pins Clojerl 0.9.1 and supports Erlang/OTP 27 and 28. OTP's built-in json module supplies JSON encoding and decoding.
Replace 0.9.1 with the tag shown on the releases page when a newer release exists.
git clone --depth 1 --branch 0.9.1 https://github.com/antlobach/clojerl.git
cd clojerl
make
make replSee the Clojerl README for platform-specific setup.
Add Clojerl, Cowboy, and Starbeam to rebar.config:
{deps,
[{clojerl,
{git, "https://github.com/antlobach/clojerl.git", {tag, "0.9.1"}}},
{cowboy, "2.13.0"},
{starbeam,
{git, "https://github.com/antlobach/starbeam.git", {branch, "main"}}}]}.
{plugins,
[{rebar3_clojerl, "0.8.8"}]}.Compile the project:
rebar3 clojerl compileFor a new application, install rebar3_clojerl globally in ~/.config/rebar3/rebar.config:
{plugins, [{rebar3_clojerl, "0.8.8"}]}.Then create and compile an OTP application:
rebar3 new clojerl_app demo
cd demo
# Replace the generated rebar.config with the dependencies above.
rebar3 clojerl compileClojerl application names cannot contain dashes.
Run the REPL from the project root:
rebar3 clojerl replLoad Starbeam:
(require '[starbeam.core :as starbeam])Cowboy calls handler functions directly from Clojerl namespaces. Re-evaluate a defn in the running REPL to replace it for subsequent requests.
Project commands:
rebar3 clojerl compile
rebar3 clojerl test
rebar3 clojerl replCreate src/demo/events.clje:
(ns demo.events
(:require [starbeam.core :as starbeam]))
(defn init [request state]
(let [stream (starbeam/open! request)]
(starbeam/patch-elements!
stream
"<main id=\"app\">Hello from Starbeam</main>")
(starbeam/close! stream)
#erl[:ok (starbeam/request stream) state]))Compile and start the REPL:
rebar3 clojerl compile
rebar3 clojerl replStart Cowboy and route /events to the handler:
(#erl application/ensure_all_started :cowboy)
(let [handlers (#erl erlang/tuple_to_list
#erl[#erl["/events" :demo.events nil]])
routes (#erl erlang/tuple_to_list
#erl[#erl[:_ handlers]])
dispatch (#erl cowboy_router/compile routes)
socket-options (#erl erlang/tuple_to_list
#erl[#erl[:port 8080]])]
(#erl cowboy/start_clear
:starbeam-demo
#erl{:socket_opts socket-options}
#erl{:env #erl{:dispatch dispatch}}))Inspect the stream:
curl -N http://127.0.0.1:8080/eventsExpected event:
event: datastar-patch-elements
data: elements <main id="app">Hello from Starbeam</main>
Stop the listener from the REPL:
(#erl cowboy/stop_listener :starbeam-demo)Open the endpoint from Datastar:
<body data-init="@get('/events')">
<main id="app"></main>
</body>Handler sequence:
(let [stream (starbeam/open! request)]
(starbeam/patch-elements! stream html)
(starbeam/close! stream)
#erl[:ok (starbeam/request stream) state])open! sends status 200 and starts the response with these headers:
Cache-Control: no-cache
Content-Type: text/event-stream
HTTP/1.1 responses also receive Connection: keep-alive.
request returns the updated Cowboy request; return it from the handler. close! sends the final body marker. Leave long-lived subscriptions open until their owner process exits.
Only the process that called open! may write to the stream. Other processes notify the owner with BEAM messages. Process ownership preserves event order without locks.
Cowboy calls terminate/3 when each /time loop handler stops. Log the handler PID and direct TCP peer to identify the request:
(defn terminate [reason request _state]
(let [handler-pid (#erl erlang/self)
peer (#erl cowboy_req/peer request)]
(#erl io/format
"Clock client pid=~p peer=~p terminated reason=~p~n"
(clj_rt/to_list.1 [handler-pid peer reason])))
nil)Run two clients:
curl --no-buffer http://127.0.0.1:8080/timeStop one client with Ctrl-C. Its PID and peer appear in the REPL; the other stream remains open. Reconnection creates a new handler PID and usually a new source port. Behind a reverse proxy, cowboy_req/peer identifies the proxy.
Re-evaluate terminate directly. Route dispatch remains unchanged, so apply-routes! is unnecessary.
Send complete HTML elements:
(starbeam/patch-elements!
stream
"<main id=\"app\"><h1>Current state</h1></main>")Pass patch options as the third argument:
(starbeam/patch-elements!
stream
"<main id=\"app\">Updated</main>"
{:selector "#app"
:mode :inner
:use-view-transition? true
:view-transition-selector "#app"
:namespace :html
:event-id "view-42"
:retry-duration 2000})Element options:
| Option | Default | Values |
|---|---|---|
:selector |
omitted | CSS selector string |
:mode |
:outer |
:outer, :inner, :replace, :prepend, :append, :before, :after, :remove |
:use-view-transition? |
false |
boolean |
:view-transition-selector |
omitted | CSS selector string |
:namespace |
:html |
:html, :svg, :mathml |
:event-id |
omitted | string |
:retry-duration |
1000 |
non-negative integer in milliseconds |
Remove an element without sending HTML:
(starbeam/patch-elements!
stream
nil
{:selector "#flash" :mode :remove})Starbeam accepts Clojerl strings, Erlang binaries, and valid iodata. It does not parse HTML. Each supplied top-level item must be a complete element.
Send an RFC 7386 JSON Merge Patch using pre-encoded JSON:
(starbeam/patch-signals!
stream
"{\"ready\":true,\"count\":4}")OTP's json module encodes Erlang JSON terms:
(starbeam/patch-signals!
stream
#erl{"ready" true
"count" 4})Set missing signals without replacing existing values:
(starbeam/patch-signals!
stream
#erl{"theme" "dark"}
{:only-if-missing? true
:event-id "signals-9"
:retry-duration 2000})Pre-encoded JSON passes through unchanged. Decoded and encoded values remain Erlang terms, avoiding another copy of the object graph.
execute-script! appends a <script> element to body:
(starbeam/execute-script!
stream
"document.querySelector('#search').focus()")Add attributes or keep the script element after execution:
(starbeam/execute-script!
stream
"console.log('connected')"
{:auto-remove? false
:attributes {"type" "application/javascript"
"data-source" "starbeam"}
:event-id "script-3"
:retry-duration 2000}):auto-remove? defaults to true and adds data-effect="el.remove()". Starbeam treats script content as trusted developer-authored JavaScript. Do not interpolate untrusted input into it.
read-signals uses the Datastar SDK request contract:
| Method | Signal location |
|---|---|
GET |
URL-decoded datastar query parameter |
DELETE |
URL-decoded datastar query parameter |
POST |
JSON request body |
PUT |
JSON request body |
PATCH |
JSON request body |
Decode signals and return 204:
(ns demo.command
(:require [starbeam.core :as starbeam]))
(defn init [request state]
(let [[signals request] (starbeam/read-signals request)
_ (when-not (#erl erlang/is_map signals)
(throw (ex-info "Expected a signal object."
{:signals signals})))
;; Validate and commit `signals` before replying.
request (#erl cowboy_req/reply 204 request)]
#erl[:ok request state]))read-signals returns [signals updated-request]. Use the returned request after reading a body because Cowboy request values are immutable.
Decoded JSON uses Erlang maps with binary keys, lists, binaries, numbers, booleans, and the JSON null atom. Missing signal data, malformed JSON, unsupported methods, and body-read failures raise exceptions.
Keep commands and long-lived queries separate at the application layer:
(ns demo.subscription
(:require [starbeam.core :as starbeam]))
(defn- render-main []
"<main id=\"app\"><h1>Authoritative state</h1></main>")
(defn- stream-loop [stream]
(starbeam/patch-elements! stream (render-main))
(receive*
:refresh (stream-loop stream)
:stop (starbeam/close! stream)))
(defn init [request state]
(let [stream (starbeam/open! request)]
(stream-loop stream)
#erl[:ok (starbeam/request stream) state]))A command path should:
- Validate the request.
- Commit the state change.
- Notify subscription processes with
:refresh. - Return
204without an HTML patch.
Each subscription process should:
- Open its SSE response.
- Read authoritative state.
- Render and send a complete element.
- Wait for a refresh notification.
- Repeat from authoritative state.
A notification means state may have changed; it carries no HTML delta. Reconnection renders current state and needs no event replay.
(starbeam/open! request)
(starbeam/request stream)
(starbeam/patch-elements! stream elements)
(starbeam/patch-elements! stream elements options)
(starbeam/patch-signals! stream signals)
(starbeam/patch-signals! stream signals options)
(starbeam/execute-script! stream script)
(starbeam/execute-script! stream script options)
(starbeam/read-signals request)
(starbeam/close! stream)Event operations return :ok or propagate the underlying error. Each operation builds one iodata frame and sends one Cowboy stream_body message.
Starbeam provides:
- Datastar SDK event framing
- Cowboy SSE response setup
- Element patches
- Signal merge patches
- Script execution events
- Datastar signal decoding
Applications own routing, rendering, persistence, authentication, supervision, pub/sub, compression, and browser assets.
rebar3 clojerl compile
rebar3 clojerl testBuild the optional Cowboy HTTP/3 profile:
rebar3 as http3 compileThe HTTP/3 profile adds Quicer and Cowboy's QUIC adapter. Starbeam uses the same handler API across HTTP/1.1, HTTP/2, and HTTP/3.
Integration tests start Cowboy and cover exact SSE frames, response headers, signal decoding for every supported method, stream ownership and closure, payload limits, concurrent subscribers, and middleware cookies.
