26 KiB
session
Professional session lifecycle. Zero external dependencies. Three
narrow generics (TUser / TCredential / TData) — auth-scheme-agnostic.
Tagged AdoptResult, RefreshResult and RevokeResult (no void lying
about scope). Generation-guarded refresh dedup that survives concurrent
revoke / re-adopt / cross-tab races. Per-field Standard Schema validation
on adopt (Sium / Zod / Valibot / ArkType — same code). Cross-tab via
BroadcastChannel + $storage's adapters — payload carries event +
generation only, never tokens. SvelteKit-native via adoptServer(...) and
readSessionFromCookies(...).
import { createActiveSession, withAutoRefresh } from '$session';
import { object, string, email } from '$sium/core';
interface JwtCredential {
accessToken: string;
refreshToken: string;
}
const Sess = createActiveSession<{ id: string; email: string }, JwtCredential>({
schemas: {
user: object({ id: string(), email: email() })
},
onRefresh: async (current) => {
const r = await fetch('/api/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: current.credential.refreshToken })
});
if (!r.ok) return null; // fatal: auto-revokes
return await r.json(); // engine adopts the new Session
},
onRevoke: async (current) => {
const r = await fetch('/api/logout', {
method: 'POST',
body: JSON.stringify({ refreshToken: current.credential.refreshToken })
});
return r.ok; // boolean drives globalRevoked
}
});
withAutoRefresh(Sess);
await Sess.adopt({
user: { id: 'u-1', email: 'a@b.c' },
credential: { accessToken: 'tok', refreshToken: 'r' },
issuedAt: Date.now(),
expiresAt: Date.now() + 3_600_000
});
{#if Sess.current}
<p>Hi {Sess.current.user.email}</p>
<button onclick={() => Sess.revoke()}>Logout</button>
{:else}
<a href="/login">Login</a>
{/if}
Why another one
auth.js, lucia, supabase-js, @clerk/clerk-js all exist. None
combine the trade-offs this artifact targets:
- Lifecycle only, not acquisition. Acquiring a session (OAuth,
password exchange, magic link) is the consumer's responsibility — no
provider lock-in, no MFA flow, no password handling.
sessionadopts credentials you already have and runs the lifecycle around them. - Auth-scheme-agnostic.
TCredentialis opaque: JWT ({accessToken, refreshToken}), API key ({apiKey}),undefinedfor cookie auth, mTLS — all the same engine. Authorization (role,permissions) and acquisition (OAuth, MFA) live above this layer. - Three-state identity.
none/anonymous/identified. UC-4 (anonymous cart that survives login over cookie auth) is expressed cleanly viaTData—cartIdis neither identity nor credential. - Generation-guarded refresh. A revoke during an in-flight refresh cannot resurrect the dead session — and external storage / broadcast changes also bump generation, so a slow refresh in tab B cannot undo a logout from tab A.
- Tagged result unions, never
void.revoke({scope:'global'})without anonRevokereturns{globalRevoked: false, reason: 'missing_revoke_url'}— the call site can show truthful UI copy. Same shape foradopt()(validation/invariant failures) andrefresh()(skipped/expired/failed). - Standard Schema first-class. Per-generic
schemas.{user,credential,data}validateadopt()input. Works with Sium / Zod / Valibot / ArkType identically. No runtime dep on Sium — only theStandardSchemaV1interface. - Composable wrappers. Auto-refresh ticker, 401-retry hook, JWT exp parser and SSR cookie reader are separable opt-in helpers — pay for what you reach. Engine is ~400 LOC.
- Storage-agnostic via
$storage. Cookie / localStorage / memory / custom adapters all work. Cross-tab viawithBroadcastis opt-in.
Architecture
session/
├── index.ts barrel
├── types.ts Session, RevokeResult, AdoptResult, RefreshResult,
│ SessionEvent, EngineSession, ActiveSession, ...
├── consts.ts defaults + every event/identity/scope literal
├── errors.ts SessionDisposedError, SessionAlreadyCreatedError,
│ SessionInvalidError + guards
├── engine-session.ts createEngineSession() — runes-free core
├── active-session.svelte.ts createActiveSession() — runes wrapper, reactive
│ `current` + `generation` + `identity`
├── bus-helpers.ts safe `session.*` event publisher/listener helpers
├── auto-refresh.ts withAutoRefresh(engine, opts) — keyed timer
│ support + visibilitychange + jitter
├── http-integration.ts createBeforeErrorHook(engine, {applyAuth?}) —
│ 401 → refresh → retry, with loop guard
├── jwt.ts extractJwtExp(token) — opt-in JWT exp helper
├── ssr.ts readSessionFromCookies(cookies, opts) — SvelteKit
└── test/
├── engine-session.test.ts adopt/revoke/refresh/dedup/generation
├── active-session.svelte.test.ts reactive cells track engine.onChange
├── auto-refresh.test.ts ticker + cleanup + margin
├── http-integration.test.ts 401 hook + refresh + retry + loop guard
├── jwt.test.ts base64url + exp extraction
├── ssr.test.ts cookie parsing + invariants
└── errors.test.ts error class names + guards
Alias
alias: {
$session: 'src/arts/session';
}
Scope
In scope:
- Adopt an already-acquired session (user + credential + data + times)
- Persist it (any
SyncStorageAdapterfrom$storage) - Refresh it (consumer-supplied
onRefresh, deduped + generation-guarded) - Auto-refresh wrapper (ticker + visibility + jitter)
- Revoke it (local + optional consumer-supplied
onRevoke) - Cross-tab broadcast (event + generation, never tokens)
- Reactive surface (
Sess.current/.generation/.identity) - Per-generic Standard Schema validation
- SvelteKit
+layout.server.tshydration - 401 → refresh → retry hook for
arts/http(loop-guarded) - Optional JWT
expextractor
Out of scope:
- OAuth / OpenID Connect provider exchange (Google, GitHub, ...)
- Password handling / hashing / MFA flows
- Authorization checks (
has(), RBAC, claims) — that lives above session - Server-side session storage (DB, Redis)
- JWT signature verification (cryptography belongs elsewhere)
- CSRF protection (delegate to SvelteKit's built-in
csrf)
The Session type — three generics
type Session<TUser, TCredential = undefined, TData = undefined> = {
readonly user: TUser | null; // null when anonymous
readonly issuedAt: number; // strict epoch ms
readonly expiresAt: number; // strict epoch ms
} & SessionCredential<TCredential> & // required iff TCredential set
SessionData<TData>; // required iff TData set
Times are strict epoch milliseconds. The engine does not normalise
Date or ISO strings — pass Date.now() + N directly. If you have a
Date or ISO string, use toEpochMs(value) from $libs/days:
import { toEpochMs } from '$libs/days';
await Sess.adopt({
user,
credential,
issuedAt: toEpochMs(payload.iat * 1000),
expiresAt: toEpochMs(payload.exp * 1000)
});
Conditional credential / data slots use [T] extends [undefined] so
union distribution does not fire — TCredential = string | undefined
correctly means "required, may be the literal string undefined", not
"optional".
SESSION_NEVER_EXPIRES (8_640_000_000_000_000) is the canonical
sentinel for long-lived credentials (API keys, mTLS).
Actor metadata (optional)
Orthogonal to identity. Identity answers "what's in the session?"; actor
answers "what nature does the client driving it have?". A session can be
automated whether anonymous (scraping bot, crawler) or identified
(service account, AI agent with API key).
interface SessionActor {
readonly kind: 'unknown' | 'human' | 'automated';
readonly source?:
| 'user_agent'
| 'captcha'
| 'fingerprint'
| 'api_key'
| 'server_assertion'
| 'manual'
| (string & {}); // open — keep custom literals
readonly confidence?: number; // [0, 1]
}
Optional throughout: apps that don't classify actors omit it and pay
nothing. When present the engine validates structural invariants (kind in
enum, confidence in [0, 1]) and runs schemas.actor if configured.
// UC-4 — anonymous cart with bot-detection signal:
await Sess.adopt({
user: null,
data: { cartId: 'cart_123' },
actor: { kind: 'automated', source: 'user_agent', confidence: 0.87 },
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000
});
// Service account / AI agent with API key:
await Sess.adopt({
user: serviceAccount,
credential: { apiKey: 'sk_…' },
actor: { kind: 'automated', source: 'api_key', confidence: 1 },
issuedAt: Date.now(),
expiresAt: Date.now() + 86_400_000
});
Actor changes propagate via a regular adopt():
await Sess.adopt({
...Sess.current!,
actor: { kind: 'automated', source: 'captcha', confidence: 0.91 }
});
// Emits ADOPTED — no separate ACTOR_CHANGED event needed.
Actor is not derivable from current.user (a service account looks
identical to a human user) — the app supplies it from its detector
(UA parsing, captcha, fingerprint, server-side assertion). Sess only
persists, validates and transports.
There is intentionally no Sess.actor getter, no isBot()/isHuman()
helper. Read Sess.current?.actor directly — convenience methods would
start leaking product semantics into the runtime layer.
Identity state
type SessionIdentityState = 'none' | 'anonymous' | 'identified';
Sess.identity; // 'none' when current === null
// 'anonymous' when current.user === null
// 'identified' when current.user !== null
Anonymous sessions are how UC-4 (anonymous cart that becomes a logged-in
cart) is modelled: current is non-null and carries data.cartId, but
user is null until login. The engine skips schemas.user when
user === null — a userSchema that validates TUser would otherwise
reject the anonymous case.
Tagged results — never void
AdoptResult
type AdoptResult<TUser, TC, TD> =
| { ok: true; session: Session<...> }
| { ok: false; reason: 'validation_failed';
field: 'user' | 'credential' | 'data' | 'actor';
issues: ReadonlyArray<StandardSchemaV1.Issue> }
| { ok: false; reason: 'invariant_failed';
invariant: 'issuedAt_not_finite'
| 'expiresAt_not_finite'
| 'expiresAt_before_issuedAt'
| 'actor_kind_invalid'
| 'actor_confidence_out_of_range' };
RefreshResult
type RefreshResult<TUser, TC, TD> =
| { status: 'refreshed'; session: Session<...> }
| { status: 'expired' }
| { status: 'failed'; error: unknown; session: Session<...> }
| { status: 'skipped'; reason: 'no_session' | 'no_refresh_fn'
| 'stale_generation' };
RevokeResult
type RevokeResult =
| { localRevoked: true; globalRevoked: true; scope: 'global' }
| {
localRevoked: true;
globalRevoked: false;
scope: 'local';
reason?: 'missing_revoke_url' | 'network_error' | 'server_rejected' | 'no_session';
};
const r = await Sess.revoke({ scope: 'global' });
if (r.globalRevoked) toast('Signed out everywhere.');
else toast(`Signed out on this device. (${r.reason ?? ''})`);
REVOKED fires regardless — locally you ARE signed out.
RefreshFn contract — null = fatal, throw = transient
The single most important rule. Encode it consciously in your onRefresh:
const onRefresh: RefreshFn<User, JwtCredential> = async (current) => {
let response: Response;
try {
response = await fetch('/api/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: current.credential.refreshToken })
});
} catch (err) {
// Transient: network failed. Throw → engine preserves the
// session and emits REFRESH_FAILED. Auto-refresh / 401 hook
// will try again later.
throw err;
}
if (response.status === 401 || response.status === 403) {
// Fatal: refresh credential is dead. Return null → engine
// auto-revokes locally and emits EXPIRED.
return null;
}
if (!response.ok) throw new Error(`refresh failed: ${response.status}`);
return await response.json(); // engine adopts as REFRESHED
};
Mismatch this contract and a flaky network logs your users out.
RevokeFn contract — boolean answer
type RevokeFn<TUser, TC, TD> = (
current: Session<TUser, TC, TD>,
ctx: RevokeContext
) => Promise<boolean>;
- Resolves
true→ engine setsscope: 'global'. - Resolves
false→ engine degrades toscope: 'local'withreason: 'server_rejected'. - Throws → degrades to
scope: 'local'withreason: 'network_error'.
The handler is called with the snapshot before local revocation —
read whatever you need from current.credential to talk to the server.
The local snapshot is cleared after the handler resolves regardless.
When onRevoke is configured, Sess.revoke() defaults to
scope: 'global' — cookie-auth apps almost always want the server to
clear its cookie too. Use Sess.revoke({ scope: 'local' }) to bypass
the handler.
Generation guard — why it exists
The engine maintains a generation counter incremented on every
adopt() / revoke() / external change (storage event / broadcast).
The counter is captured at the start of every refresh(). If the
captured value differs when the refresh resolves, the result is
discarded silently with status: 'skipped', reason: 'stale_generation'.
This prevents three race classes:
- Local revoke during refresh. User clicks "Logout" →
revoke()bumps generation → slow refresh resolves with new tokens → guard discards → session stays revoked. - Cross-tab logout during refresh. Tab A logs out → storage event in tab B bumps tab B's generation → tab B's in-flight refresh is discarded.
- Re-adopt during refresh.
adopt({differentUser})runs while refresh is in flight → guard prevents the refresh from stamping the old user's data over the new one.
Reactive surface
ActiveSession exposes three reactive properties — all backed by
$state, all updated through the same engine.onChange dispatch path
the external listeners use:
Sess.current; // Session | null
Sess.generation; // number
Sess.identity; // 'none' | 'anonymous' | 'identified'
{#if Sess.current}
<p>Hi {Sess.current.user?.email}</p>
{:else if Sess.identity === 'anonymous'}
<p>Cart: {Sess.current?.data.cartId}</p>
{/if}
<button disabled={Sess.identity === 'none'}>Action</button>
Mutations always go through methods (adopt, revoke, refresh) —
the reactive cells are read-only.
API
createEngineSession(options) / createActiveSession(options)
const Sess = createActiveSession<User, JwtCredential, CartData>({
schemas: {
user: UserSchema,
credential: CredentialSchema,
data: CartSchema
},
storage: { adapter: localAdapter, key: 'app:session' },
onRefresh: async (current, ctx) => { ... },
onRevoke: async (current, ctx) => { ... },
logger: App.logger,
bus: App.bus,
broadcastChannel: 'my-app:session'
});
All options are optional — the engine works as a pure in-memory store without storage / refresh / revoke handlers.
Engine methods (also on Active)
Sess.adopt(session) // → AdoptResult, validates schemas + invariants
Sess.adoptServer(session) // SSR: skips schemas; throws SessionInvalidError
// on bad invariants
Sess.refresh() // → RefreshResult, deduped + generation-guarded
Sess.revoke(opts?) // → RevokeResult, defaults global if onRevoke set
Sess.clearLocal() // local-only revoke, no network call
Sess.onChange(listener) // INITIAL_SESSION fires synchronously
Sess.dispose()
Wrappers
const stop = withAutoRefresh(Sess, {
tickMs: 30_000,
marginMs: 90_000,
jitterMs: 5_000,
refreshOnVisible: true,
timers: App.timers // optional when using active-app; omit for native interval
});
// ... later
stop();
import { createBeforeErrorHook } from '$session';
import { createEngineHttp } from '$http';
const http = createEngineHttp({
hooks: {
beforeError: [
createBeforeErrorHook(Sess, {
applyAuth: (request, session) => {
request.headers.set('authorization', `Bearer ${session.credential.accessToken}`);
}
})
]
}
});
The 401 hook:
- Fires when a response returns 401.
- Calls
engine.refresh()(deduped — N concurrent 401s share one). - If refreshed: mutates the request via
applyAuth(omit for cookie auth — the browser resends the new cookies), sets a sentinel header, and re-issuesfetchreturning the newResponse. - If the retry itself returns 401, the hook fires again, sees the sentinel, and bails out — no infinite loop.
- If
expired: engine has already auto-revoked locally; the 401 propagates to the caller askind: 'http', andSess.onChangesubscribers handle theEXPIREDevent.
Bus integration
session can publish safe module events when a bus is injected:
import { createEngineBus } from '$bus';
import { createEngineSession, SESSION_EVENT_CHANGED, type SessEventMap } from '$session';
const Bus = createEngineBus<SessEventMap>();
Bus.on(SESSION_EVENT_CHANGED, (event) => {
console.log(event.payload.event, event.payload.identity);
});
const Sess = createEngineSession<User>({
bus: Bus
});
Published module events:
| Event | When |
|---|---|
SESSION_EVENT_CHANGED |
Any lifecycle event except INITIAL_SESSION. |
SESSION_EVENT_IDENTITY_CHANGED |
identity.from !== identity.to. |
SESSION_EVENT_REVOKED |
REVOKED. |
SESSION_EVENT_EXPIRED |
EXPIRED. |
SESSION_EVENT_REFRESHED |
REFRESHED. |
The bus payload is intentionally small:
interface SessLifecyclePayload {
readonly event: SessionEvent;
readonly generation: number;
readonly identity: { readonly from: SessionIdentityState; readonly to: SessionIdentityState };
}
It does not include current, previous, user, credential, tokens or
session data. Code that needs the full snapshot should use
Sess.onChange(...) or read Sess.current directly.
createEngineSession({ bus }) publishes from the engine. createActiveSession
publishes from the active wrapper after $state has been updated, so consumers
that react through App.bus see the latest Sess.current in the same tick.
When registered through the App service schema:
const App = createActiveApp({
services: {
session: defineActiveSession<User, JwtCredential>({ ... })
}
});
await App.session.adopt({ user, credential, ... });
defineActiveSession(...) makes the App builder inject Logger and Bus
automatically. The session art publishes its own SESSION_EVENT_* events
on App.bus; cross-module reactions live in orca presets at the App
level (applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange, etc.) — the standard set is wired
by applyStandardOrca(App).
Helpers
import { extractJwtExp } from '$session/jwt';
import { toEpochMs } from '$libs/days';
await Sess.adopt({
user,
credential,
issuedAt: Date.now(),
expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000
});
import { readSessionFromCookies } from '$session/ssr';
event.locals.session = readSessionFromCookies(event.cookies, { key: 'app:session' });
Lifecycle events
type SessionEvent =
| 'INITIAL_SESSION' // sync on subscribe; current snapshot or null
| 'ADOPTED' // adopt() succeeded
| 'ADOPTED_SERVER' // adoptServer() was called (SSR hydration)
| 'REFRESHED' // refresh() returned a new session
| 'REFRESH_FAILED' // refresh() threw (transient); session preserved
| 'EXPIRED' // refresh() returned null (fatal); auto-revoked
| 'REVOKED' // revoke() (any scope, including degraded global)
| 'EXTERNAL_CHANGED'; // cross-tab broadcast updated local state
onChange() invokes the listener synchronously once with
INITIAL_SESSION so the subscriber is the single source of truth — no
"subscribed-after-the-snapshot-was-set" race. The change payload is
rich:
interface SessionChange<TUser, TC, TD> {
readonly event: SessionEvent;
readonly current: Session | null;
readonly previous: Session | null;
readonly generation: number;
readonly identity: { from: SessionIdentityState; to: SessionIdentityState };
readonly error?: unknown; // populated for REFRESH_FAILED
readonly revoke?: RevokeResult; // populated for REVOKED
}
Consumers never compare prev to curr by hand — identity.from/to
and the typed event already carry the discriminator.
SvelteKit integration
Server: read cookie in hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { readSessionFromCookies } from '$session/ssr';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = readSessionFromCookies(event.cookies, {
key: 'app:session'
});
return resolve(event);
};
readSessionFromCookies returns null on missing / unparseable /
invalid payloads (missing required fields, non-finite times, expiresAt
< issuedAt) — same invariant set the engine enforces.
Pass to client via +layout.server.ts
export const load: LayoutServerLoad = ({ locals }) => ({
session: locals.session
});
Hydrate on the client without re-validation
<script>
import { App } from '$lib/app';
import { page } from '$app/state';
$effect(() => {
if (page.data.session && App.session?.current === null) {
App.session.adoptServer(page.data.session);
}
});
</script>
adoptServer skips schema validation (the server already validated)
but enforces invariants — bad timestamps throw
SessionInvalidError, surfacing the bug at the boundary instead of
poisoning the engine state.
Composition with App
const App = createActiveApp({
services: {
session: defineActiveSession<User, JwtCredential>({
schemas: { user: UserSchema },
storage: { adapter: localAdapter, key: 'app:session' },
onRefresh: async (current) => {
const r = await App.http.post('/api/refresh', {
body: { refreshToken: current.credential.refreshToken },
schema: SessionResponseSchema
});
return r.ok ? r.value : null;
},
onRevoke: async (current) => {
const r = await App.http.post('/api/logout', {
body: { refreshToken: current.credential.refreshToken }
});
return r.ok;
}
})
}
});
applyStandardOrca(App); // cross-module reactions on identity changes
defineActiveSession(...) makes the App builder inject Logger and Bus;
App.http is reachable through closure capture inside the handlers. The
session art publishes SESSION_EVENT_* directly on App.bus.
Cross-tab sync
The engine opens a BroadcastChannel (default legacy key 'arts:sess',
overridable via broadcastChannel) and emits
{ type, event, generation } on every commit — never tokens. Receivers
re-read the storage adapter, validate the payload, freeze it, and emit
EXTERNAL_CHANGED.
Storage adapters that already have an onChange (like
withBroadcast(localAdapter)) deliver the same signal through both
paths — the engine dedups identical snapshots so only one
EXTERNAL_CHANGED fires per real change.
Adapters without onChange (memory, raw sessionStorage) get a single
warning at construction. Wrap with $storage's withBroadcast(...) to
add cross-tab sync to any adapter.
Errors
| Class | When | Behavior |
|---|---|---|
SessionDisposedError |
mutator called after dispose() |
Thrown + logged via logger.error |
SessionInvalidError |
adoptServer() invariant violation |
Thrown + logged via logger.error |
SessionAlreadyCreatedError |
session service registered twice | Thrown by App factory |
Type guards: isSessionDisposedError, isSessionInvalidError,
isSessionAlreadyCreatedError.
Runtime conditions (validation failures, refresh transients, revoke
degradation) are not exceptions — they are tagged result fields
(AdoptResult, RefreshResult, RevokeResult) and lifecycle events
(REFRESH_FAILED, EXPIRED). Exceptions are reserved for programmer
errors where catching at the call site is the right pattern.
Testing
Use createMemoryAdapter() from $storage for isolation. Schemas can come
from any Standard Schema vendor — test fixtures often hand-roll a small
schema rather than pulling Sium for a single field.
import { createEngineSession } from '$session';
import { createMemoryAdapter } from '$storage';
const session = createEngineSession<User>({
schemas: {
user: {
'~standard': {
/* ... */
}
}
},
storage: { adapter: createMemoryAdapter(), key: 'session' },
onRefresh: async () => null,
onRevoke: async () => true
});
The engine tests at src/arts/session/test/engine-session.test.ts cover
the race-condition cases (concurrent refresh dedup, generation guard
discard on revoke-during-refresh and external-change-during-refresh,
fatal vs transient distinction, invariant rejection of stored payloads,
freeze of hydrated snapshots). Read them before adding refresh-adjacent
features.
Bundle profile
| Layer | Approx. size (min) |
|---|---|
| Engine + types + errors + consts | ~4 KB |
| Active wrapper (runes) | +1 KB |
| Auto-refresh wrapper | +1 KB |
| HTTP integration (401 retry) | +0.5 KB |
| JWT helper | +0.3 KB |
| SSR helper | +0.2 KB |
| Total when everything is reached | ~7 KB |
Zero runtime dependencies. The StandardSchemaV1 import is type-only.