Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline

Quick start

Before You Begin

Install a JDK, Mill, and Node.js 18 or newer with npm. This quick start uses Scala 3.8.4 and the dev.scalive::scalive:0.0.1-e2d90be8edfd-SNAPSHOT artifact. Verify that the tools are available before creating the project:

bash
java -version
mill --version
node --version
npm --version

Each command should print a version; node --version must report v18 or newer.

Create The Project

Create this project tree:

text
scalive-quick-start/
├── build.mill
└── app/
    ├── assets/
    │   └── js/
    │       └── app.js
    ├── src/
    │   └── quickstart/
    │       ├── CounterLiveView.scala
    │       ├── Main.scala
    │       ├── RootLayout.scala
    │       └── Routes.scala
    ├── package-lock.json
    └── package.json

Create build.mill at the project root:

scala
package build

import mill.*, scalalib.*

object app extends ScalaModule:
  def scalaVersion = "3.8.4"
  def mainClass = Some("quickstart.Main")

  def repositories = Seq(
    "https://central.sonatype.com/repository/maven-snapshots"
  )

  def mvnDeps = Seq(
    mvn"dev.scalive::scalive:0.0.1-e2d90be8edfd-SNAPSHOT"
  )

  def packageJson = Task.Source(moduleDir / "package.json")
  def packageLock = Task.Source(moduleDir / "package-lock.json")
  def assetSources = Task.Sources(moduleDir / "assets")

  def bundle = Task {
    val workDir = Task.dest / "work"
    val publicDir = Task.dest / "public"

    os.copy(packageJson().path, workDir / "package.json", createFolders = true)
    os.copy(packageLock().path, workDir / "package-lock.json")
    assetSources().foreach(source =>
      os.copy(source.path, workDir / source.path.last)
    )
    os.proc("npm", "ci").call(cwd = workDir)
    os.proc("npm", "run", "build").call(cwd = workDir)
    os.copy(
      workDir / "dist" / "app.js",
      publicDir / "app.js",
      createFolders = true
    )
    PathRef(publicDir)
  }

  def resources = Task {
    super.resources() :+ bundle()
  }
end app

The :: in the dependency selects the Scala 3 artifact. Scalive publishes and supports exactly two Scala coordinates: dev.scalive::scalive, which contains all production API, render, runtime, protocol, and transport classes, and the optional test-support coordinate dev.scalive::scalive-testing. Scalive's ZIO and ZIO HTTP dependencies are supplied transitively.

Create app/package.json:

json
{
  "private": true,
  "type": "module",
  "scripts": {
    "build": "esbuild assets/js/app.js --bundle --platform=browser --format=iife --target=es2020 --outfile=dist/app.js"
  },
  "dependencies": {
    "phoenix": "1.7.21",
    "phoenix_live_view": "1.1.28"
  },
  "devDependencies": {
    "esbuild": "0.28.1"
  }
}

Generate and commit the lockfile:

bash
npm install --package-lock-only --prefix app

Mill uses npm ci to reproduce this dependency graph and places the bundled app.js in the application's classpath resources.

Connect The Browser

Create app/assets/js/app.js:

Source
javascript
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"

const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content")
const params = csrfToken ? { _csrf_token: csrfToken } : {}

const liveSocket = new LiveSocket("/live", Socket, { params })
liveSocket.connect()

window.liveSocket = liveSocket

Live.router mounts its socket at /live by default. The server injects the CSRF meta element into the root layout's <head> and binds it to a cookie. The client returns the value as _csrf_token when it opens the socket. Do not create or hard-code this token in JavaScript.

Define The LiveView

Create app/src/quickstart/CounterLiveView.scala:

Source
scala
package quickstart

import zio.{Task, ZIO}

import scalive.*

final class CounterLiveView extends LiveView[CounterLiveView.Msg, Int]:
  import CounterLiveView.Msg

  def mount(ctx: MountContext): Task[Int] =
    ZIO.succeed(0)

  def handleMessage(model: Int, ctx: MessageContext) =
    case Msg.Decrement => ZIO.succeed(model - 1)
    case Msg.Increment => ZIO.succeed(model + 1)

  override def view(model: Signal[Int]): HtmlElement[Msg] =
    mainTag(
      h1("Scalive counter"),
      button(typ          := "button", on.click(Msg.Decrement), "Decrease"),
      outputTag(aria.live := "polite", model.map(_.toString)),
      button(typ          := "button", on.click(Msg.Increment), "Increase")
    )

object CounterLiveView:
  enum Msg:
    case Decrement, Increment

The Int is all state needed to render this interface. Msg lists every input the view accepts. mount creates the state, handleMessage performs an effectful transition, and view projects the state into typed HTML.

Add Routes And Layout

Create app/src/quickstart/Routes.scala:

Source
scala
package quickstart

import scalive.*

object Routes:
  val home = live

Create app/src/quickstart/RootLayout.scala:

Source
scala
package quickstart

import scalive.*

final class RootLayout(assets: StaticAssets) extends LiveRootLayout[Any, Any]:
  def key(ctx: LiveRootLayoutContext[Any, Any]): String = "quick-start-root"

  def render[Msg](
    content: HtmlElement[Msg],
    pageTitle: Option[String],
    ctx: LiveRootLayoutContext[Any, Any]
  ): HtmlElement[Msg] =
    htmlRootTag(
      lang := "en",
      headTag(
        metaTag(charset  := "utf-8"),
        metaTag(nameAttr := "viewport", contentAttr := "width=device-width, initial-scale=1"),
        liveTitle(pageTitle, default = "Scalive quick start"),
        assets.trackedScript("app.js", defer := true, typ := "text/javascript")
      ),
      bodyTag(content)
    )

The root layout renders the complete document. Its <head> gives Scalive a place for the CSRF meta element and loads the tracked browser bundle.

Start The Server

Create app/src/quickstart/Main.scala:

Source
scala
package quickstart

import java.time.Duration

import zio.*
import zio.http.Server

import scalive.*

object Main extends ZIOAppDefault:
  override val run =
    for
      assets <- StaticAssets.load(StaticAssetConfig.classpath("public", Seq("app.js")))
      config <- ZIO
                  .fromEither(
                    ZioHttpConfig(
                      signingSecret = sys.env.getOrElse(
                        "SCALIVE_TOKEN_SECRET",
                        "local-development-secret-change-me"
                      ),
                      sessionMaxAge = Duration.ofDays(7),
                      secureCookie = false
                    )
                  ).mapError(error => new IllegalArgumentException(error.toString))
      security    = LiveSecurity(config)
      application = Live.router
                      .withRootLayout(RootLayout(assets))(
                        Routes.home -> CounterLiveView()
                      )
      liveRoutes = ZioHttp.routes(application, security)
      routes     = liveRoutes ++ assets.routes
      _ <- Server.serve(routes).provide(Server.defaultWithPort(8080))
    yield ()

The server loads the browser bundle, builds CSRF-protected Live routes, adds the static asset routes, and listens on port 8080. For local HTTP, this fixture uses a fixed development-only signing-secret fallback and sets secureCookie = false. Do not deploy those settings unchanged: production must require a stable, high-entropy SCALIVE_TOKEN_SECRET and set secureCookie = true behind HTTPS. See Configuration and Deployment.

Run It

From the project root, run:

bash
mill app.run

Open http://localhost:8080/. The HTTP request first produces disconnected HTML. The client then connects to /live, Scalive mounts an independent connected model, and button events travel over the socket as typed messages.

You are done when Mill completes the npm build, the server listens on port 8080, and the browser shows a counter starting at 0. Both buttons should update it without reloading the page.

If Something Fails

  • If Mill cannot resolve the Scalive dependency, confirm the snapshot version and repository above, then check startup troubleshooting.

  • If npm ci, bundling, or app.js fails, check missing assets.

  • If port 8080 is already in use, stop the other process or change the port in Main.scala. If the page loads but its buttons do not connect, check socket connections.