A server-side reactive UI library for Scala 3, inspired by Phoenix LiveView. UI state lives on the server, and updates are pushed to clients over WebSocket.
Status: Early stage. I recently ported from some other hobby libraries to use some Li Haoyi's ecosystem (Cask, Scalatags, uPickle). Still getting things set up, s there may be some rough edges, but the example is working
Note - not published yet while working through the port!
mvnDeps:
- dev.alteration::spider:0.0.15Spider uses:
This example is also in the source code for now, until the library gets re-published, so there is an easy to run example.
import keanu.actors.ActorSystem
import spider.*
import spider.http.ResourceServer
import upickle.default.*
// State
case class CounterState(count: Int)
// Events
sealed trait CounterEvent derives ReadWriter
case object Increment extends CounterEvent
case object Decrement extends CounterEvent
object CounterEvent {
given EventCodec[CounterEvent] = EventCodec.derived
}
// WebView
class CounterWebView extends WebView[CounterState, CounterEvent] {
override def mount(
params: Map[String, String],
session: Session
): CounterState =
CounterState(params.get("initial").flatMap(_.toIntOption).getOrElse(0))
override def handleEvent(
event: CounterEvent,
state: CounterState
): CounterState =
event match {
case Increment => state.copy(count = state.count + 1)
case Decrement => state.copy(count = state.count - 1)
}
override def render(state: CounterState): String =
s"""
<div style="font-family: sans-serif; max-width: 400px; margin: 50px auto; text-align: center;">
<h1>Count: ${state.count}</h1>
<div style="display: flex; gap: 10px; justify-content: center;">
<button wv-click="Decrement">-</button>
<button wv-click="Increment">+</button>
</div>
</div>
"""
}
// Server (using Cask)
object ExampleApp extends cask.MainRoutes {
import CounterEvent.given
val actorSystem: ActorSystem = ActorSystem()
@cask.get("/counter")
def counterPage(): cask.Response[String] = WebViewPageHandler.response(
wsUrl = "ws://localhost:8080/counter",
title = "Counter Demo"
)
@cask.websocket("/counter")
def counterWs(): cask.WebsocketResult = WebViewHandler.createWsHandler(
actorSystem,
() => new CounterWebView()
)
@cask.get("/js", subpath = true)
def js(request: cask.Request): cask.Response[String] = {
val path = request.remainingPathSegments.mkString("/")
ResourceServer.serveText("", path)
}
println("Starting Spider Counter Example on http://localhost:8080/counter")
initialize()
}trait WebView[State, Event] {
def mount(params: Map[String, String], session: Session): State
def handleEvent(event: Event, state: State): State
def handleInfo(msg: Any, state: State): State = state
def render(state: State): String
def terminate(reason: Option[Throwable], state: State): Unit = {}
}Events use a sealed trait with derives ReadWriter (from uPickle) and need an EventCodec:
sealed trait MyEvent derives ReadWriter, EventCodec
case class DoThing(value: String) extends MyEvent
case object Reset extends MyEventSpider provides custom attributes for wiring up events:
import spider.html.WebViewAttributes.*
// In your render method (using Scalatags):
button(wvClick := "Increment")("+")
input(wvChange := "UpdateText", value := state.text)Available attributes:
wvClick- click eventswvChange- change eventswvInput- input events (fires on every keystroke)wvSubmit- form submitwvFocus,wvBlur- focus eventswvKeydown,wvKeyup- keyboard eventswvTarget- attach a target ID to eventswvValue- attach a value to eventswvDebounce,wvThrottle- rate limitingwvIgnore- prevent DOM updates for an element
class MyWebView extends WebView[MyState, MyEvent] {
override def afterMount(state: MyState, context: WebViewContext): Unit = {
// Called after mount, can access actor system
}
override def beforeUpdate(event: MyEvent, state: MyState, context: WebViewContext): Unit = {
// Called before processing an event
}
override def afterUpdate(event: MyEvent, oldState: MyState, newState: MyState, context: WebViewContext): Unit = {
// Called after processing an event
}
override def beforeRender(state: MyState): MyState = {
// Transform state before rendering
state
}
}WebViews run inside actors. You can send messages to other actors and receive them via handleInfo:
override def afterMount(state: State, context: WebViewContext): Unit = {
// Send message to self (received via handleInfo)
context.sendSelf(LoadData)
// Send message to another actor
context.tellPath("/user/some-actor", SomeMessage)
}
override def handleInfo(msg: Any, state: State): State = {
msg match {
case DataLoaded(data) => state.copy(data = data)
case _ => state
}
}override def onError(error: Throwable, state: State, phase: ErrorPhase): Option[State] = {
// Return Some(state) to recover, None to show error UI
Some(state.copy(error = Some(error.getMessage)))
}
override def renderError(error: Throwable, phase: ErrorPhase): String = {
s"<div>Error: ${error.getMessage}</div>"
}Spider includes a DevTools WebView for debugging. See spider.devtools.DevToolsWebView.
Apache 2.0