Skip to content
Meeko
Type to search the guides and the API reference. Open it from any page with / or Ctrl K; move with the arrow keys and open a result with Enter.
Get started

Contents

On this page

RedisSessionStore

Persistent Redis session store, with Redis expiration, atomic rotation and revocation, cross-instance revocation and shared rateLimit counters.

server Since 1.0.0 @meekojs/meeko/server Source code
ts
import { RedisSessionStore } from "@meekojs/meeko/server";

RedisSessionStore stores each session in Redis with its expiration (PX) and a per-family index locating the active session. set, replace, revokeFamily and rateLimit are atomic Lua scripts, so multiple instances share sessions, revocations and limits without races. With a subscriber, it receives revocations published by any instance and immediately aborts that family's requests and sockets.

This is the store Meeko creates without configuration in production with MEEKO_REDIS_URL (Node, Bun and Deno). Create it manually for your own client, another prefix or Upstash on Workers.

Usage#

ts
// server/runtime.ts
import { createClient } from "redis";
import { defineServerRuntime, RedisSessionStore, SessionManager } from "@meekojs/meeko/server";

export default defineServerRuntime(async ({ env, production, prerendering }) => {
  const redis = createClient({ url: env.MEEKO_REDIS_URL });
  await redis.connect();
  const subscriber = redis.duplicate(); // dedicated connection in subscriber mode
  await subscriber.connect();
  const store = new RedisSessionStore(redis, { prefix: "tienda:prod:session:", subscriber });
  return {
    sessions: new SessionManager({ store, production: production && !prerendering }),
    close: async () => { await subscriber.quit(); await redis.quit(); },
  };
});

Type#

ts
class RedisSessionStore<T = unknown> implements SessionStore<T> {
  readonly persistent: true;
  readonly subscribeRevocations?: (
    listener: (familyId: string) => void,
  ) => Promise<Unsubscribe>;
  constructor(client: RedisSessionClient, options: RedisSessionStoreOptions);
  get(id: string): Promise<SessionRecord<T> | undefined>;
  set(record: SessionRecord<T>): Promise<void>;
  delete(id: string): Promise<void>;
  replace(previousId: string, record: SessionRecord<T>): Promise<boolean>;
  revokeFamily(familyId: string): Promise<void>;
  rateLimit(key: string, windowMs: number): Promise<RateLimitHit>;
  close(): Promise<void>;
}

RedisSessionStoreOptions

ts
interface RedisSessionStoreOptions {
	prefix: string
	closeClient?: boolean
	subscriber?: RedisSubscriberClient
}

RedisSessionClient

ts
interface RedisSessionClient {
	get(key: string): Promise<string | null>
	set(key: string, value: string, options: { PX: number }): Promise<unknown>
	del(key: string): Promise<unknown>
	eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>
	quit?(): Promise<unknown>
}

RedisSubscriberClient

ts
interface RedisSubscriberClient {
	subscribe(channel: string, listener: RedisMessageListener): Promise<unknown>
	unsubscribe(channel: string, listener: RedisMessageListener): Promise<unknown>
	sSubscribe?(channel: string, listener: RedisMessageListener): Promise<unknown>
	sUnsubscribe?(channel: string, listener: RedisMessageListener): Promise<unknown>
}

Parameters#

new RedisSessionStore(client, options):

Parameter Type Default Description
client RedisSessionClient required A connected client with get, set(key, value, { PX }), del and eval(script, { keys, arguments }). node-redis 4 or later satisfies this as is.
options.prefix string required Prefix for all keys, unique per application and environment: nonempty, without whitespace and ending in :.
options.subscriber RedisSubscriberClient none Dedicated connection (client.duplicate()) to receive revocations from <prefix>revoked. The application opens and closes it.
options.closeClient boolean false Closes client with quit() when closing the store, when the store owns the connection.

Return value#

A persistent SessionStore (persistent: true) with replace, revokeFamily and rateLimit, and subscribeRevocations only if given a subscriber. Keys it writes:

Key Content
<prefix><id> The session record in JSON, with PX equal to its remaining lifetime.
<prefix>family:<familyId> The family's active ID, with the same PX.
<prefix>ratelimit:<resumen> The counter for a rateLimit window; the key is a SHA-256 of the route and subject, never the plain-text IP.
<prefix>revoked Pub/sub channel where revokeFamily publishes the familyId in the same atomic operation that deletes the session.

Examples#

Sessions and Socket across instances with one Redis#

ts
// server/runtime.ts
import { createClient } from "redis";
import { defineServerRuntime, RedisSessionStore, RedisSocketPubSub, SessionManager } from "@meekojs/meeko/server";

export default defineServerRuntime(async ({ env, production, prerendering }) => {
  const redis = createClient({ url: env.MEEKO_REDIS_URL });
  await redis.connect();
  const subscriber = redis.duplicate();
  await subscriber.connect();
  return {
    sessions: new SessionManager({ store: new RedisSessionStore(redis, { prefix: "app:prod:session:", subscriber }), production: production && !prerendering }),
    socket: { pubsub: new RedisSocketPubSub({ publisher: redis, subscriber, prefix: "app:prod:" }) },
    close: async () => { await subscriber.quit(); await redis.quit(); },
  };
});

The same subscriber serves both: each subscribes to its own channels.

Upstash through its REST API, on Cloudflare Workers#

Workers does not open TCP connections. Upstash's REST API covers the four operations the store needs:

ts
import { RedisSessionStore } from "@meekojs/meeko/server";
import type { RedisSessionClient } from "@meekojs/meeko/server";

function upstashClient(url: string, token: string): RedisSessionClient {
  const command = async (...args: Array<string | number>): Promise<unknown> => {
    const response = await fetch(url, {
      method: "POST",
      headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
      body: JSON.stringify(args),
    });
    const body = await response.json() as { result?: unknown; error?: string };
    if (!response.ok || body.error) throw new Error(`Upstash ${String(args[0])} failed: ${body.error ?? response.status}`);
    return body.result;
  };
  return {
    get: async (key) => (await command("GET", key)) as string | null,
    set: (key, value, { PX }) => command("SET", key, value, "PX", PX),
    del: (key) => command("DEL", key),
    eval: (script, { keys, arguments: args }) => command("EVAL", script, keys.length, ...keys, ...args),
  };
}

export const createStore = (env: { UPSTASH_REDIS_REST_URL: string; UPSTASH_REDIS_REST_TOKEN: string }) =>
  new RedisSessionStore(upstashClient(env.UPSTASH_REDIS_REST_URL, env.UPSTASH_REDIS_REST_TOKEN), { prefix: "mi-app:prod:" });

The REST API does not maintain a subscription: without subscriber, other instances detect a revocation when rereading the session.

Without server/runtime.ts#

sh
MEEKO_REDIS_URL=rediss://default:…@redis.example.com:6380
MEEKO_REDIS_PREFIX=tienda:prod:   # defaults to meeko:<package.json name>:prod:

With npm install redis in the project, meeko start connects a client and a subscriber before serving traffic and closes both on shutdown.

Common mistakes#

A prefix without a trailing :#

Incorrectts
new RedisSessionStore(redis, { prefix: "app" });

TypeError: Session Redis prefix must be nonempty, contain no whitespace, and end with ':'.

Correctts
new RedisSessionStore(redis, { prefix: "app:prod:session:" });

The same prefix in two applications or environments#

Incorrecttexto
staging and production with prefix: "app:" in the same Redis

There is no error: both applications read each other's sessions, a revokeFamily published on app:revoked reaches both, and they share rateLimit counters. Use one prefix per application and environment, and never FLUSHALL or FLUSHDB on shared Redis.

Correcttexto
prefix: "app:staging:session:" and prefix: "app:prod:session:"

Using the normal connection as subscriber#

Incorrectts
new RedisSessionStore(redis, { prefix: "app:prod:session:", subscriber: redis });

A node-redis connection in subscriber mode does not accept other commands: session reads fail. Create a separate connection.

Correctts
const subscriber = redis.duplicate();
await subscriber.connect();
new RedisSessionStore(redis, { prefix: "app:prod:session:", subscriber });

MEEKO_REDIS_URL without the redis package#

Incorrectsh
MEEKO_REDIS_URL=redis://127.0.0.1:6379 npm start   # without npm install redis

Startup stops with MEEKO_REDIS_URL is set but the project does not install the redis package: add it with npm install redis (or pnpm add redis) and build again. A URL other than redis:// or rediss:// reports MEEKO_REDIS_URL must be a redis:// or rediss:// URL, and an unreachable server reports Could not connect to the Redis server of MEEKO_REDIS_URL. Messages never include the URL, which contains the password.

Correctsh
npm install redis && npm run build && MEEKO_REDIS_URL=rediss://… npm start

Notes#

  • Fail closed. If Redis does not respond, the request reading the session fails with 500; access is never granted without the store. rateLimit also rejects.
  • Old records. Records without familyId (from before family revocation) remain readable and receive a family on their first rotation.
  • Forgeable notifications. Anyone with PUBLISH permission on Redis can publish a familyId to <prefix>revoked: it does not grant access, but aborts requests and closes that family's sockets. Restrict that channel with ACLs.
  • Disconnected subscriber. Redis pub/sub does not store messages: a revocation published while an instance's subscriber is down does not reach it. Deletion is atomic, however, so the next read detects it; use session.verify() before irreversible effects.
  • Shutdown. The store does not close the client except with closeClient; close client and subscriber in close in server/runtime.ts.

Was this page helpful?

Add a comment

It is sent when you choose “Yes” or “No”. Only the page, your answer and the comment are stored: no cookies and no personal data.

0 of 2000 characters

Edit on GitHub: docs/en/reference/server/redissessionstore.md