glimr/session

Session

Gleam is immutable, but sessions need mutable state that persists across multiple reads and writes within a single request handler. An OTP actor per request provides that mutability safely — each operation is a message, so concurrent access is serialized. The middleware reads the actor’s final state after the handler returns to persist only what actually changed, avoiding unnecessary store writes.

Types

Context requires a Session field at construction time, but the actor can’t start until a request arrives with cookie data. The Empty variant satisfies the type system during boot without allocating an actor, while Live wraps the real per-request actor subject. Making the type opaque prevents callers from pattern matching on the variant and coupling to the internal representation.

pub opaque type Session

Gleam doesn’t have traits or interfaces, so the store is modeled as a record of closures — each backend provides its own implementations at construction time. Making the type opaque prevents callers from reaching into the closures directly, ensuring all access goes through the public functions that handle the “no store configured” fallback.

pub opaque type SessionStore

Values

pub fn all(session: Session) -> dict.Dict(String, String)

Bulk reads are needed for serialization (e.g. the cookie store encoding the full session into a signed cookie) and for debugging. Returns a snapshot — further mutations after this call won’t be reflected in the returned dict.

pub fn cookie_store() -> SessionStore

Cookie-based sessions avoid server-side storage entirely — the signed cookie is the store. This is ideal for small payloads under ~4KB where the simplicity of zero infrastructure outweighs the per-request bandwidth cost.

pub fn cookie_value(
  session_id: String,
  data: dict.Dict(String, String),
  flash: dict.Dict(String, String),
) -> String

Server-side stores put only the session ID in the cookie while cookie stores encode the full payload. Abstracting this behind a callback lets the middleware set the cookie without knowing which strategy is active — the store decides what goes over the wire.

pub fn decode_payload(
  payload_json: String,
) -> #(dict.Dict(String, String), dict.Dict(String, String))

Falling back to empty dicts on any parse failure means a corrupt or truncated payload degrades to a fresh session rather than crashing the request. Using optional_field for both “_data” and “_flash” handles partial payloads — old sessions written before flash support was added will still load correctly with an empty flash dict.

pub fn destroy(session_id: String) -> Nil

Invalidation must remove the old session immediately so a stolen session ID can never be reused. The middleware calls this before saving the new session, ensuring the old and new entries never coexist in the store.

pub fn empty() -> Session

Context is constructed at boot before any HTTP request arrives, so there’s no cookie data to seed a real session actor. This returns a no-op handle where all reads return empty values and all writes are silently ignored, avoiding the need for Option(Session) throughout the framework.

pub fn encode_payload(
  data: dict.Dict(String, String),
  flash: dict.Dict(String, String),
) -> String

Data and flash are namespaced under “_data” and “_flash” keys rather than merged flat so neither can collide with the other — a session key called “_flash” won’t shadow the actual flash dict. JSON was chosen over ETF or other formats because the cookie store sends it to browsers where binary Erlang terms wouldn’t be readable.

pub fn error(session: Session, field: String) -> String

Showing inline validation errors next to the field that failed is way better than a generic “something went wrong” banner. The validator flashes each field’s first error so templates can display it right where it matters.

<p l-if="session.error(ctx.session, 'email') != ''" class="text-red-600">
  {{ session.error(ctx.session, "email") }}
</p>
pub fn flash(session: Session, key: String, value: String) -> Nil

Flash messages provide one-shot feedback across redirects (e.g. “Item saved successfully”). Storing them separately from regular session data and clearing them after one read ensures they appear exactly once without the handler needing to manage cleanup.

pub fn forget(session: Session, key: String) -> Nil

Removing a key marks the session dirty so the middleware persists the deletion. Fire-and-forget like put since the caller doesn’t need to wait for confirmation.

pub fn gc() -> Nil

Expired sessions accumulate in the store over time. Running GC probabilistically (2% of requests) spreads cleanup cost so no single request pays the full scan price, while still purging stale entries within a reasonable window.

pub fn get(session: Session, key: String) -> Result(String, Nil)

Reads go through actor.call (synchronous) because the caller needs the value immediately to make decisions in the handler. Returns Error(Nil) for missing keys so callers can distinguish absence from an empty string.

pub fn get_flash(session: Session, key: String) -> String

Most flash reads happen in templates where an empty string is the natural “no flash” value. Returning “” instead of a Result avoids wrapping every template interpolation in a case expression.

pub fn get_flash_or(
  session: Session,
  key: String,
) -> Result(String, Nil)

When the handler needs to distinguish between “no flash was set” and “flash was set to an empty string”, this Result- returning variant provides that distinction. get_flash delegates here and unwraps for the common case.

pub fn has(session: Session, key: String) -> Bool

Existence checks are synchronous (actor.call) because the caller typically branches on the result immediately, e.g. to decide whether to redirect an unauthenticated user.

pub fn has_error(session: Session, field: String) -> Bool

Templates often need to conditionally show error styling or error messages. Checking error() != "" works but reads awkwardly in template expressions. This gives a clean boolean for l-if directives.

<p l-if="session.has_error(ctx.session, 'email')" class="text-red-600">
  {{ session.error(ctx.session, "email") }}
</p>
pub fn has_flash(session: Session, key: String) -> Bool

Templates often need to conditionally render a flash banner only when a message exists. A Bool check is cleaner than matching on a Result when the value itself isn’t needed for the conditional.

pub fn id(session: Session) -> String

The session ID is needed by the store to look up or persist the session data. Exposing it lets middleware and store implementations access it without reaching into the actor’s internal state directly.

pub fn invalidate(session: Session) -> Nil

Logout and account deletion need to destroy all session state and issue a new ID so the old session cookie can never be reused. The invalidated flag tells middleware to delete the old entry from the store rather than just overwriting it.

pub fn load(
  session_id: String,
) -> #(dict.Dict(String, String), dict.Dict(String, String))

Middleware calls this at request start to hydrate the session actor. Returning empty dicts when no store is configured lets the app boot and serve requests even if sessions aren’t set up — reads just return nothing rather than crashing.

pub fn new(
  load load: fn(String) -> #(
    dict.Dict(String, String),
    dict.Dict(String, String),
  ),
  save save: fn(
    String,
    dict.Dict(String, String),
    dict.Dict(String, String),
  ) -> Nil,
  destroy destroy: fn(String) -> Nil,
  gc gc: fn() -> Nil,
  cookie_value cookie_value: fn(
    String,
    dict.Dict(String, String),
    dict.Dict(String, String),
  ) -> String,
) -> SessionStore

Labeled arguments make construction self-documenting at the call site — each backend explicitly names every callback it provides. This is the only way to build a SessionStore, so the opaque type guarantee holds: every store has all five callbacks populated.

pub fn old(session: Session, field: String) -> String

Nobody wants to retype an entire form because one field failed validation. After a redirect back, the validator stashes the old values as flash data so templates can repopulate inputs automatically.

<input type="email" name="email" :value="session.old(ctx.session, 'email')" />
pub fn put(session: Session, key: String, value: String) -> Nil

Writes use process.send (fire-and-forget) because the caller doesn’t need confirmation — the actor serializes all mutations and the middleware reads the final state after the handler returns.

pub fn regenerate(session: Session) -> Nil

After login, the session ID must change to prevent session fixation attacks — an attacker who planted a known session ID before authentication can’t hijack the post-login session if the ID rotates. Data is preserved so the user doesn’t lose pre-login state.

pub fn save(
  session_id: String,
  data: dict.Dict(String, String),
  flash: dict.Dict(String, String),
) -> Nil

Middleware calls this after the handler returns to persist mutations. The flash dict here contains only values set during this request — the previous request’s flash was already consumed and cleared, enforcing one-shot semantics at the store level.

pub fn setup(session_store: SessionStore) -> Nil

Caches the given session store in persistent_term so the session middleware can access it on every request without threading it through function arguments. Call this once at boot after creating a store from any driver.

Search Document