npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

app-redis

v0.0.13

Published

Production-grade Redis infrastructure for distributed systems

Downloads

1,787

Readme

app-redis

Production-grade Redis infrastructure for distributed systems: a unified client, cache, rate limiter, distributed lock, pub/sub and health checking — all working in standalone, sentinel and cluster modes.

Features

  • Unified client (RedisClientWrapper) — one API for standalone / sentinel / cluster; all multi-key operations (mget, mset, scanIterator, ...) are slot-aware and cluster-safe
  • Cache (Cache) — JSON serialization, optional gzip compression, namespaces, TTLs, hash helpers and pattern-based cleanup
  • Rate limiter (RateLimiter) — generic, works for any resource (routes, users, IPs, API keys, databases, ...) with fixed and sliding windows
  • Distributed lock (DistributedLock) — atomic acquire/release, auto-extension, retries
  • Pub/Sub (PubSub) — publish, subscribe, pattern subscriptions
  • Health checker (HealthChecker) — periodic health monitoring with callbacks
  • Session subsystem (createSessionManager) — the production session stack: validation, retry-safe rotation, idle/absolute expiry, eviction ceilings, security versioning, optional AES-256-GCM encryption at rest, fail-closed circuit breaker, metrics and health
  • Revocation store (RedisRevocationStore) — TTL-backed token revocation with batch operations and fail-closed checks
  • Lua scripts (eval / evalsha) — atomic server-side logic, Cluster-safe when keys share a hash slot
  • Observability — pino-compatible logging and slow-command warnings

Installation

npm install app-redis ioredis zod

Quick start

import { RedisClient, Cache, RateLimiter } from 'app-redis';

// 1. Create the client
const client = new RedisClient({
  mode: 'standalone',
  host: 'localhost',
  port: 6379,
});

// 2. Cache
const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
await cache.set('user:1', { name: 'alice' });
const user = await cache.get('user:1');

// 3. Rate limiting
const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
if (!result.allowed) {
  // HTTP 429, set Retry-After: result.retryAfter
}

// 4. Shut down gracefully
await client.close();

Connection modes

All features behave identically in every mode. Choose the mode via the mode config field.

Standalone

const client = new RedisClient({
  mode: 'standalone',
  host: 'localhost',
  port: 6379,
  password: 'secret',
  database: 0,
});

Or with a URL:

const client = new RedisClient({ mode: 'standalone', url: 'redis://:secret@localhost:6379/0' });

Sentinel

const client = new RedisClient({
  mode: 'sentinel',
  sentinelNodes: [
    { host: 'sentinel1', port: 26379 },
    { host: 'sentinel2', port: 26380 },
  ],
  sentinelMasterName: 'mymaster',
  password: 'secret',
});

Cluster

const client = new RedisClient({
  mode: 'cluster',
  clusterNodes: [
    { host: 'redis1', port: 7000 },
    { host: 'redis2', port: 7001 },
    { host: 'redis3', port: 7002 },
  ],
  password: 'secret',
});

Common configuration

| Option | Type | Default | Description | | --- | --- | --- | --- | | maxRetries | number | 3 | Max reconnect attempts | | retryDelay | number | 1000 | Base reconnect delay (ms) | | connectionTimeout | number | 5000 | Connect timeout (ms) | | defaultTTL | number | 3600 | Default cache TTL (seconds) | | compressionThreshold | number | 1024 | Cache compression threshold (bytes) | | slowCommandThreshold | number | 1000 | Log commands slower than this (ms) | | tls | boolean | false | Enable TLS (tlsOptions for CA/cert/key) |

Configs are validated with Zod (RedisConfigSchema).

RedisClient

Full reference is in the generated .d.ts (JSDoc with params + examples). Highlights:

Strings & keys

await client.set('name', 'alice');              // SET
await client.set('session', 'x', 3600);         // SET ... EX
await client.setexnx('job:1', 'w', 60);         // SET ... EX NX (only if missing)
await client.setnx('lock:1', 'owner', 30);      // SETNX + EXPIRE, returns 1|0
await client.get('name');                       // 'alice' | null
await client.getdel('queue:job');               // GETDEL
await client.exists('name');                    // 1 | 0
await client.del('a', 'b');                     // number deleted
await client.expire('session', 3600);           // set TTL
await client.ttl('session');                    // seconds left
await client.incr('visits');                    // counters
await client.decr('stock:sku-1');

Batch operations (cluster-safe)

await client.mset(['user:1', 'alice'], ['user:2', 'bob']); // grouped by hash slot
const [a, b] = await client.mget('user:1', 'user:2');      // routed per slot

Hashes, sets, sorted sets

await client.hset('user:1', 'name', 'alice');
await client.hget('user:1', 'name');
await client.hgetall('user:1');
await client.hdel('user:1', 'age');

await client.sadd('tags:1', 'redis', 'typescript');
await client.smembers('tags:1');
await client.sismember('tags:1', 'redis');
await client.srem('tags:1', 'redis');

await client.zadd('leaderboard', 100, 'p1');
await client.zrange('leaderboard', 0, 9);
await client.zrem('leaderboard', 'p1');

Scanning & pipelines

// Scans every node in cluster mode
for await (const key of client.scanIterator('session:*')) {
  console.log(key);
}
await client.deletePattern('temp:*'); // delete by glob, cluster-safe

// Pipelines (cluster mode: keys must share a hash slot)
const pipeline = client.pipeline();
pipeline.set('a', '1');
pipeline.incr('b');
const results = await pipeline.exec();

Cluster helpers

client.isCluster();                  // boolean
client.getClusterNodes();            // raw node clients
client.getClusterSlots();            // raw slot map
client.getSlotRanges();              // Map<slot, host:port[]>
client.calculateSlot('{user}:a');    // CRC16 slot, honors hash tags
await client.getNodeForKey('user:1');// node serving a key
await client.isKeyServed('user:1');  // slot is served
await client.executeOnNode('user:1', 'get', 'user:1'); // run on owning node
await client.mgetClusterAware([...]); // slot-grouped multi-get
client.getClusterInfo();             // topology snapshot

Lifecycle & low-level

await client.ping();            // boolean
await client.close();           // graceful QUIT
client.raw;                     // raw ioredis client
client.defineCommand(name, def);// custom commands (e.g. fastify-rate-limit)
await client.info('memory');    // INFO output
await client.select(1);         // standalone only

Lua scripts (atomic server-side logic)

// EVAL: the first numKeys arguments are KEYS, everything else is ARGV.
// Cluster mode: every key touched inside the script must be declared in
// KEYS and share one hash slot.
const script = `
  if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
  end
  return 0
`;
await client.set('lock:job', 'owner-1');
await client.eval(script, 1, 'lock:job', 'owner-1'); // 1 (deleted)

// SCRIPT LOAD + EVALSHA: avoid re-sending the script body on every call.
// evalsha falls back to EVAL automatically when the server cache was flushed.
const sha = await client.scriptLoad(script);
await client.evalsha(sha, script, 1, 'lock:job', 'owner-2'); // 0 (not owner)

Convenience accessors

RedisClient lazily creates and shares one instance of each sub-component. Access them as properties — no manual wiring needed:

client.cache;        // shared Cache (created on first access)
client.pubsub;       // shared PubSub
client.lock;         // shared DistributedLock
client.rateLimiter;  // shared RateLimiter

await client.cache.set('user:1', { name: 'alice' });
const ok = await client.lock.acquire('order:42');
const { allowed } = await client.rateLimiter.consume('/api', 'ip-1', { limit: 5, duration: 60 });

The corresponding setters replace the shared instance with a custom one (e.g. one built with different defaults):

client.cache = new Cache(client, config, logger);
client.rateLimiter = new RateLimiter(client, { limit: 50, duration: 10 });

Cache

Basic usage

const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });

await cache.set('user:1', { name: 'alice' }, { ttl: 300 });
const user = await cache.get('user:1');

await cache.delete('user:1');
await cache.exists('user:1');
await cache.expire('user:1', 60);
await cache.ttl('user:1');
  • Values are JSON-serialized; strings/numbers/buffers are stored as-is.
  • Values larger than compressionThreshold bytes are gzip-compressed transparently.
  • compress: false disables compression for a single write.

Namespaces

await cache.set('token', 'abc', { namespace: 'auth' });
await cache.get('token', 'auth');          // 'abc'
await cache.get('token');                  // null

await cache.clearNamespace('sessions');    // delete every 'sessions:*' key
await cache.keys('session:*');             // list keys (cluster-safe)
await cache.deletePattern('temp:*');       // delete by pattern

Atomic & batch operations

await cache.setNX('job:1', 'worker-1', { ttl: 60 });   // only if missing
await cache.setEXNX('lock:1', 'txn', { ttl: 30 });     // atomic with TTL

await cache.increment('stats:visits');                 // 1, 2, 3, ...
await cache.decrement('stock:sku-1');

await cache.mset({ 'user:1': alice, 'user:2': bob }, { ttl: 300 }); // slot-grouped
const [a, b] = await cache.mget(['user:1', 'user:2']);

Hash helpers

await cache.hset('user:1', 'age', 30);
await cache.hget('user:1', 'age');       // 30
await cache.hgetall('user:1');           // { age: 30, ... }

RateLimiter

Generic rate limiting for any resource — routes, API endpoints, users, IPs, API keys, database writes, email sends, webhooks...

const limiter = new RateLimiter(client, { limit: 100, duration: 60 });

const result = await limiter.consume('/api/login', 'ip-10.0.0.1');

Result

interface RateLimitResult {
  allowed: boolean;    // request may proceed
  limit: number;       // configured max
  used: number;        // requests in current window
  remaining: number;   // left in the window
  resetAt: number;     // epoch ms when the window resets
  retryAfter: number;  // seconds to wait (0 when allowed)
}

Usage in an HTTP handler

const result = await limiter.consume('/api/orders', request.ip, { limit: 5, duration: 60 });
if (!result.allowed) {
  response.setHeader('Retry-After', String(result.retryAfter));
  return response.status(429).json({ error: 'Too many requests' });
}

Peek & reset

const state = await limiter.check('/api/search', 'user-1'); // no capacity consumed
await limiter.reset('/api/export', 'user-7');               // grant full capacity again

Algorithms

| Algorithm | Key type | Characteristics | | --- | --- | --- | | sliding (default) | sorted set + atomic Lua | smoothest; precise rolling window | | fixed | counter (INCR/EXPIRE) | cheapest; window resets at fixed boundaries |

const fixed = new RateLimiter(client, { limit: 10, duration: 1, algorithm: 'fixed' });
const perRoute = await fixed.consume('/api', 'user-1', { limit: 3, duration: 10 }); // per-call override

Keys are ratelimit:{resource}:{identifier} — each resource/identifier pair is tracked independently, so routes and callers never interfere. If Redis is unavailable the limiter fails open (allows requests) so an outage cannot take down the whole app.

DistributedLock

const lock = new DistributedLock(client, { ttl: 30000, retryCount: 3, retryDelay: 200 });

await lock.acquire('order:42');        // boolean
await lock.release('order:42');        // owner-checked Lua delete
await lock.releaseForce('order:42');   // delete without ownership check
await lock.withLock('order:42', async () => {
  // exclusive section; TTL is auto-extended, lock released afterwards
});
await lock.extend('order:42', 60000);  // renew TTL while owned
await lock.isLocked('order:42');
await lock.getLockInfo('order:42');    // { locked, ttl, lockId }
await lock.getLockOwner('order:42');   // lock id | null
await lock.getLockTTL('order:42');     // seconds left
await lock.cleanupAll();               // delete every lock:* key (tests/emergency)

PubSub

const pubsub = new PubSub(client);
await pubsub.connectSubscriber(redisConfig); // dedicated subscriber connection

await pubsub.subscribe('orders:created', (message) => {
  console.log(message); // message payload (JSON-parsed)
});
await pubsub.publish('orders:created', { id: 1 }); // JSON-serialized

await pubsub.unsubscribe('orders:created', handler); // one handler
await pubsub.unsubscribe('orders:created');          // whole channel
await pubsub.psubscribe('orders:*', ({ channel, message }) => {
  // pattern handlers receive { channel, message }
});
await pubsub.punsubscribe('orders:*');
await pubsub.close();       // closes subscriber only
pubsub.getStats();          // { subscriptions, patternSubscriptions, connected }

HealthChecker

const health = new HealthChecker(client);
health.start(10000); // check every 10s
health.onChange((status) => console.log(status));

const status = await health.check(); // ping + latency
health.getStatus();                  // last result (null before first check)
await health.waitForHealthy(30000);  // boolean
health.stop();

Revocation store

RedisRevocationStore supports refresh-token revocation workflows: short-lived entries (revoked:{jti}) so rotated/logged-out tokens are rejected for their remaining lifetime. It is framework-independent, works identically on standalone, Sentinel and Cluster, never stores raw tokens — only ids (jti) — and is the same store the session subsystem consults when checkRevocationStore: true.

import { RedisRevocationStore } from 'app-redis/session';

RedisRevocationStore

Each revoked jti is stored as {prefix}{jti} -> reason with a Redis TTL equal to the token's remaining lifetime, so expired entries are reclaimed automatically — no sweep job required. Every operation is single-key (or a slot-grouped pipeline for batches), so no hash tags are needed on any topology.

const revocations = new RedisRevocationStore({
  client,
  keyPrefix: 'authcore:revoked:',
});

const expiry = Math.floor(Date.now() / 1000) + 86400;

// Revoke (single or batch)
await revocations.revoke({ jti: 'a1b2c3d4', reason: 'logout', expiresAt: expiry });
await revocations.revokeMany([
  { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
  { jti: 'c3', reason: 'password-change', expiresAt: expiry },
]);

// Check (single or batch)
await revocations.isRevoked('a1b2c3d4');              // boolean
const revoked = await revocations.isRevokedMany(['b2', 'c3', 'd4']); // Set<string>

if (revoked.has('b2')) {
  // reject the token
}

Fail-closed semantics: if a batched command fails (Redis error, timeout, ...), revokeMany / isRevokedMany throw RevocationBatchError carrying the exact jtis that failed — a check can never silently treat a token as "not revoked" when its status is unknown.

Session subsystem

The production session stack (createSessionManager): validation with fail-closed semantics, rotation with retry-safe idempotency, throttled touches, idle + absolute expiry, per-user eviction ceilings, security versioning, optional AES-256-GCM encryption at rest, an optional jti index for userId-free lookup, a fail-closed circuit breaker, metrics and health — all Cluster-safe by construction. It supersedes the historical RedisSessionStore (removed; see the migration note below).

See SESSION.md (spec) and docs/architecture.md (decisions, deviations, exact semantics) before adopting it. Highlights:

import { createSessionManager } from 'app-redis/session';

const manager = createSessionManager({
  client,
  config: {
    enabled: true,              // explicit opt-in
    namespace: 'authcore',
    maxSessionsPerUser: 20,
    securityVersion: { enabled: true },
  },
});
await manager.init();           // preloads the Lua scripts

const { token, session } = await manager.service.create({ userId: 'user-42' });

// Validate: single round trip when userId is known. Never throws for
// invalid sessions; throws (SessionStorageError) only on infra failure.
const result = await manager.service.validate(token, { userId: 'user-42' });
if (result.valid) { /* session is live */ }

// Rotate with a retry-safe nonce (idempotent retries).
const rotated = await manager.service.rotate(token, { userId: 'user-42', rotationNonce: 'uuid' });

await manager.service.touch(token, { userId: 'user-42' });
await manager.service.revoke(token, { userId: 'user-42' });       // tombstone
await manager.service.revokeAll('user-42');                       // logout all devices
await manager.service.setSecurityVersion('user-42', 2);           // invalidates old sessions
  • Never stores the raw token — only jti = SHA-256(token).
  • Validation results are discriminated: { valid: true, session } or { valid: false, reason: 'invalid' | 'not_found' | 'expired' | 'idle_timeout' | 'revoked' | 'binding_mismatch' }.
  • Fail closed: infra errors are SessionStorageError (503), never "invalid"; a broken revocation-store read is a 503, not a 401. The circuit breaker trips only on storage failures — bad tokens, consumed sessions and concurrent-update conflicts can never open it.
  • Idempotent creation: enableCreateIdempotency: true + an idempotencyKey on create() makes retries return the original session (replayed: true) instead of duplicating it; the claim is TTL-bounded, so replay windows cannot grow forever.
  • Encryption at rest: encryption: { enabled: true } + encryptionKeyProvider (AES-256-GCM, key-versioned).
  • Revocation store: RedisRevocationStore plugs in via the revocationStore option for external jti denylists.
  • Manager surface: manager.service (ops), manager.metrics, manager.health, manager.circuitBreaker, manager.cookies, manager.token, manager.keys — plus manager.config.
  • Cookie helpers: manager.cookies.serialize(token) returns the Set-Cookie string; serializeWithAttributes(token) additionally returns the structured cookie object ({ header, name, value, attributes: { path, domain?, httpOnly, secure, sameSite, maxAge? } }, types exported from both entry points).

Session configuration

| Option | Default | Description | | --- | --- | --- | | enabled | false | Explicit opt-in; the manager refuses to construct otherwise | | namespace | — | Key prefix (e.g. authcore) | | ttl | 2592000 (30d) | Absolute session lifetime in seconds | | idleTimeout | 86400 (24h) | Rolling idle timeout in seconds (null disables) | | rolling | true | touch extends the idle boundary | | touchInterval | 300 | Minimum seconds between touch writes (throttling) | | maxSessionsPerUser | 20 | Eviction ceiling, enforced atomically inside the create script | | securityVersion | off | Global per-user version; a bump invalidates older sessions | | encryption | off | AES-256-GCM envelopes (enabled + encryptionKeyProvider) | | jtiIndex | off | jti -> userId map so validate/touch/rotate work without userId | | checkRevocationStore | false | Consult the revocation store during validation | | bindingPolicy | disabled | strict rejects on IP/UA/device mismatch, advisory reports it | | circuitBreaker | off | failureThreshold 10, resetTimeoutMs 30_000, halfOpenMaxRequests 5 | | enableCreateIdempotency | false | Idempotent create() via idempotencyKey | | retainConsumedTombstones | true | Keep consumed records (TTL-bounded) for replay detection |

Session errors

All session errors extend SessionError (itself a RedisError): SessionNotFoundError, SessionExpiredError, SessionInvalidError, SessionRevokedError, SessionRotationError, SessionConcurrencyError, SessionStorageError (503 — the only one that trips the circuit breaker), SessionSerializationError, SessionConfigurationError, SessionBindingError. Public error messages never contain raw tokens or identifiers.

  • Tested end-to-end against standalone, Sentinel and Cluster — the Sentinel topology (test/infra/) includes a live failover drill (sentinel-failover-probe.mjs): sessions created before a master outage stay valid on the promoted replica. The real-Redis suites skip cleanly when Redis is unreachable.

Migration from RedisSessionStore (removed)

RedisSessionStore and its types (SessionStore, LegacySessionRecord, CreateSessionInput, UpdateSessionInput) are removed. The data and token models are incompatible, so old sessions cannot be read through the new API:

  • The legacy store keyed sessions by a caller-supplied jti and stored raw JSON; the subsystem persists token-derived jtis (SHA-256(token)) in versioned envelopes and validates tokens, not jtis.
  • Existing sessions must be re-established (users re-authenticate). For a smooth cutover, validate old tokens through a time-boxed app-side shim (legacy jti lookup in your own data → mint a new session via create()) and remove the shim once the old tokens age out.
  • Do not run both against the same Redis namespace — records and indexes share the same key layout but use different formats.

Errors

import { RedisError } from 'app-redis';

try {
  await client.select(1);
} catch (error) {
  if (error instanceof RedisError && error.code === 'CLUSTER_MODE') {
    // SELECT is not available in cluster mode
  }
}

Logging

Every component accepts a pino-compatible logger (trace/debug/info/warn/error/fatal + child). Defaults to console.

import { createLogger } from 'pino';
const logger = createLogger();
const client = new RedisClient(config, logger);

Mode compatibility

| Operation | Standalone | Sentinel | Cluster | | --- | --- | --- | --- | | Single-key commands (get/set/hash/set/zset/incr/...) | ✅ | ✅ | ✅ | | mget / mset / mgetClusterAware | ✅ | ✅ | ✅ slot-grouped | | scanIterator / deletePattern / keys | ✅ | ✅ | ✅ all nodes scanned | | Pipelines | ✅ | ✅ | ✅ (same-slot keys per pipeline) | | Lua scripts (eval / evalsha / scriptLoad) | ✅ | ✅ | ✅ (keys declared in KEYS, one slot) | | RedisRevocationStore | ✅ | ✅ | ✅ batch ops slot-grouped | | createSessionManager | ✅ | ✅ | ✅ hash-tagged Lua scripts, slot-grouped fan-out | | select(database) | ✅ | ✅ | ❌ (Redis limitation) | | Hash-tag keys {tag}:... | ✅ | ✅ | ✅ same slot |

Development

npm install
npm run build       # tsc + asset copy (Lua scripts land in dist/session/scripts)
npm run typecheck   # src + test + scripts (tsconfig.test.json)
npm test            # vitest
npm run test:watch

The test suite covers the client, cache and rate limiter (including cluster-mode behavior) using in-memory fakes — no Redis server required. The session suites are gated: they run against real Redis (localhost:6379, or REDIS_MODE=cluster / REDIS_MODE=sentinel with the compose topologies in test/infra/) and skip cleanly when it is unreachable.