Session
elements man session Read as markdownSession in Elements is unified across your entire app. Route handlers, rpc functions, and reactive templates all read the calling user's session through one API with one set of semantics. Session works the same way over HTTP as it does over WebSockets. Browser templates that read the session are reactive: a login flips the UI without a page reload.
A session in Elements is login state. It exists only after session.login(). An
anonymous visitor has no session row, no token, and no cookie. There is no
anonymous session id. See "Identifying Anonymous Visitors" below for how to
track visitors who haven't logged in.
At a Glance
import { session, sql, AuthError } from "@elements/app";
/** @rpc */
export function login(email: string, password: string) {
let user = sql<User>(`
select * from users where email = ${email} and passwordHash = crypt(${password}, passwordHash)
`).first();
if (!user) {
throw new AuthError("invalid credentials");
}
session.login({ userId: user.id, userName: user.name });
}
/** @rpc */
export function logout() {
session.logout();
}
/** @rpc */
export function getMyProfile(): User {
session.isLoggedInOrThrow();
return sql<User>(`select * from users where id = ${session.getOrThrow("userId")}`).firstOrThrow("user not found");
}
In a template:
<header>
<span e:if={session.isLoggedIn()}>{session.get("userName")}</span>
<a e:else href="/login">log in</a>
</header>
The header re-renders the moment login state changes.
Session Data
The fields carried on a session (userId, userName, whatever your app needs)
are declared once through module augmentation. This gives session.get(),
session.getOrThrow(), and session.login() full type checking.
// app/types/session.d.ts
declare module "@elements/app" {
interface SessionData {
userId: string;
userName: string;
}
}
SessionData is empty by default. Once you augment it:
session.login()requires a matching data object:session.login({ userId, userName }).session.get("userId")returnsstring | undefined.session.getOrThrow("userId")returnsstring(throws if missing).- An unknown key (
session.get("nope")) is a compile error.
The data is stored in the data jsonb column of the session's
elements.sessions row.
API
Read methods (isLoggedIn, get, getOrThrow, willExpireIn) are reactive in
the browser. If the session changes, every template that reads it re-renders.
Mutation methods (login, logout, renew, findActiveSessions, revoke)
are server-only.
-
session.login(data): server-only. Creates a session row, mints an opaque token, and communicates it to the browser.datamatches your augmentedSessionData; omit it entirely whenSessionDatais empty. -
session.logout(): server-only. Deletes the session row and clears the cookie. -
session.renew(): server-only. Takes no arguments. Extends the current session's expiry to now plus the configured policy duration. No-op if not logged in. -
session.isLoggedIn():boolean. -
session.isLoggedInOrThrow(): throws if not logged in; otherwise a no-op. -
session.get(key): a typed field from the session data, orundefined. -
session.getOrThrow(key): a typed field from the session data; throws if missing. -
session.willExpireIn(durationMs):boolean. True when the session expires withindurationMsof now. -
session.expiresAt():Date | null. Null when there is no expiry. -
session.id():string | null. The session's non-secret id, safe to log or show in support tooling. Null when anonymous. -
session.findActiveSessions(key, value): server-only. Returns the session rows whose data fieldkeyequalsvalue, for account features like "list my sessions across devices". -
session.revoke(token): server-only. Deletes a session by its token, forcing that device to log out. Take the token from a row returned byfindActiveSessions().
Login and Logout
session.login({ userId: user.id, userName: user.name });
session.logout();
Expiry is a config policy (see Config below), not a per-login argument.
session.login() and session.logout() are server-only. Call them from
anywhere on the server: an @rpc body, a route handler, or any helper reachable
from those. Calling them from browser-reachable code is a compile error pointing
at the call site.
Renew
Extends the current session's expiration, anchored at now.
session.renew();
renew() takes no arguments: the amount of the extension is the configured
expires policy. It is a no-op if not logged in. Use it after a significant
action, or from your own activity heuristic when autoRenew is off (see
Config).
Sliding Window
With autoRenew on (the default), every authenticated request extends the
session's expiry automatically. Elements writes a fresh Set-Cookie and
silently updates the server row (no WebSocket fanout). Every request keeps the
session alive without any app code.
Turn autoRenew off for strict-activity apps (banking-style "log out after N
minutes of true inactivity"). With it off, Elements never extends the session;
call session.renew() yourself from your own activity signal.
Config
// config.jsoc
{
session: {
expires: '30d',
autoRenew: true,
},
}
expires: expiration policy. Default'30d'.'close': a browser session cookie with no server-side expiry; dies when the browser closes.- A duration string (
'15m','7d','30d','1y'): a sliding expiry. The server row'sexpires_atand the cookieMax-Ageboth track it. WithautoRenewon, each active request advances it, so'1y'is the long "remember me".
autoRenew: automatic sliding-window renewal. Defaulttrue. When on, every authenticated request advances the expiry. Setfalsefor strict-activity apps that renew manually.domain: optionalDomainattribute for the cookie. Set to a parent domain (.example.com) to share the session across subdomains. Leave unset in development. Browsers rejectDomainfor localhost and IP hosts.reaperIntervalSeconds: how often the expired-session reaper runs. Default30.
The session cookie is set with Path=/ and SameSite=Lax. Lax rather than
Strict so a user returning from an OAuth or payment-provider redirect still
arrives authenticated. The cookie is intentionally not HttpOnly and
not HMAC-signed: the runtime reads the session payload from the cookie in
JavaScript at page load so the first render is correct without waiting on the
WebSocket. Security does not rest on the cookie being unreadable. It rests on
the opaque token being unguessable and validated server-side against the
elements.sessions row on every request.
What's Stored
Sessions are stored server-side in the elements.sessions table. The cookie
carries a small payload:
token: the opaque credential. It is the session's identifier in every code path; the row's primary-keyidcolumn is never surfaced.data: yourSessionDatafields, for synchronous access on the next page load.expiresAt: when the session expires.
The token is what authenticates the request: it's looked up against the server
row every time. State that doesn't belong on the cookie lives in your own
tables, keyed by a data field such as session.get("userId").
Identity vs Credential
The token is a credential, proof of an authenticated session, and is never
exposed to your code. The session's own id is session.id(), the non-secret row
primary key: safe to log or show in support tooling, but it identifies a
session, not a user (a login starts a new session). For application identity,
meaning who the user is, use a SessionData field you set at login and read
back with session.get("userId"). Session is auth state, not an identity
service.
Identifying Anonymous Visitors
Because a session exists only after login, an anonymous visitor has nothing to
key on server-side: no row, no token, no cookie. This is deliberate: a
server-generated anonymous id would force a Set-Cookie on every visitor, which
forces Vary: Cookie and breaks edge caching.
To track visitors before they log in (analytics, an abandoned draft, a wizard-in-progress), generate the id in the browser and persist it client-side, then pass it explicitly to the server:
// browser: generate once, reuse across visits
function anonId(): string {
let id = localStorage.getItem("anonId");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("anonId", id);
}
return id;
}
/** @rpc */
export function saveDraft(anonId: string, text: string) {
sql(`
insert into drafts (anonId, text) values (${anonId}, ${text})
on conflict (anonId) do update set text = excluded.text
`);
}
The app owns the anonymous id; Elements stays out of it. On login, associate the
stored anonId with the new userId if you want continuity across the
boundary.
Reactivity in Templates
Read methods are reactive. Templates that read the session re-render
automatically when the session changes. After session.login() lands via rpc,
every UI element that depends on session state updates immediately.
<html>
<p e:if={session.isLoggedIn()}>welcome, {session.get("userName")}</p>
<p e:else>please <a href="/login">log in</a></p>
</html>
The e:if branch swaps the moment the session state flips.
Browser Session Lifecycle
Each page navigation creates a new request and a new WebSocket. The session arrives with the new request, read synchronously from the cookie payload. Session state is consistent across the WebSocket: a login via rpc is immediately visible to subsequent rpc calls and to live data on the same connection.
Closing the tab or navigating away does not log the user out. Logout is an
explicit session.logout() call.
Related
rpc: the@rpcboundary, authorization patterns, error catalog.router: route handlers have access to the samesession.