Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Layouts, live sessions, and mount aspects

Before You Start

You need at least two named Live route declarations and should understand that Scalive mounts once for HTTP rendering and again for the live connection. Review Routes, parameters, and navigation if your routes are still assembled from ad hoc strings.

Choose The Right Boundary

Scalive separates three application-structure concerns:

  • a LiveRootLayout renders the outer HTML document and identifies routes that can share live navigation;

  • a LiveLayout wraps rendered LiveView content inside that document; and

  • a named live session groups routes that share mount policy, layouts, token settings, and a connected-navigation boundary.

Use the router for application-wide structure, a named live session for one coherent area such as authenticated account pages, and a route modifier for one page. This keeps policy close to the broadest boundary that actually needs it. Session and route mount aspects decide whether a lifecycle may start and produce typed context. Session aspects carry claims across the HTTP-to-socket boundary; route aspects derive fresh, claimless context for each route mount. Connected-turn guards reuse that context when policy must run again before later application work. Route and session withConnectedResources modifiers attach cleanup-owned side effects after admission and context construction, before the connected page mount; they do not decide admission or produce context.

BoundaryLiveSessionMountAspectLiveRouteMountAspect
Installation scopeA named live sessionOne route declaration
InputsPhase-specific request and preceding session contextTyped destination parameters, destination URL, and preceding context
TimingDisconnected mount, initial connected mount, and connected navigation within the named live sessionEvery disconnected or connected mount of that route, including connected navigation
SerializationMinimal signed claims cross from HTTP to the connected lifecycleNo claims; output is never serialized
Failure boundaryHTTP Response when disconnected; LiveMountFailure when connectedLiveRouteMountFailure defines both HTTP and connected outcomes
Intended useIdentity and policy shared by a route groupDestination-specific loading, admission, and authorization

Install Root And Ordinary Layouts

The root layout owns <html>, <head>, assets, and <body>. Give it a stable compatibility key:

scala
val root = LiveRootLayout[Any, Any]("application-root")([Msg] =>
  (content, pageTitle, _) =>
    htmlRootTag(
      headTag(liveTitle(pageTitle, default = "My application")),
      bodyTag(content)
    )
)

Routes with the same root key may use connected navigation without replacing the document. When the key changes, Scalive falls back to a fresh HTTP request instead of trying to reuse an incompatible document shell.

Ordinary layouts wrap the LiveView inside the selected root:

scala
val applicationShell = LiveLayout[Any, Any]([Msg] =>
  (content, _) =>
    div(
      headerTag(a(href := "/", "My application")),
      mainTag(content)
    )
)

val router = Live.router
  .withRootLayout(root)
  .withLayout(applicationShell)

Ordinary layouts are signal-backed view graphs. Their params, request, and currentUrl context values are read-only Signals, so route-dependent chrome updates without reconstructing the layout:

scala
val routeShell = LiveLayout[WorkspaceId, CurrentUser]([Msg] =>
  (content, ctx) =>
    div(
      dataAttr("workspace") := ctx.params.map(_.value),
      p("Signed in as ", ctx.context.name),
      mainTag(content)
    )
)

Root-layout context remains value-backed because the root document is rendered only for the disconnected HTTP response; connected diffs patch the LiveView inside it.

Router layouts are outermost, followed by session layouts and route layouts. Within one level, registration order is preserved. Root layouts do not compose: a route root overrides a session root, which overrides the router root.

Reuse Layouts Across Contexts

A shared shell should depend on the small context it needs, rather than an application-wide Any or a runtime search through accumulated context tuples. Use forContext to adapt an existing root layout to another context type:

scala
// documentRoot expects DocumentContext; AccountContext contains .document.
val accountRoot = documentRoot.forContext[AccountContext](_.document)

Ordinary layouts provide the same forContext operation. Read this as "use this layout for AccountContext, selecting the context it needs." It returns a reusable layout value; it does not change the route or session's context or construct a different layout for each request.

When the selection is local to installation, pass the selector directly:

scala
Live.session("account")
  .withMountAspect(accountContext)
  .withRootLayout(documentRoot, _.document)
  .withLayout(documentLayout, _.document)

The builder supplies the selector's input type. These two-argument overloads are available on typed session builders, including admitted sessions, and on route builders after a mount aspect, before selecting .params or .query. Direct installation remains available when the layout already accepts the current context. The separate second argument keeps .withRootLayout(root)(routes...) unambiguous.

Selection uses the context available where the layout is installed. Appending another mount aspect or admission afterward preserves that selection; it does not make the layout consume the final accumulated context. Only the layout's context value is adapted. Parameters, request, URL, message type, content, and root page title retain their existing behavior; ordinary layout inputs remain the original signals.

Extending a route prefix with / also preserves its installed layouts and root layout. They continue to receive the path parameters available at installation, projected from the extended path through the path codec's combiner; they do not suddenly consume the destination's larger parameter tuple. This automatic parameter projection is separate from forContext.

Selectors must be pure and deterministic. A root layout applies the selector independently for key and render, not once per mount. Including language in a root key makes a language-dependent shell incompatible with a different-language shell; projection alone does not make <html lang> reactive during connected patches. Equal root keys also do not bypass named-session navigation boundaries. The identity root remains unchanged when adapted, preserving the default document shell without evaluating a selector for context it does not use.

Router-level installation remains context-independent and accepts LiveRootLayout[Any, Any]. Install a context-dependent root at a typed session or route boundary instead. Plain route builders do not infer a layout context from a later .context(factory) call.

This executable integration example shares one document shell across public and account contexts, retains an earlier layout through another aspect, and overrides the root for one mounted route. Its fixed context aspects demonstrate composition, not authentication; use real admission and authorization for protected routes.

Source
scala
object LayoutContextExample:
  final case class DocumentContext(language: String)
  final case class PublicContext(document: DocumentContext)
  final case class AccountContext(document: DocumentContext)

  final class EventlessPage(content: String, title: String) extends LiveView.Eventless[String]:
    def mount(ctx: MountContext)                          = ZIO.succeed(content)
    override def pageTitle(model: String): Option[String] = Some(title)
    def view(model: Signal[String])                       = div(idAttr := "page", model)

  val documentLayout = LiveLayout[Any, DocumentContext]([Msg] =>
    (content, context) =>
      sectionTag(
        idAttr                  := s"document-${context.context.language}",
        dataAttr("params")      := context.params.map(_.toString),
        dataAttr("request-url") := context.request.map(_.url.encode),
        dataAttr("current-url") := context.currentUrl.map(_.encode),
        content
      )
  )
  val documentRoot = LiveRootLayout.dynamic[Any, DocumentContext](context =>
    s"document:${context.context.language}"
  )([Msg] =>
    (content, pageTitle, context) =>
      htmlRootTag(
        lang := context.context.language,
        headTag(titleTag(pageTitle.getOrElse("Scalive"))),
        bodyTag(
          dataAttr("params")      := context.params.toString,
          dataAttr("request-url") := context.request.url.encode,
          dataAttr("current-url") := context.currentUrl.encode,
          content
        )
      )
  )
  val applicationLayout =
    LiveLayout[Any, Any]([Msg] => (content, _) => mainTag(idAttr := "application", content))
  val applicationRoot = LiveRootLayout[Any, Any]("application-root")([Msg] =>
    (content, pageTitle, _) =>
      htmlRootTag(
        lang := "en",
        headTag(titleTag(pageTitle.getOrElse("Scalive"))),
        bodyTag(content)
      )
  )

  val publicContext = LiveSessionMountAspect.fromRequest[Any, String, PublicContext](
    _ => ZIO.succeed("fr" -> PublicContext(DocumentContext("fr"))),
    (language, _) => ZIO.succeed(PublicContext(DocumentContext(language)))
  )
  val accountContext = LiveSessionMountAspect.fromRequest[Any, String, AccountContext](
    _ => ZIO.succeed("de" -> AccountContext(DocumentContext("de"))),
    (language, _) => ZIO.succeed(AccountContext(DocumentContext(language)))
  )
  val accountAudit = LiveSessionMountAspect.make[Any, AccountContext, String, String](
    (_, _) => ZIO.succeed("audited" -> "audited"),
    (claim, _, _) => ZIO.succeed(claim)
  )
  val accountVariant =
    LiveRouteMountAspect.make[Any, Unit, (AccountContext, String), DocumentContext]((_, _) =>
      ZIO.succeed(DocumentContext("de-CH"))
    )

  val defaultRoute = scalive.live / "default" -> EventlessPage("default", "Default title")
  val publicRoute  = (scalive.live / "public").context { (context: PublicContext) =>
    EventlessPage(s"public-${context.document.language}", "Public title")
  }
  val publicSession = scalive.Live
    .session("public")
    .withMountAspect(publicContext)
    .withLayout(documentLayout.forContext[PublicContext](_.document))
    .withRootLayout(documentRoot.forContext[PublicContext](_.document))(publicRoute)

  val accountBuilder = scalive.Live
    .session("account")
    .withMountAspect(accountContext)
    .withLayout(documentLayout, _.document)
    .withRootLayout(documentRoot, _.document)
    .withMountAspect(accountAudit)
  val accountRoute = (scalive.live / "account").context { (context: (AccountContext, String)) =>
    EventlessPage(s"account-${context._1.document.language}-${context._2}", "Account title")
  }
  val accountOverride = (scalive.live / "account" / "variant")
    .withMountAspect(accountVariant)
    .withLayout(documentLayout, _._2)
    .withRootLayout(documentRoot, _._2)(EventlessPage("account-variant", "Variant title"))

  val application = scalive.Live.router
    .withLayout(applicationLayout)
    .withRootLayout(applicationRoot)(
      defaultRoute,
      publicSession,
      accountBuilder(accountRoute, accountOverride)
    )
end LayoutContextExample

Group Routes In A Named Live Session

A named live session applies common modifiers to several routes and defines which routes may use live navigation together:

scala
val accountRoutes = Live
  .session("account")
  .withLayout(accountLayout)(
    (live / "account") -> AccountLiveView(),
    (live / "account" / "settings") -> SettingsLiveView()
  )

val routes = router(accountRoutes)

Live-session names must be unique in one router. Treat the name as application structure, not as a browser session identifier or authentication record. A named live session groups route behavior; your service still owns login state, expiry, and revocation.

Derive Session Context Before Mount

A LiveSessionMountAspect runs before both the disconnected HTTP mount and the fresh connected mount. It can reject the request or produce typed context required by routes in the named session:

scala
val account = Live
  .session("account")
  .withMountAspect(currentUser)(
    (live / "account").context(AccountLiveView.apply)
  )

The route factory receives the aspect result. The compiler rejects a route whose factory requires context that preceding aspects did not provide. The combined example shows this context flowing through session and route admission.

Session aspects are claim-bearing and can be installed only on Live.session builders through withMountAspect or as the aspect passed to withAdmission. Aspects compose from left to right with ++. Each aspect receives the preceding context and may append another typed value. Prefer a small domain value such as CurrentUser over passing the complete request or a general service container.

Treat Mount Phases Independently

The disconnected callback sees the browser's original HTTP request, including cookies and headers. The connected callback receives a request synthesized from the socket join URL; it does not retain those original cookies, headers, method, or body.

A session aspect therefore returns two values during disconnected mount:

  • JSON-serializable claims that cross the phase boundary in the signed LiveView session; and

  • context used only by that disconnected LiveView instance.

The connected callback decodes the claims and independently produces fresh context for the initial join and connected navigation within the named live session. Claims are signed but not encrypted and remain visible to the client. Never put passwords, cookie values, access tokens, or other secrets in them. Transfer the smallest non-secret identifier and revalidate mutable authorization state before connected mount. HTTP-only cookies and headers must be consumed at an ordinary HTTP/document boundary or by a session aspect during disconnected mount; route aspects intentionally cannot read them.

Derive Fresh Context For Every Route Mount

A LiveRouteMountAspect is claimless. It receives a LiveRouteMountRequest containing the route's typed path parameters and URL, plus any typed context supplied by its named live session or a preceding route aspect. Install it only on a route builder through withMountAspect:

scala
val authorizeWorkspace =
  LiveRouteMountAspect.make[Any, String, CurrentUser, WorkspaceAccess] {
    (request, currentUser) =>
      workspaces.authorize(currentUser, WorkspaceId(request.params))
        .mapError(_ => LiveRouteMountFailure.notFound("workspace unavailable"))
  }

val workspace =
  (live / "workspaces" / PathCodec.string("id"))
    .withMountAspect(authorizeWorkspace)
    .context(WorkspaceLiveView.apply)

The aspect runs for disconnected HTTP rendering, the initial connected join, and every connected navigation to the route within the named live session. Its result is freshly derived each time and is never serialized into a token or trusted across route changes.

A live patch is different: it keeps the current lifecycle mounted and calls handleParams, so it does not rerun the route aspect. Use route aspects to admit a mounted destination. If a patch can change the protected resource identity, reauthorize that identity in handleParams and again at each sensitive operation, or navigate instead so the destination receives fresh route admission.

Combine Session And Route Context

This complete pipeline authenticates the application session at the named live session's boundary, binds each physical connection to its public ID, and then authorizes the typed destination:

scala
import scalive.*
import zio.*
import zio.http.*
import zio.http.codec.PathCodec
import zio.json.*

final case class SessionId(value: String) derives JsonCodec
final case class AuthClaims(sessionId: SessionId) derives JsonCodec
final case class CurrentUser(id: Long, name: String)
final case class AuthenticatedSession(sessionId: SessionId, currentUser: CurrentUser)
final case class WorkspaceAccess(workspaceId: String, canEdit: Boolean)

trait Authentication:
  def authenticate(request: Request): IO[Response, AuthenticatedSession]
  def resume(sessionId: SessionId): IO[LiveMountFailure, CurrentUser]

trait Workspaces:
  def authorize(
    user: CurrentUser,
    workspaceId: String
  ): IO[LiveRouteMountFailure, WorkspaceAccess]

final class WorkspaceLiveView(user: CurrentUser, access: WorkspaceAccess)
    extends LiveView.Eventless[Unit]:
  def mount(ctx: MountContext) = ZIO.unit
  def view(model: Signal[Unit]) = div(s"${user.name}: ${access.workspaceId}")

val currentUser =
  LiveSessionMountAspect.fromRequest[Authentication, AuthClaims, CurrentUser](
    request =>
      ZIO.serviceWithZIO[Authentication](_.authenticate(request.request)).map { authenticated =>
        AuthClaims(authenticated.sessionId) -> authenticated.currentUser
      },
    (claims, _) =>
      ZIO.serviceWithZIO[Authentication](_.resume(claims.sessionId))
  )

val workspaceAccess =
  LiveRouteMountAspect.make[Workspaces, String, CurrentUser, WorkspaceAccess] {
    (destination, user) =>
      ZIO.serviceWithZIO[Workspaces](_.authorize(user, destination.params))
  }

val workspaceRoute =
  (live / "workspaces" / PathCodec.string("workspaceId"))
    .withMountAspect(workspaceAccess)
    .context((context: (CurrentUser, WorkspaceAccess)) =>
      new WorkspaceLiveView(context._1, context._2)
    )

val accountRoutes = Live
  .session("account")
  .withAdmission(currentUser)(_.sessionId)(workspaceRoute)

The default ContextAppend discards only the initial Any identity context. After that it accumulates each output as a pair without flattening: here the route factory receives (CurrentUser, WorkspaceAccess); one more aspect would produce ((CurrentUser, WorkspaceAccess), NextContext). Define a custom ContextAppend only when a domain-specific accumulated type is clearer and its left projection can recover the preceding context.

Mount admission does not rerun merely because another application turn arrives while a view remains mounted. Append a route guardConnectedTurns or session guardConnectedTurns after the aspects or admission that produce its context when policy must be checked before every later application turn. Session guards run before route guards and are inherited by nested LiveViews. See Lifecycle hooks for the complete scope, ordering, and controlled outcomes.

Attach Connected Resource Initialization

Install withConnectedResources on the named live-session builder for every root mounted in that group, or on a route builder for one destination. It is available on plain, typed, and admitted session builders and on ordinary and routed route builders. Each callback receives the builder's current context and ConnectedResources, returning Task[Result] with Result <: Unit.

Install after the aspect that supplies the callback's required context. A plain builder supplies Any; a later .context(factory) is not a context declaration for an earlier initializer. If another aspect or admission is appended afterward, the initializer retains its installation-time context via projection. Its execution still waits for all session and route admission/context checks, including those declared after it. It cannot provide context to later aspects or return a handle to the page model. Capture services or use the supplied typed context; the callback's Task does not introduce another environment requirement.

Modifiers compose additively: session initializers run first, then route initializers, in declaration order within each boundary. They run lazily before connected page mount, never during disconnected rendering or rejected admission. Page factories and render compilation may already have run. Callback failures are mount failures rather than controlled authentication rejections; execution stops and the same connected resource scope releases prior successful acquisitions.

Even a session-installed initializer belongs to each connected root lifecycle, not the session name, login session, or WebSocket. It runs anew on reconnect or navigation, not on patches or ordinary messages, and is not inherited by nested LiveViews. Use a service for shared session ownership. See route and session resources for the executable example, bounded acquisition/finalization requirements, and when to keep acquisition directly in the page's mount.

Use Failure Semantics Deliberately

Disconnected session-aspect failures are ordinary HTTP responses. Connected session failures use LiveMountFailure:

  • redirect to a typed location when the visitor can recover elsewhere;

  • reject as unauthorized when no navigation is appropriate; or

  • report stale state when the browser should reload.

Build session authentication with LiveSessionMountAspect.fromRequest: the disconnected callback validates the original request and returns minimal signed claims, while the connected callback revalidates those claims and loads fresh authorization context. Either callback can redirect to login using its phase-specific failure type. Cookie validation, expiry, revocation, and claims resumption remain application policy.

Route aspects fail with LiveRouteMountFailure. Its standard outcomes preserve HTTP semantics while producing a controlled connected rejection:

  • redirect uses a typed location in both phases; redirectUnsafe accepts an unchecked URL and is only for trusted destinations;

  • unauthorized, forbidden, and notFound render HTTP 401, 403, and 404 respectively, while all reject connected admission as unauthorized; and

  • custom supplies an explicit disconnected Response and connected LiveMountFailure for uncommon policies.

Use notFound when hiding record existence is part of policy, forbidden when the HTTP distinction is useful, and unauthorized for missing authentication. These mount outcomes do not replace per-turn guards or domain authorization at the operation that reads or changes protected state.

Continue with Authentication for a complete runnable flow.