@samveys/session

Version
1.0.3Nov 5, 2026
Install
pnpm add @samveys/session

From packages/session/README.md in the framework repository.

Server-side sessions for Samveys. The browser holds an opaque id in a cookie; the data lives in a store (the database by default).

  • Id: 32 random bytes, base64url (43 characters). A cookie value of any other shape is ignored before it reaches the store.
  • Cookie defaults: svs_session, Secure; HttpOnly; SameSite=Lax; Path=/, 2 hour lifetime.
  • A request that never writes to the session sets no cookie and writes no row.
  • The idle timeout slides: a used session is refreshed once half of it has passed.

1. Install the plugin

ts
// samveys.config.ts
import { database } from '@samveys/database'
import { http } from '@samveys/http'
import { session } from '@samveys/session'
import dbConfig from './config/database.ts'

export default {
  plugins: [http(), database(dbConfig), session({ cookie: { maxAgeMs: 8 * 60 * 60 * 1000 } })],
}

The plugin registers the named middleware session. Put use: ['session'] on a route or group (or list it in the global middleware). Reading ctx.session on a route without it fails with SVS_E090.

OptionDefaultMeaning
store'database''database', 'memory' (tests), or your own SessionStore
cookie.namesvs_session
cookie.securetrueTurn off only for plain-HTTP local development
cookie.httpOnlytrue
cookie.sameSite'Lax''None' requires secure
cookie.path/
cookie.maxAgeMs2 hours
idleTimeoutMscookie.maxAgeMs

The database store keeps samveys_sessions(id, data, expires_at) and creates it on first use.

2. Use it

ts
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { http } from '@samveys/http'
import { session } from '@samveys/session'
import { createTestApp } from '@samveys/testing'

test('a counter that survives requests, and a login that changes the id', async () => {
  await using app = await createTestApp({
    plugins: [http(), session({ store: 'memory' })],
    routes: (router) => {
      router.get('/count', { use: ['session'] }, ({ session: s }) => {
        const count = s.get<number>('count', 0) + 1
        s.put('count', count)
        return { count }
      })
      router.post('/login', { use: ['session'] }, async ({ session: s }) => {
        s.put('userId', 7)
        await s.regenerate() // always on login: stops session fixation
        return { ok: true }
      })
    },
  })

  const first = await app.http.get('/count')
  const header = first.headers.get('set-cookie') ?? ''
  assert.match(header, /Secure/)
  assert.match(header, /HttpOnly/)
  assert.match(header, /SameSite=Lax/)

  const cookie = header.split(';')[0] ?? ''
  assert.equal((await app.http.get('/count', { headers: { cookie } })).json<{ count: number }>().count, 2)

  const login = await app.http.post('/login', { headers: { cookie } })
  assert.notEqual((login.headers.get('set-cookie') ?? '').split(';')[0], cookie)
})

3. The ctx.session API

CallDoes
get(key, fallback?)Reads a value (including flash data from the previous request)
put(key, value)Writes a JSON-serializable value
forget(key)Removes a value
flash(key, value)Readable with get() on the next request only
all()A copy of every value
regenerate()New id, same data; the old id is deleted. Call it on login
invalidate()Logout: deletes the data now and clears the cookie
id, isNewThe current id; whether the request arrived without a valid session

Notes and limits

  • Two concurrent requests of one browser that both change the session race; the later save wins. Keep sessions small and avoid writing on read-only routes.
  • If a handler throws, nothing is saved.
  • SessionStore is three methods (read, write, destroy). Stores hold a record and an expiresAt; the middleware does the clock arithmetic.
  • The cookie value is the id and nothing else. It is not signed, because the store lookup is the check and a guessed id is a 256-bit guess.