Sessions
A session is a token plus a state object held in a store. The token travels in a cookie; the state is whatever your handlers put there.
defineRouter({
auth: {
login: procedure({
access: 'public',
handler: async (context, { user, password }) => {
await verify(user, password);
context.client.startSession(undefined, { user });
return { ok: true };
},
}),
logout: procedure({
access: 'session',
handler: async (context) => context.client.finalizeSession(),
}),
},
profile: {
whoami: procedure({
access: 'session',
handler: async (context) => ({ user: context.session.state.user }),
}),
},
});Reading and writing state
context.session.state is a proxy: assigning to it persists to the store, with no save() call to forget.
handler: async (context) => {
context.session.state.lastSeen = Date.now(); // written through to the store
},The write is fire-and-forget — a store error is logged, not thrown at the handler. Mutating a nested object (state.prefs.theme = 'dark') does not trigger a save, because only the top level is proxied; reassign the whole branch instead.
The client API
These live on context.client:
| Method | What it does |
|---|---|
startSession(token?, data?) | Creates a session and sends the cookie. What a login calls. |
initializeSession(token?, data?) | Creates it without touching cookies. |
restoreSession(token) | Loads one from the store. false if it is gone. |
finalizeSession() | Deletes it from the store and drops it. false if there was none. |
token defaults to a freshly generated one; pass your own to adopt an existing identifier.
A login over WebSocket sets no cookie
A Set-Cookie header needs a response, and an open socket has none. startSession therefore only emits the cookie on HTTP transports; over a WebSocket it creates the session for that connection alone. If you want the session to survive a reconnect, log in over HTTP (the browser stores the cookie) and let the WebSocket upgrade restore it — which it does, from the same cookie.
The counterpart for everything the cookie cannot reach: the client's authenticate hook re-presents the credential on every reconnect, before the subscriptions are re-opened and the units re-loaded. On a Node client — whose WebSocket sends no cookies at all — it is the only way a session survives a reconnect.
A dropped connection never deletes the session from the store. That is exactly what makes a reconnect cheap: sessions end through finalizeSession() or store-side expiry, and nothing else.
The lifecycle
A reconnect is a new server-side client carrying the same session: the cookie is what survives the socket. That is why room membership has to be re-applied by hand (rooms) while session state simply reappears.
Cookies
new Server({
router,
sessions: {
cookie: { name: 'token', path: '/', httpOnly: true, secure: true, sameSite: 'Lax', maxAge: null },
},
});Those are the defaults. maxAge: null makes it a session cookie — gone when the browser closes. The cookie is read back on both an HTTP request and the WebSocket upgrade, which is what restores a session without a round trip.
secure: true and localhost
Browsers refuse a Secure cookie over plain http:// — except on localhost, which they treat as a secure context. So the default works in development and stays correct in production. If you terminate TLS at a proxy and genuinely serve plain HTTP on a real hostname, you will need secure: false, and you should understand what you are giving up.
The cross-site GET gate
A SameSite=Lax cookie rides along on cross-site navigation. That is convenient for ordinary web pages and dangerous for an RPC endpoint: an attacker's page could point a browser at {basePath}/account/deleteEverything and the cookie would come along.
So in REST mode, a GET/HEAD request only gets its cookie-restored session when the request proves same-origin intent through Sec-Fetch-Site. A cross-site one runs anonymously — public procedures only, 403 for the rest. Packet-mode POSTs are unaffected, and non-browser peers (curl, server-to-server), which send no Fetch metadata at all, keep their session.
Stores
The store contract is structural — three async methods, no base class to extend:
const store = {
async get(token) { /* -> state | null */ },
async set(token, state) {},
async delete(token) {},
};
new Server({ router, sessions: { store } });Which is how Redis, a database table, or a signed-cookie store plug in without wrpc depending on any of them. The default is MemorySessionStore, bounded on both axes:
const { MemorySessionStore } = require('@alexify/wrpc');
new Server({
router,
sessions: { store: new MemorySessionStore({ maxSessions: 10000, ttl: 24 * 60 * 60 * 1000 }) },
});LRU eviction past maxSessions, expiry after ttl (0 disables either). It is a real store, not a stub — but it lives in one process, so a second instance shares nothing with it. Anything running more than one process wants an injected store.
Tokens
new Server({ router, sessions: { generateToken: () => myUlid() } });The default is a v4 UUID from node:crypto (or globalThis.crypto in a browser build). Whatever you substitute must be unguessable: it is the whole credential.
Pluggable token carriers
Where the token lives on the wire is an injection like the store — sessions.transport, structural (isTokenTransport), with the cookie behaviour as the byte-identical default:
// { read({ headers, url, declared, meta }) -> token | null,
// write(token) -> Set-Cookie-style header value | null,
// ambient?: boolean }
new Server({ router, sessions: { transport: myTransport } });Besides the raw headers/url, read() receives what the core already parsed: declared — the merged declared+observed header bag (the ws wrpc_h query included, capped on the configurable metaMaxBytes) — and meta, the sanitized connection-metadata bag with both x-wrpc-meta spellings merged and keys kebab-normalized. Prefer them over re-parsing the wire: a strategy with its own parser can silently drift from the core's. (Both are absent on the SSE channel-key path, so keep a raw-header fallback for the names you read.)
Two ready-made strategies ship in the @alexify/wrpc/auth subpath — deliberately outside the base bundle, like the rooms backplane in ./scaling:
const { bearerTransport, payloadTransport } = require('@alexify/wrpc/auth');
new Server({ router, sessions: { transport: bearerTransport() } });bearerTransport()readsAuthorization: Bearer <token>— the real header where the transport can send one (http/sse, curl); on browser ws, where the WebSocket constructor cannot set headers, the client offers the token as awrpc.bearer.<token>subprotocol next to the wire revision, so the credential travels as a real upgrade header and never lands in the connect URL (URLs end up in proxy access logs — see the metadata caveat). A token outside the RFC 7230 token charset (spaces,=padding) cannot ride a subprotocol and falls back to the declared-headers query, with a warning.payloadTransport({ field })reads a field of the client's declaredmeta— for apps that keepauthorizationsemantics out of it. Bothx-wrpc-metaspellings are read, the canonical JSON header and the per-keyx-wrpc-meta-<field>form.
Two asymmetries every non-cookie strategy inherits, both by construction:
- The server cannot send
Authorization.write()returns null; thesignInhandler hands the token pair back in its result, the client stores it (see the client half below) and presents it on the next connection. Neither half works alone — the pair is the strategy. - The safe-method CSRF rule does not apply. That rule guards ambient authority — a cookie the browser attaches without script. A bearer credential is script-attached, so a non-ambient transport (
ambient: false) restores on safe methods too, cross-site fetch headers or not.
The client half: stores and bearerAuth()
The same subpath carries the client side: token stores (get/set/delete, sync or async — a Map already qualifies) and bearerAuth(), which composes a store with your signIn/refresh calls into the three client options that make the strategy work end to end:
const { bearerAuth, webStorage } = require('@alexify/wrpc/auth');
const client = await connect(url, {
...bearerAuth({
store: webStorage(localStorage), // or memoryStore(), cookieStorage(document), your IndexedDB wrapper
signIn: (c) => c.call('auth/signIn', credentials()),
refresh: (c, tokens) => c.call('auth/refresh', { token: tokens.access }),
}),
});headers presents the stored token on every open (so the reconnect's upgrade restores the session before the re-subscribe), authenticate signs in only when the store is empty, and refresh is single-flight with a one-shot retry. The server-side auth/refresh handler re-binds the live connection with context.client.startSession(...) and returns the rotated pair — that is what heals a token expiring mid-socket without a reconnect.