Expire sessions with a Cache
Resolve a session token to its user for a bounded lifetime, expiring entries automatically without writing a sweep, and report how many sessions are currently live.
commons tokens
---A session token — an opaque, bounded handle that addresses a cache entry. It isnon-empty and length-capped, so a malformed token is rejected at the boundarybefore any cache lookup runs.---type Token = String where NonEmpty and MaxLength(128)
---The authenticated user a live token resolves to.---type UserId = String where NonEmptycontext sessions
uses tokensconsumes bynk { Clock }
type LoginRequest = { token: Token, user: UserId,}
---A shard of live sessions (a Cloudflare Durable Object). State is a `Cache` — a`Map` whose entries expire on their own. `@ttl(30.minutes)` sets the lifetime:each `put` (re)starts the 30-minute clock, an entry past its TTL reads as `None`,and expired entries are reaped at the next commit.
Eviction consults the clock, so every cache op *except* `remove` declares`given Clock` — the time dependency is visible in the signature.---agent Sessions { key shard: String
store live: Cache[Token, UserId] @ttl(30.minutes)
-- start or refresh a session; stamps a fresh 30-minute expiry on call login(token: Token, user: UserId) -> Effect[()] given Clock { let _ <- live.put(token, user) Effect.pure(()) }
-- resolve a token to its user, or None if unknown or expired on call whoami(token: Token) -> Effect[Option[UserId]] given Clock { let found <- live.get(token) Effect.pure(found) }
-- end a session now; `remove` is idempotent and needs no clock on call logout(token: Token) -> Effect[()] { let _ <- live.remove(token) Effect.pure(()) }
-- how many sessions are currently live (expired entries are not counted) on call active() -> Effect[Int] given Clock { let n <- live.size() Effect.pure(n) }}
service api from http { on POST("/sessions") by Visitor (body: LoginRequest) -> Effect[HttpResult[String]] { let _ <- Sessions("default").login(body.token, body.user) Created("ok") }
on GET("/sessions/:token") by Visitor (token: Token) -> Effect[HttpResult[UserId]] { let who <- Sessions("default").whoami(token) match who { Some(user) => Ok(user) None => NotFound } }
on POST("/sessions/:token/logout") by Visitor (token: Token) -> Effect[HttpResult[String]] { let _ <- Sessions("default").logout(token) NoContent }
on GET("/sessions") by Visitor () -> Effect[HttpResult[Int]] { let n <- Sessions("default").active() Ok(n) }}This example reaches Workers-only shapes (storage bindings, agents, or cron), so it runs with bynk dev rather than in the browser playground. See Install to get started.
How it works
Section titled “How it works”agent Sessions is keyed by a shard string and holds one store:
live: Cache[Token, UserId] @ttl(30.minutes). A Cache is a Map whose entries
expire on their own — each put (re)starts the 30-minute clock, an entry past its
TTL reads as None, and expired entries are reaped at the next commit, so size
counts only live entries with no separate sweep to write. The boundary types are
refined in commons tokens: Token is NonEmpty and MaxLength(128), UserId is
NonEmpty, so a malformed token is rejected before any cache lookup.
Time is honest. Eviction consults the clock, so every cache operation except
remove declares given Clock — login, whoami, and active all carry it,
while logout (an idempotent live.remove) does not. The dependency is visible in
each signature, and a mocked clock would make expiry deterministic.
The HTTP service exposes POST /sessions (login, returns Created("ok")),
GET /sessions/:token (whoami — Some resolves to the user, None is NotFound),
POST /sessions/:token/logout (NoContent), and GET /sessions (the live count).
Every route delegates to Sessions("default").