⚠️ WARNING: This project is highly experimental and the API will surely change. Use only for non-serious projects.
Buzz lets you write a web application using the JVM (or babashka) only. State lives on the server and can be watched and updated from client code.
This project uses Squint to compile the UI to JavaScript and Reagami to renders it.
Buzz runs on Babashka and on the JVM. You do not need other tooling like ClojureScript or Node.js.
In this project, you can run:
bb serve # a demo on http://localhost:1341
bb bench # a benchmark, on http://localhost:1342
Also take a look at tube-pod, a real application I wrote using Buzz.
Create a project with two files. deps.edn:
{:paths ["src"]
:deps {io.github.borkdude/buzz
{:git/sha "<latest>"}}}src/counter.clj:
(ns counter
(:require [buzz.core :as buzz :refer [client defui local-state server server!]]
[org.httpkit.server :as http]))
(defonce clicks (atom 0))
(defui counter []
(let [n (server @clicks)
step (local-state 1)]
[:div
[:p "clicked " n " times"]
[:button {:on-click (fn [_] (server! (swap! clicks + (client @step))))} "add"]
[:button {:on-click (fn [_] (swap! step inc))} (str "step " @step)]]))
(def ui
(buzz/handler {:title "counter"
:watch [clicks]
:mounts [{:el "app" :ui #'counter}]}))
(defn -main [& _]
(http/run-server (fn [req] (or (ui req) {:status 404 :body "not found"}))
{:port 1350})
(println "http://localhost:1350")
@(promise))Then run it:
clojure -M -m counter
The count is a server value, so it is the same for all browsers. The step is a browser value, so each browser has a different one.
The body of a component is client side code. In the body you can use four marks to communicate with the server or to make local state.
-
(server expr)is a value from the server. The server runs the expression again after each change to an observed atom and the result is sent to the browser. -
(server! expr)is way to make the server do something. It is a side effect, not a value. The return value is a promise. Using the specialreplyform, you can send a value back to the browser. Givereplya second argument to add to the http response the value arrives in, which is how a handler sets a cookie.
(server! (reply :ok {:headers {"Set-Cookie" "session=abc; HttpOnly; Path=/"}}))-
(client expr)is a client value that crosses into aserver!form. -
(local-state init)is an atom that the client can read and write. It is not sent to the server. This state survives a re-render of the app and is only created once per mount. It is not shared between browsers or tabs. The initial value can read aserverexpression, so a client atom can start from what the server sent.
You can define a part of a component with defpart. A part is like a component, but it does not have its own root element. You can use a defpart inside a defui to break it into smaller pieces.
(defpart row [item]
[:li (:title item)])Parts compile to browser functions and can call themselves. Define
(server ...) and (local-state ...) in defui, then pass their results to
the part. Parts can contain (server! ...). See doc/parts.md.
The buzz/handler function returns a Ring handler. Its event stream requires a
buzz.stream adapter. Buzz uses the bundled http-kit adapter unless the
handler spec supplies :adapter.
To compose the handler with other routes, you can use or since the handler returns nil for unknown routes. For example:
(defn app [req]
(or (ui req) (my-other-routes req)))Buzz watches each atom in :watch. When one of them changes, it re-renders the component and sends a patch to each browser. One mount can hold one component at one element. A page can have more than one mount.
Rendering is asynchronous: a write returns at once, and rendering happens at
most once per :render-interval-ms (default 20). The first write renders
immediately and writes inside the window collapse into one render carrying the
latest state, so patches are sampled state, not every state: a counter can
step from 3 to 7. Pass :render-interval-ms 0 to render synchronously on the
writing thread, which makes tests deterministic.
A mount names its component by var, so re-evaluating the component reaches the open pages:
:mounts [{:el "app" :ui #'todo-app}]The page belongs to the handler, so one application can serve more than one of them. Give a handler a :path and it answers under that path, stream and modules included.
(def admin (buzz/handler {:path "/admin" :mounts [...]})) ; the page is /admin
(def home (buzz/handler {:mounts [...]})) ; the page is /
(defn app [req] (or (admin req) (home req) {:status 404 :body "not found"}))Use (buzz/request) inside (server ...) and (server! ...) to read the
current Ring request. In (server ...), this is the request that opened the
event stream. In (server! ...), this is the RPC request.
Keep state in application atoms. Use (buzz/token (buzz/request)) as a key for
browser-scoped state and (buzz/connection (buzz/request)) for
connection-scoped state. See examples/auth for per-user state
and authentication.
(defonce queries (atom {})) ; connection id -> search text
(defn- my-query [req] (get @queries (buzz/connection req) ""))
(defn- remember! [req q] (swap! queries assoc (buzz/connection req) q))
(defui todo-app []
(let [todos (server (matching (my-query (buzz/request))))]
[:div
[:input {:on-input (fn [e] (server! (remember! (buzz/request)
(client (.. e -target -value)))))}]
...]))A reconnect gets a new connection ID. Use :on-close to remove
connection-scoped state. Buzz passes it the request that opened the connection:
(buzz/handler {:on-close (fn [req] (swap! queries dissoc (buzz/connection req))) ...})Without an :index, Buzz writes the page: a title from :title, a div per
mount holding its first render, and the two script tags. :head adds anything
else that belongs in the head, such as a stylesheet.
Give :index a file to write the page yourself:
(buzz/handler {:index "public/index.html" …})Two things in that file are then yours to place. Buzz replaces <!--el--> with
the first render of the mount at that element, and every NONCE with the one in
the Content-Security-Policy header:
<div id="app"><!--app--></div>
<script type="importmap" nonce="NONCE">
{"imports": {"squint-cljs/core.js": "https://esm.sh/squint-cljs@0.14.208/core.js"}}
</script>
<script type="module" src="/client.mjs"></script>Leave out the comment and the page still works. It arrives empty and the browser fills it in.
- examples/auth signs two users in and gives each of them their own data.
- examples/tap-viewer shows everything the process taps, with a tree the browser folds by itself.
- examples/whiteboard is a shared whiteboard with live cursors, one color per connection.
- examples/datalevin is a Datalevin browser over a MusicBrainz sample, with a query log shared between viewers.
bb dev # the demo, plus an nrepl on 1667
Evaluate a defui or a defpart again and the open page updates. Browser state
survives the update, and also a reconnect after a restart.