Clojure-first cross-platform server-driven UI framework.
The server is the source of truth for UI state and emits intent-shaped UI
trees as patches over a single SSE channel per client. Clients maintain a
local mirror of the tree, apply patches, render natively, and dispatch user
actions as data-shaped intents back to the server. Intents are defined once
in .cljc with a shared morph function that runs on both server
(authoritative) and client (optimistic prediction).
Components are a namespaced vocabulary defined as data; each platform binds keywords to native renderers; unsupported components fall back to webview frames seamlessly via Hotwire Native.
Mental model: Datastar's data flow + Hotwire's transport + Clojure's code-as-data + SDUI's component vocabulary.
# from a clone:
./install.sh
# or one-liner (once the repo is publicly hosted on GitHub):
curl -fsSL https://raw.githubusercontent.com/Holy-Coders/wun/master/install.sh | bashThe installer ensures babashka is on PATH, then symlinks bin/wun into
/usr/local/bin/ (or ~/.local/bin/ as a fallback). Re-running is safe.
wun doctor # check java/clojure/swift/gradle/node/bb
wun dev # server + shadow-cljs watch (Ctrl-C stops both)
wun run ios # in another terminal, open the macOS demo
wun add component myapp/Card # scaffold a multi-platform component
wun add screen myapp/profile # scaffold a screen .cljc
wun add intent myapp/login # scaffold an intent .cljc
wun new app myapp # standalone app (server + web + ios + android)
wun new pack myapp-components # reusable component pack
wun release v0.1.0 # tag + push for downstream consumptionRe-running any generator is idempotent. wun help lists every subcommand.
# 1. Have wun cloned alongside (the template assumes a sibling clone)
cd ~/code
git clone https://github.com/Holy-Coders/wun.git
wun new app myapp # creates ~/code/myapp/
cd myapp
npm install
wun dev # http://localhost:8081The template depends on Wun via local-root paths to ../wun/wun-*, so
the dev loop works immediately with no publishing. Once Wun is stable
enough to pin to a tag, swap the :local/root / path: /
includeBuild entries for git-deps / SwiftPM URL / JitPack — see the
generated app's README for the exact swap. wun release from the wun
repo cuts a tag suitable for any of those forms.
wun new app myapp --db sqlite --docker # or --db postgres / --db datomic
cd myapp && npm install && wun devThis adds a working notes feature, signup/login, an auth-gated
dashboard, structured logging, a /healthz route, a multi-stage
Dockerfile, GitHub Actions CI, and a fly.toml for fly.io. See the
generated README.md for the per-flag layout, and docs/build-a-feature-end-to-end.md
for a walkthrough of adding your own feature on top.
This is a monorepo for the cross-platform pieces called for in the brief.
Each subdirectory is self-contained and can be split into its own repo at
any time with git subtree split.
| dir | language | role |
|---|---|---|
wun-shared/ |
Clojure (cljc) | open registries shared on both sides |
wun-server/ |
Clojure | Pedestal SSE + intent endpoint |
wun-web/ |
ClojureScript | reagent-driven web client |
wun-ios/ |
Swift package | SwiftUI client + WebFrame bridge |
wun-ios-example/ |
Swift package | reference user-component pack |
wun-android/ |
Kotlin lib | Compose Multiplatform Desktop client |
wun-android-example/ |
Kotlin module | reference user-component pack |
templates/ |
- | starter pack scaffold for wun new |
cli/, bin/ |
babashka | the wun CLI |
The production-readiness pass on
claude/production-readiness-planning-1Irov brought Wun from
"end-to-end spike" to "shippable at the LiveView/Hotwire level."
Full plan and per-phase verification gates in
docs/production-readiness.md.
What that pass added, briefly:
- Hardened transport: heartbeat envelopes, bounded-buffer
backpressure with snapshot resync, token-bucket rate limiting,
HMAC-SHA256 CSRF, session-token rotation + revocation, structured
vendor-neutral telemetry (
wun.server.telemetry). - Wire envelope v2: key-aware children diffing
(
:childrenop replaces siblings reorders without re-rendering every following position), version negotiated at handshake; v1 fallback for older native clients. - Web rewrite on Replicant: zero React, zero JS deps. Same hiccup tree the renderers always produced; the substrate underneath is now a pure-Clojure VDOM with direct DOM emission.
- Forms + uploads:
:wun/Form/:wun/Fieldwith:wun.forms/change//submit//touch//resetframework intents, Malli validation merge, a streaming/uploadendpoint with progress patches piggybacking the SSE stream. - Theme primitives that cascade server → all clients:
namespaced design tokens (
:wun.color/primary,:wun.spacing/md, ...) resolved server-side before substitution; the resolved theme rides in every envelope so the web client mirrors it as CSS custom properties; iOS / Android decode + mirror the same shape. - PubSub + Presence:
wun.server.pubsub(pluggableBusprotocol; in-process default),wun.server.presenceper-topic rolls with auto-cleanup,wun.server.broadcastconvenience layer fusing pubsub + per-conn morph + re-broadcast. - Native parity: iOS + Android both decode wire v2, echo CSRF,
mirror theme, and expose host extension points
(
Wun.hostNavigatorfor HotwireNative on iOS,Wun.openUrlfor custom URL handlers on Android). - DX + ops polish:
wun.errorsboundary so a screen render exception ships an error tree instead of dropping the SSE stream;wun.server.configfor 12-factor env-var resolution;migrations/lib.bbAST-based codemods via rewrite-clj; REPL + observability docs. - Property-based tests (
clojure.test.check) for the diff round-trip, capability substitution, and theme resolution.
Test totals: 65 shared, 86 server, 286+ assertions; wun-android gradle compileKotlin clean. iOS XCTest and full Compose gradle test are deferred to a workstation that can fetch from Clojars +
google-maven (this sandbox can't).
- Real
HotwireNativeSwiftPM dependency wired in (Wun ships the extension point + integration recipe; the host app links the package). - A real Android-target
WebView(the Compose Desktop build ships a "Open in browser" fallback; on-device Android needs anAndroidView { WebView }host). - A hosted Redis / NATS pubsub backend (
Busprotocol is the swap-in surface; framework only ships the in-process impl). - Push-notification provider integration.
# 1. build the cljs client (one-time, ~10s)
cd wun-web && clojure -M:build
# 2. start the server (also serves the web client at /)
cd wun-server && clojure -M:run
# open http://localhost:8080Click the buttons; every connected tab updates because the server broadcasts the new tree to all SSE connections.
GET /wunemits a:replace-at-root patch envelope on connect, with a tree built by the registered:counter/mainscreen rendering through the registered:wun/*vocabulary.POST /intentlooks up the intent in the shared registry, applies the morph, and broadcasts the new tree to every open SSE connection. Multi-client broadcast confirmed; both clients see the same trail.- Counter trail across
inc inc inc dec reset inc:0 → 1 → 2 → 3 → 2 → 0 → 1. - Static asset serving with path-traversal protection (
/js/../../etc/passwd→ 404). - Transit-json round-trips on the wire including
:resolves-intentUUIDs. - cljs build pulls
wun-sharedvia:local/rootand bundles the shared.cljcregistries plus the foundational and app namespaces.
The brief calls for Pedestal on the server, shadow-cljs + reagent
on the web. Phase 0 ships with the JDK's built-in HttpServer and
cljs.main + a vanilla-DOM renderer because the development sandbox we
built it in cannot reach Clojars (only Maven Central). The substitution is
transport- and view-layer only:
- The wire format is identical (transit-json patch envelopes, namespaced Hiccup component trees, intent envelopes with UUIDs).
- The intent semantics are identical (
defintentregisters a:morph, applied server-authoritatively, broadcasts a full tree). - The component vocabulary is identical (
:wun/Stack,:wun/Text,:wun/Button, withdefmultidispatch on the keyword).
Phase 1 swaps Pedestal in (interceptor chain, content negotiation, NIO SSE) and reagent in (proper React reconciler, hot reload), without touching the wire format or the brief's API surface.
- The macros define the universe --
defcomponent/defscreen/defintent. Push back hard before adding API surface. - Framework code uses the same APIs as user code. No privileged path.
- Intent definitions over framework knobs. Behavior in intent metadata, not config or middleware stacks.
- Pure functions everywhere they're possible. Side effects in named,
identified places (
persist,fetch, action handlers). - Patches over re-renders. The server only emits what changed; the client only re-renders what was patched.
- Native first, webview last. WebFrame is graceful fallback, not default. Every WebFrame in production is an implicit roadmap item.
- One source of truth: UI state on the server. Local state on the client only where it doesn't belong on the server (form drafts, scroll position, ephemeral focus).
Single SSE channel per client, server-initiated. Patch envelope:
{:patches [{:op :replace :path [...] :value ...}
{:op :insert :path [...] :value ...}
{:op :remove :path [...]}]
:resolves-intent #uuid "..." ; optional
:status :ok ; or :error
:error {...}} ; when :errorIntent envelope (POST /intent):
{:intent :counter/inc
:params {}
:id #uuid "..."}UI tree -- Hiccup-shaped, namespaced components, actions as data:
[:wun/Stack {}
[:wun/Text {:variant :h1} "Aaron"]
[:wun/Button {:on-press {:intent :user/edit :params {:id 42}}} "Edit"]]The wire format is namespace-agnostic. :wun/* and :myapp/* flow
identically.
- Phase 0 -- spike. Validate the loop feels right. Done.
- Phase 1 -- server foundations + web client.
- 1.A open registries via shared
defcomponent/defscreen/defintent. Done. - 1.B tree diffing per connection; path-aware patch ops on the
client. Diff + apply live in shared
wun.diff(cljc) so producer and consumer can't drift. Done. - 1.C shared
.cljcmorphs run on web for optimistic UI; reconciliation via:resolves-intent. Envelope carries:stateso the client mirrors the screen state and can run the samewun.intents/apply-intentmorphs the server runs. Done. - 1.D-Malli intent
:paramsschemas validate at both wire boundaries; server returns 400 with a humanised explanation, client logs and drops the call before optimistic prediction or POST. Done. - 1.D-Pedestal server transport runs on Pedestal's
interceptor chain on Jetty. SSE via
sse/start-event-stream, transit-json bodies viabody-params, custom static interceptor after routing. Done. - 1.D-shadow-cljs build replaces
cljs.mainwith shadow-cljs;:advancedClosure compilation cuts the bundle from ~2.25 MB to ~533 KB (with reagent + Malli) / ~359 KB (without reagent). Done. - 1.D-reagent foundational
:wun/*renderers return reagent Hiccup; a single reagent root re-renders when thedisplay-treereagent atom changes, and React's reconciler handles incremental DOM updates. Done. - 1.E per-connection tree eviction (the brief's risk #5) and
client reconnection awareness. Server: scheduled GC sweep
actively probes each channel via a
:wun-probeSSE comment and evicts whatever Pedestal has closed. Client: pending intents tagged with timestamps; periodic JS-interval GC drops entries older than 30 s; bootstrap-frame detection (a:replaceat root) clears pending across reconnect. - 1.F capability negotiation. Client builds a caps map from
its registered web renderers and sends
?caps=wun/Stack@1,...on the SSE URL (EventSource can't set custom headers; native clients in phase 2 useX-Wun-Capabilities). Server parses per-connection, applieswun.capabilities/substituteto the rendered tree before diffing. Unsupported subtrees collapse to[:wun/WebFrame {:missing <kw>}]at the smallest containing level; the web client renders WebFrame as a placeholder today, and the iOS/Android phases swap in a real Hotwire Native frame.
- 1.A open registries via shared
- Phase 2 -- iOS native. SwiftUI renderers, WebFrame fallback,
capability negotiation end-to-end.
- 2.A server content-negotiates JSON (
Accept: application/jsonor?fmt=json) alongside transit-json; Swift package scaffolds the wire-shape types (JSON,WunNode,Patch,Envelope) with Codable + Equatable, no networking yet. Done. - 2.B Swift port of
wun.diff/apply-patches(Hiccup-aware indexing);TreeMirroractor;SSEClientwith hand-rolled byte-level line splitter (Foundation'sbytes.linescollapses empty lines, which SSE needs as frame terminators);wun-smokeexecutable for end-to-end. iOS counter trail across server intents matches the cljc smoke. Done. - 2.C
WunComponentregistry +WunViewSwiftUI driver + SwiftUI renderers for:wun/Stackand:wun/Text.TreeStore(@MainActor+ObservableObject) sits alongside the actorTreeMirrorso SwiftUI views can deref the tree reactively.WunFoundation.register(into:)mirrors the cljc-side bootstrap; user code registers components through the same API. Done. - 2.D the rest of the brief's foundational vocabulary --
:wun/Image,:wun/Button,:wun/Card,:wun/Avatar,:wun/Input,:wun/List,:wun/Spacer,:wun/ScrollView-- each as a SwiftUI renderer inFoundation/.Wun.intentDispatcheris the global hook Button/Input fire when the user acts; phase 2.E plugs in a real POST. iOS client now advertises 11 caps; the server passes the tree through without substitution. Done. - 2.E action dispatcher:
IntentDispatcherPOSTs JSON envelopes to/intentwith a generated UUID; on 400, decodes the error envelope and forwards the Malli explanation through anonErrorcallback.dispatcher.install()swaps the globalWun.intentDispatcher.wun-smokenow fires inc / by 5 / by "oops" / reset from Swift; theresolves-intentUUIDs match each dispatch's id, the 400 case lights uponErrorwith{n: ["should be an integer"]}. Done. - 2.F
:wun/WebFramefallback. Server emits[:wun/WebFrame {:missing :wun/Foo :src "/web-frames/wun%2FFoo"}]when capability negotiation finds an unsupported subtree. Server route/web-frames/<key>returns HTML the client displays in a WKWebView viaWunWebFrame.render(cross-platform SwiftUI/WKWebView bridge for iOS + macOS).Wun.serverBaseresolves the relative:src.wun.capabilities/substitutegrew an optionalsrc-builderso the URL-encoding stays server-side and the cljc stays pure. The HTML is currently a stub diagnostic; rendering the actual subtree via the web cljs renderers is a later-phase improvement. Done. - 2.G native clients now use
X-Wun-CapabilitiesandX-Wun-Formatrequest headers as the brief specifies; server reads either header or query-string. SSEClient takesheaders: [String:String]; web stays on the query-string form because EventSource can't set custom headers. Done. - 2.H reference user component shipped as a separate Swift
package:
wun-ios-example/definesWunExample(registers:myapp/Greeting) and anexample-smokeexecutable. On the server,myapp.componentscarries the matching defcomponent spec; the counter screen now opens with a:myapp/Greeting. Clients that advertise the cap render it natively; clients that don't see aWebFramesubstituted at the smallest containing subtree -- demonstrating the brief's "no privileged path" thesis on iOS. Done. - 2.I SwiftUI macOS demo.
wun-ios-example/Sources/WunDemoMac/is an@main Apptarget you can run from Xcode (openPackage.swift, pick thewun-demo-macscheme, ⌘R) or from CLI (swift run wun-demo-mac). HostsWunViewagainst a liveTreeStorefed bySSEClient; clicking buttons fires intents through the installedIntentDispatcher, server confirms via SSE, the on-screen counter updates. Done.
- 2.A server content-negotiates JSON (
- Phase 3 -- Android. Compose renderers, parity with iOS.
- 3.A Gradle/Kotlin scaffold + wire-shape types
(
WunNode,Patch,Envelope) +Diff(Kotlin port of the cljc differ). 10/10 tests mirror the SwiftEnvelopeTests+DiffTests. Done. - 3.B OkHttp-sse
SSEClient,TreeMirror,Registry,IntentDispatcher,wun-android/.../Smoke.ktrunnable viagradle run. Same intent / SSE round-trip the Swift smoke does. Done. - 3.C Compose Multiplatform Desktop renderers for the full
foundational vocabulary, plus
wun.demo.App-- agradle run-launchable window that hostsWunViewagainst a liveTreeStore.WunComponentis a@Composablefunction type,LocalWunRegistrypropagates the host's registry through the composition tree. Done.
- 3.A Gradle/Kotlin scaffold + wire-shape types
(
- Phase 4 -- shared morphs on native via SCI in JavaScriptCore / V8.
- Phase 5 -- opt-in CRDTs for collaborative components.
- Phase 6 -- hardening, ecosystem, theming, hot reload, starter templates.