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
LiveRootLayouttrait LiveRootLayout[-A, -Ctx]Declaratively renders and identifies the outer document shell. renders the outer HTML document and identifies routes that can share live navigation;a
LiveLayouttrait LiveLayout[-A, -Ctx]Declaratively wraps a LiveView while preserving its message type. wraps rendered LiveView content inside that document; anda 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.
| Boundary | LiveSessionMountAspect | LiveRouteMountAspect |
|---|---|---|
| Installation scope | A named live session | One route declaration |
| Inputs | Phase-specific request and preceding session context | Typed destination parameters, destination URL, and preceding context |
| Timing | Disconnected mount, initial connected mount, and connected navigation within the named live session | Every disconnected or connected mount of that route, including connected navigation |
| Serialization | Minimal signed claims cross from HTTP to the connected lifecycle | No claims; output is never serialized |
| Failure boundary | HTTP Response when disconnected; LiveMountFailure when connected | LiveRouteMountFailure defines both HTTP and connected outcomes |
| Intended use | Identity and policy shared by a route group | Destination-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:
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:
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:
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 forContextdef forContext[NewContext](select: NewContext => Ctx): LiveRootLayout[A, NewContext]Adapts this document shell to another context type by selecting the context it needs. to adapt an
existing root layout to another context type:
// documentRoot expects DocumentContext; AccountContext contains .document.
val accountRoot = documentRoot.forContext[AccountContext](_.document)Ordinary layouts provide the same
forContextdef forContext[NewContext](select: NewContext => Ctx): LiveLayout[A, NewContext]Adapts this layout to another context type by selecting the context it needs. 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:
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.
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 LayoutContextExampleView source (documentation/site/src/scalive/docs/examples/LayoutContextExample.scala:8-102)
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:
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 LiveSessionMountAspectclass LiveSessionMountAspect[R, -In, Claims, Ctx]Produces session context immediately before disconnected and connected LiveView mount.
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:
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 LiveRouteMountAspectclass LiveRouteMountAspect[R, A, -In, Ctx]Derives fresh typed route context before every disconnected or connected route mount. is
claimless. It receives a LiveRouteMountRequestclass LiveRouteMountRequest[+A](params: A, url: zio.http.URL)
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:
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:
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
guardConnectedTurnsdef guardConnectedTurns[Result <: Unit](guard: Ctx => zio.IO[LiveConnectedTurnFailure, Result]): LiveRouteMountAspectBuilder[R, A, Need, Ctx]Appends a policy check before each connected application turn.
or session
guardConnectedTurnsdef guardConnectedTurns[Result <: Unit](guard: Ctx => zio.IO[LiveConnectedTurnFailure, Result]): LiveSessionBuilder.Admitted[R, Ctx]Appends a policy check before each connected application turn.
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 LiveMountFailureenum LiveMountFailureStops connected mount admission before a LiveView lifecycle is installed.:
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.fromRequestdef fromRequest[R, Claims, Ctx](disconnected: LiveSessionMountRequest => zio.ZIO[R, zio.http.Response, (Claims, Ctx)], connected: (Claims, LiveSessionMountRequest) => zio.ZIO[R, LiveMountFailure, Ctx])(using evidence$1: zio.json.JsonCodec[Claims]): LiveSessionMountAspect[R, Any, Claims, Ctx]Creates a session aspect that does not consume context from a preceding aspect.:
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
LiveRouteMountFailureenum LiveRouteMountFailureA route-mount rejection with safe HTTP and connected-admission semantics.. Its
standard outcomes preserve HTTP semantics while producing a controlled connected
rejection:
redirectuses a typed location in both phases;redirectUnsafeaccepts an unchecked URL and is only for trusted destinations;unauthorized,forbidden, andnotFoundrender HTTP 401, 403, and 404 respectively, while all reject connected admission as unauthorized; andcustomsupplies an explicit disconnectedResponseand connectedLiveMountFailurefor 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.
Related Tasks
Add protected route context with Authentication and sessions.
Recheck connected policy with Lifecycle hooks.
Construct LiveViews from application dependencies with Services and dependency injection.
Check navigation across named live-session boundaries in Routes, parameters, and navigation.