Spoonbill is a server-side single-page application framework for Scala 3. The browser runs a minimal JS bridge (~6kB). The server owns all application state and renders UI updates. Client and server are combined into a single app without any REST protocol in the middle.
- Lightning-fast page loading speed (~6kB of uncompressed JS)
- Comparable to static HTML client-side RAM consumption
- Indexable pages out of the box
- Routing out of the box
- Build extremely large app without increasing size of the page
- No need to make CRUD REST service
- Connect to infrastructure (DBMS, Message queue) directly from application
// build.sbt
libraryDependencies += "com.natural-transformation" %% "spoonbill" % "<version>"See the examples directory for working projects.
Spoonbill is a server-side SPA framework built on top of Avocet. The browser runs a small JS bridge. The server owns the application state and renders UI updates.
Spoonbill keeps the "real app" on the server. The client is responsible for:
- applying DOM changes sent by the server
- sending user events back to the server
flowchart LR
Browser["Browser<br/>Spoonbill JS bridge"] <-->|WebSocket| Frontend["Frontend"]
Frontend --> App["ApplicationInstance<br/>(one session)"]
App --> State["StateManager / StateStorage"]
App --> Render["Avocet DiffRenderContext<br/>render + diff"]
App --> Router["Router (optional)"]
ApplicationInstance[F, S, M]: one running session. It owns the render loop, state stream, message stream, and theFrontend.Frontend[F]: parses incoming client messages into typed streams (domEventMessages,browserHistoryMessages) and sends DOM changes back.ComponentInstance[F, ...]: applies the Avocet document to the render context, collects event handlers, and runs transitions.Effect[F[_]]: Spoonbill abstracts over the effect type (Future / cats-effect / ZIO / etc).
When state changes, Spoonbill re-renders and computes a diff using Avocet. The server then ships a compact "DOM patch" to the browser.
sequenceDiagram
participant B as Browser
participant F as Frontend
participant A as ApplicationInstance
participant C as ComponentInstance
participant L as Avocet DiffRenderContext
B->>F: DomEventMessage(targetId, eventType, eventCounter)
F->>A: domEventMessages stream
A->>C: look up handlers by (targetId,eventType)
C->>C: transition() -> enqueue state update
A->>A: onState(...) render tick
A->>L: swap() + reset()
A->>C: applyRenderContext(...)
A->>L: finalizeDocument()
A->>F: performDomChanges(diff)
F->>B: apply DOM patch
Spoonbill relies on Avocet's deterministic ids (like 1_2_1) for:
- mapping DOM events back to server-side handlers
- ensuring events from an outdated DOM are ignored
The client attaches an eventCounter to each event.
The server tracks counters per (targetId, eventType) and only accepts events that match the current counter.
After handling a valid event, it increments the counter and tells the client the new value.
ApplicationInstance.initialize() has two important startup modes:
- pre-rendered page: the browser already shows the initial DOM, so Spoonbill registers handlers and starts streams without needing an initial diff.
- dev mode saved render-context: when
spoonbill.dev=trueand a saved render-context exists, Spoonbill loads it and diffs against it to update the browser after reloads.
flowchart TD
Init["initialize()"] --> Check{"dev mode saved?"}
Check -- yes --> Saved["load saved render-context<br/>diff + apply changes"]
Check -- no --> Pre["pre-rendered page<br/>no initial diff"]
Saved --> Streams["start dom/history/state streams"]
Pre --> Streams
Spoonbill includes a renderer-level testkit (modules/testkit) for testing UI behavior without running a real browser session.
Key pieces:
PseudoHtml.render(dom)renders an Avocet node to pseudo DOM and keeps:- DOM id to
ElementIdmapping - event handlers
- DOM id to
Browser.event(...)simulates event propagation and returns captured actions (Publish,Transition,PropertySet, and so on).
PseudoHtml provides:
byAttrEquals(name, value)for exact attribute matchingfirstByTag(tagName)for first-match tag lookup
val pd = PseudoHtml.render(dom).pseudoDom
val submitButtonId = pd.byAttrEquals("name", "submit").headOption.map(_.id)
val firstDivId = pd.firstByTag("div").map(_.id)When a renderer uses explicit elementId(...), tests can target that Spoonbill id directly:
val clickTarget = elementId(Some("click-target"))
val actionsF =
Browser().eventByElementId(
state = initialState,
dom = dom,
event = "click",
targetElementId = clickTarget
)For full control, use the overload that exposes the Avocet-to-ElementId map:
Browser().event(
state = initialState,
dom = dom,
event = "click",
target = (_, elementMap) =>
elementMap.collectFirst { case (domId, eid) if eid == clickTarget => domId },
eventData = ""
)nix develop --command sbt testSpoonbill is a continuation and rebranding of Korolev, originally created by Aleksey Fomkin.
Breaking changes from Korolev:
- Renamed from Korolev to Spoonbill
- Scala 3 only — Scala 2.13 support has been dropped
- Avocet replaces the Levsha virtual DOM library
For the original article: