Cookie Sessions (@zebra-web/session)
@zebra-web/session provides signed-cookie server-side sessions: HMAC-SHA256-signed sid cookies, a pluggable SessionStore (in-memory by default), rolling TTL renewal, and session-fixation protection. It also bridges into core's session-scoped DI via its resolver.
Install
bun add @zebra-web/sessionQuick start
import { Zebra } from "@zebra-web/core";
import { sessionMiddleware } from "@zebra-web/session";
const session = sessionMiddleware({
secret: "a-long-random-secret",
cookie: { httpOnly: true, sameSite: "lax", maxAge: 7 * 24 * 60 * 60, path: "/" },
});
const app = new Zebra({
session: { resolver: session.resolver, wsSession: session.wsSession, ttl: 30 * 60 * 1000 },
});
app.use(session);
app.get("/counter", async (req) => {
const s = getSession(req)!;
const count = (await s.get<number>("count")) ?? 0;
await s.set("count", count + 1);
return { count: count + 1 };
});The key wiring:
sessionMiddleware({ secret, cookie?, store? })returns the middleware object.- Its
.resolvergoes intonew Zebra({ session: { resolver, ttl } })— this makes session-scoped DI work under the same session id (core doesn't depend on the session package; the resolver is the bridge). .wsSessiongoes intosession: { wsSession }— WebSocket connections get a session handle (see WebSocket).app.use(session)mounts the middleware: it puts a read/writeRequestSessiononreq.ctx.sessionand persists on the response path.
Two entry points for the same options:
ZebraOptionsaccepts the session resolver/ttl either nested (session: { resolver, ttl }, recommended — it also carrieswsSession) or at the top level (sessionResolver,sessionTtl— kept for compatibility; the nested form wins where both are given forwsSession, andopts.sessionResolver ?? opts.session?.resolverresolves the resolver). The middleware's.resolverproperty is the cookie-parsing function to pass through either entry point.
RequestSession API
getSession(req) returns the current request's session handle (undefined when the middleware didn't run):
interface RequestSession {
readonly id: string; // verified session id (fresh for first-time visitors)
readonly isNew: boolean; // whether this request created the session
get<T>(key: string): Promise<T | undefined>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
data(): Promise<Record<string, unknown>>; // shallow copy
flush(): Promise<void>; // persist now (the middleware also persists at response end)
destroy(): Promise<void>; // destroy: remove data + expiring Set-Cookie
}- Data is lazily loaded: the first
get/setpulls from the store, cached for the rest of the request. set/deletemark the session dirty; persistence happens at response end (even on error). A brand-new visitor that never wrote data writes nothing (no store pollution).- After
destroy()the handle is inert: further mutations are not persisted, and the response carriesSet-Cookie: sid=; Max-Age=0so the client drops the cookie.
Persistence semantics
| Scenario | Behavior |
|---|---|
| New session + no writes | store untouched (zero-cost anonymous requests) |
| New session + writes | store.set(id, data) + Set-Cookie (signed) |
| Existing session + writes | store.set(id, data) |
| Existing session + no writes | store.touch(id) — rolling TTL renewal |
destroy() | store.destroy(id) + expiring cookie; never revived |
Persistence runs after next() (including the handler-threw path, as long as the session is not destroyed) — see "TTL ownership" below.
Cookie details
- Default cookie name
sid, path/. - Value = HMAC-SHA256-signed id.
parseSignedCookieverifies the signature; a tampered cookie is treated as anonymous. - The default cookie carries
HttpOnly+SameSite=Lax. Opt out withpreset: "plain"(a flag-free cookie — the original default), or override per attribute (explicit attributes always win over the preset):
sessionMiddleware({
secret,
// default: HttpOnly + SameSite=Lax
cookie: { preset: "plain" }, // no flags
// or explicit: cookie: { httpOnly: true, sameSite: "strict", secure: true }
});SECURE_COOKIE is the frozen { httpOnly: true, sameSite: "lax" } constant applied by default.
Cookie maxAge is in seconds and must be finite: NaN, Infinity, and -Infinity throw TypeError during serialization. Positive fractions are rounded down before computing both Max-Age and Expires (1.5 becomes 1). Zero, negative values, and positive values below one second emit Max-Age=0 with an epoch Expires to delete the cookie. A positive normalized value that puts Expires outside JavaScript's Date range also throws TypeError.
Session-fixation protection
- The signature only proves the cookie is genuine, not that the session is alive. Both the resolver and
openSessionconsult the store before reusing an id: a verified id with no store record (destroyed or TTL-expired) is treated as a new visitor — a fresh sid + cookie replace the stale one instead of resurrecting the old session. - This keeps core's session DI scope consistent with the middleware's data layer: a destroyed session revives neither data nor DI scope.
MemoryStoreuses a short-lived tombstone so an in-flight request'ssetcannot resurrect a destroyed session.
SessionStore interface and default implementation
interface SessionStore {
get(id: string): Promise<unknown | undefined>;
set(id: string, data: unknown): Promise<void>;
touch(id: string, ttl?: number): Promise<void>;
destroy(id: string): Promise<void>;
}MemoryStore({ ttl })— default,Map-backed, lazy sweep (at mostSWEEP_BUDGETentries per access), no timers, no leaks; TTL in ms.- Roll your own backend (Redis / Postgres): implement this interface.
@zebra-web/redisshipsRedisSessionStore(see Redis).
MemoryStore and RedisSessionStore require finite millisecond values for constructor ttl and per-call touch(id, ttl) overrides. NaN, Infinity, and -Infinity fail with TypeError; invalid touches reject before any store read, write, or expiry sweep and leave existing data and expirations unchanged. Omitting the override uses the store's configured TTL. Finite overrides retain their value, including fractions; zero or negative overrides expire the session immediately.
TTL ownership
Two independent TTLs:
- The store TTL owns the data: a session id is alive iff the store holds a record. After expiry, the cookie is dead and the data is gone.
- Core's
sessionTtlonly reclaims the DI container:app.disposeSession(id)clears the container and timer, never touching store data.
To reclaim both immediately (logout), combine session.destroy() (store layer) with app.disposeSession(id) (container layer).
Logout pattern
import { HttpError } from "@zebra-web/core";
import { getSession } from "@zebra-web/session";
z.post("/logout", async (req) => {
const s = getSession(req);
if (!s) throw new HttpError(401, "unauthorized", "No session");
await s.destroy();
// response carries the expiring Set-Cookie; the store record is gone,
// so the old cookie can no longer revive the session
return { ok: true };
});WebSocket sessions
The .wsSession hook returned by sessionMiddleware attaches a connection-level session handle to ws.data.session at upgrade time (undefined for anonymous connections — an upgrade response cannot send Set-Cookie, so no orphan sessions are fabricated). WebSockets have no HTTP response path for automatic persistence: write explicitly with await session.flush(). See WebSocket.