Store Adapters
The promise-based SESSION_STORE is framework-neutral; two thin adapters translate it onto the middleware callback contracts. Everything security-critical — cookie signing, session fixation defense, ID rotation — remains the middleware's job.
express-session
import { NestFactory } from '@nestjs/core';
import session from 'express-session';
import { SESSION_STORE, toExpressStore } from '@nestjs-redisx/session';
import type { ISessionStore } from '@nestjs-redisx/session';
import { AppModule } from './types';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
// One line replaces connect-redis — passport and req.session keep working.
const store = app.get<ISessionStore>(SESSION_STORE);
app.use(
session({
secret: process.env.SESSION_SECRET ?? 'change-me',
resave: false,
saveUninitialized: false,
rolling: true, // slide the TTL on every request
cookie: { maxAge: 3600_000, httpOnly: true },
store: await toExpressStore(store),
}),
);
await app.listen(3000);
}
void bootstrap();toExpressStore() is async because express-session is loaded lazily — it is an optional peer dependency, so fastify-only applications never pay for it. If the package is missing, SessionMiddlewareMissingError explains what to install.
Migrating from connect-redis:
// Before
store: new RedisStore({ client: redisClient }),
// After — same session middleware config, RedisX-managed connection
store: await toExpressStore(app.get(SESSION_STORE)),@fastify/session
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import fastifyCookie from '@fastify/cookie';
import fastifySession from '@fastify/session';
import { SESSION_STORE, toFastifyStore } from '@nestjs-redisx/session';
import type { ISessionStore } from '@nestjs-redisx/session';
import { AppModule } from './types';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
const store = app.get<ISessionStore>(SESSION_STORE);
await app.register(fastifyCookie);
await app.register(fastifySession, {
secret: process.env.SESSION_SECRET ?? 'a secret with minimum length of 32 characters',
cookie: { secure: 'auto', maxAge: 3600_000 },
saveUninitialized: false,
store: toFastifyStore(store),
});
await app.listen(3000, '0.0.0.0');
}
void bootstrap();toFastifyStore() is synchronous and dependency-free: @fastify/session only consumes the returned { get, set, destroy } object.
TTL Semantics
Per write/touch, the TTL is resolved in order:
cookie.expires - now— when the middleware set an expiry (i.e.cookie.maxAgeis configured);- the adapter's
ttlMsoption (toExpressStore(store, { ttlMs })); - the plugin's
defaultTtlMs(1 day).
With rolling: true (express) the middleware touches the store on every request, sliding the TTL and refreshing lastSeenAt. A cookie that already expired is destroyed instead of written back. When absoluteLifetimeMs is set, every TTL is additionally clamped to the remaining lifetime window.
Typing req.session
req.session stays middleware-owned; extend it with declaration merging (compile-time only — session contents are not validated at runtime):
declare module 'express-session' {
interface SessionData {
passport?: { user?: string };
cart?: string[];
}
}Our own API is genuinely typed via the service generic:
import { Injectable, Inject } from '@nestjs/common';
import { SESSION_SERVICE } from '@nestjs-redisx/session';
import type { ISessionService } from '@nestjs-redisx/session';
import { AppSession } from './types';
// req.session typing stays middleware-owned — extend it via declaration
// merging (compile-time only; session contents are not validated at runtime):
//
// declare module 'express-session' {
// interface SessionData extends AppSession {}
// }
@Injectable()
export class TypedSessionService {
constructor(
// Our API is genuinely typed: pass your payload shape as the generic.
@Inject(SESSION_SERVICE) private readonly sessions: ISessionService<AppSession>,
) {}
async cartOf(sessionId: string): Promise<string[]> {
const info = await this.sessions.getSession(sessionId);
return info?.data.cart ?? []; // data is AppSession, not unknown
}
}Activity Stamping
The store cannot see the HTTP request, so IP and user agent are stamped by an opt-in one-liner after the session middleware:
import { NestFactory } from '@nestjs/core';
import { SESSION_SERVICE } from '@nestjs-redisx/session';
import type { ISessionService } from '@nestjs-redisx/session';
import { AppModule } from './types';
interface SessionRequest {
sessionID: string;
session?: { passport?: { user?: string } };
ip: string;
get(header: string): string | undefined;
}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const sessions = app.get<ISessionService>(SESSION_SERVICE);
// Opt-in metadata stamping AFTER the session middleware: gives the device
// page its IP and user-agent columns. Fire-and-forget — never blocks.
app.use((req: SessionRequest, _res: unknown, next: () => void) => {
if (req.session?.passport?.user) {
void sessions.recordActivity(req.sessionID, { ip: req.ip, userAgent: req.get('user-agent') }).catch(() => undefined);
}
next();
});
await app.listen(3000);
}
void bootstrap();createdAt, lastSeenAt, and expiresAt are maintained by the store itself — the stamper only adds the request-scoped columns to the device page.