Write distributed code that looks like local code. Variables from your scope automatically travel with your computation across peers.
(defn-go-remote search-and-display [client-id server-id] (go-remote client-id [server-id] ; 1. Start on client (let [query (get-search-input) filters (get-active-filters)] (let [results (<? S (go-remote server-id [query filters] ; 2. Hop to server (-> (db/search query) (apply-filters filters) (take 50))))] (<? S (go-remote client-id [results] ; 3. Back to client (render-results! results) (count results)))))))
What just happened?
- Client: Collected
queryandfiltersfrom the UI - Server: Ran database search with those values - no manual serialization needed
- Client: Rendered results -
resultstraveled back automatically
The explicit [query filters] and [results] argument lists declare what travels across the wire. The macro validates at compile-time that you're not accidentally forgetting variables.
;; deps.edn {:deps {is.simm/distributed-scope {:mvn/version "LATEST"}}}
- Scope Travels With Code: Variables are explicitly captured and serialized to remote peers
- Compile-time Safety: Missing variables = error, unused variables = warning
- Bidirectional: Hop between any connected peers (server→client→server→...)
- Cross-platform: Clojure + ClojureScript with reader conditional support
- Two Flavors:
go-remote(core.async) orsp-remote(Missionary) - Auth Ready: Works with kabel-auth for JWT/principal-based authentication
- Hot Reload: Update code without reconnecting—server via dev namespace (hawk), client via shadow-cljs
- Datahike kabel writer for CLJ/CLJS streaming
- Internal organizational app (WIP)
When you write:
(let [x 42, y "hello"] (go-remote server-id [x] ; Only 'x' is sent (+ x 1)))
The macro:
- Captures the values of variables in
[x]from your local scope - Serializes them into
{:x 42}and sends to the remote peer - Executes
(+ x 1)on the server withxbound to42 - Returns the result (
43) back to the caller
The code block is identified by its source location (line/column), so both peers must share the same codebase. Only the data travels over the wire, not the code. This approach handles reader conditionals (#?(:clj ...)) correctly since positions are stable even when syntax differs between Clojure and ClojureScript.
Client Server
┌────────────────┐ ┌────────────────┐
│ (let [x 42] │ │ │
│ (go-remote │─── {:x 42} ─▶│ (+ x 1) │
│ server │ │ ; x = 42 │
│ [x] │◀──── 43 ────│ => 43 │
│ (+ x 1))) │ │ │
└────────────────┘ └────────────────┘
Each go-remote can contain more go-remote calls, creating a chain of context switches:
(go-remote A [] (let [from-a (compute-on-a)] (<? S (go-remote B [from-a] (let [from-b (compute-on-b from-a)] (<? S (go-remote C [from-a from-b] (finalize from-a from-b))))))))
Execution flows: A → B → C, with each hop carrying exactly the variables you specify.
For core.async style with superv.async:
(require '[is.simm.distributed-scope :refer [defn-go-remote go-remote]] '[superv.async :refer [S <?]]) (defn-go-remote my-distributed-fn [peer-a peer-b] (go-remote peer-a [peer-b] (let [a-result (do-something)] (<? S (go-remote peer-b [a-result] (use-result a-result)))))) ;; Call it (<? S (my-distributed-fn peer-a-id peer-b-id))
For Missionary sequential processes:
(require '[is.simm.distributed-scope :refer [defn-sp-remote sp-remote]] '[missionary.core :as m]) (defn-sp-remote my-distributed-task [peer-a peer-b] (sp-remote peer-a [] (let [a-result (do-something)] (m/? (sp-remote peer-b [a-result] (use-result a-result)))))) ;; Call it (m/? (my-distributed-task peer-a-id peer-b-id))
;; Start REPL: clj -A:dev (require '[simple.server :as server] '[simple.client :as client] '[simple.demo :as demo] '[hasch.core :refer [uuid]] '[superv.async :refer [S <??]]) (def server-id (uuid :server)) (def client-id (uuid :client)) ;; Start peers (def started (server/start! "ws://localhost:47291" server-id)) (def _client (client/start! "ws://localhost:47291" client-id)) ;; Run distributed computation (<?? S (demo/demo server-id client-id)) ;; => [(42 42 42 ...) 44] (server/stop! started)
(require '[kabel.peer :as peer] '[is.simm.distributed-scope :refer [remote-middleware invoke-on-peer]]) (def server-id #uuid "05a06e85-e7ca-4213-9fe5-04ae511e50a0") (def server (peer/server-peer S handler server-id remote-middleware identity)) (invoke-on-peer server) (<?? S (peer/start server))
(def client-id #uuid "c14c628b-b151-4967-ae0a-7c83e5622d0f") (def client (peer/client-peer S client-id remote-middleware identity)) (invoke-on-peer client) (<?? S (peer/connect S client "ws://localhost:47291"))
Inspired by Electric Clojure's vision of seamless distributed code. Key differences:
- À la carte: No full buy-in to a reactive compiler—integrate where you need it
- P2P native: Built on kabel with no server/client distinction; peers can broadcast via pub-sub
- Your choice of async: Supports both
core.async(widespread) and Missionary (also used by Electric)
Unlike systems that serialize closures (Spark, Flink), distributed-scope only transmits immutable Clojure values. Code blocks are identified by source location (line/column), allowing reader conditionals to work correctly across CLJ/CLJS.
Related systems: Unison (content-addressed functions), Termite Scheme (distributed continuations), Links/Hop.js (tierless web programming).
Coming soon: First-class Datahike database references that can travel with scope.
# Run Clojure tests clojure -X:test # Run browser integration tests (requires Chrome) npm install ./test-browser.sh # Build JAR clojure -T:build jar # Deploy to Clojars clojure -T:build deploy
Copyright 2025 Christian Weilbach. Apache License 2.0.