Skip to content

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 ​

typescript
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:

typescript
// Before
store: new RedisStore({ client: redisClient }),
// After — same session middleware config, RedisX-managed connection
store: await toExpressStore(app.get(SESSION_STORE)),

@fastify/session ​

typescript
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:

  1. cookie.expires - now — when the middleware set an expiry (i.e. cookie.maxAge is configured);
  2. the adapter's ttlMs option (toExpressStore(store, { ttlMs }));
  3. 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):

typescript
declare module 'express-session' {
  interface SessionData {
    passport?: { user?: string };
    cart?: string[];
  }
}

Our own API is genuinely typed via the service generic:

typescript
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:

typescript
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.

Released under the MIT License.