Connect one client per Redis server, and close it at shutdown:
ts
import { connectRedis } from 'jg-redis';const redis = await connectRedis({ socket: { host: 'cache.internal', port: 6379 }, password: 'secret', onError: (error) => console.error('Redis', error),});await redis.close(); // waits for pending replies, and never flushes any data
The client sends its errors, such as a dropped connection, to onError, console.error by default, instead of crashing the process. A command sent while it’s disconnected is rejected instead of queued and replayed later, and connecting gives up after 10 seconds unless socket.connectTimeout says otherwise. redis.client is the node-redis client, for every other command.
Concurrent misses for a key in the process share one load.
A cached null is a hit, not a miss.
A cached value its schema rejects, such as one from an older shape, is deleted and loaded again.
Every value is stored with its TTL in the same command, and a value over 1 MiB isn’t stored.
When Redis fails to read, the value is loaded instead; when a write fails, it’s skipped. Both go to onError, so Redis being down slows the app without breaking it.
invalidate keeps a load already in flight in the process from storing its older value. Across processes, an older load can still store its value after the invalidation, until its TTL ends, so keep TTLs short for data that changes.
One-time claims and single-use values
jg-redis/coordination is for security: replay protection for one-time codes, and values one request takes, such as a sign-in challenge. It fails closed, throwing COORDINATION_FAILED when Redis fails, instead of letting the request through.
ts
import { connectRedis } from 'jg-redis';import { claimOnce, storeSingleUseValue, takeSingleUseValue } from 'jg-redis/coordination';import { z } from 'zod';declare const redis: Awaited<ReturnType<typeof connectRedis>>;// true for the first use of the code, false for a replayconst isFirstUse = await claimOnce(redis, 'user:42:used-otp', '123456', 90);await storeSingleUseValue(redis, 'user-auth:challenge:9f86d081', { userId: '42' }, 300);const challenge = await takeSingleUseValue( redis, 'user-auth:challenge:9f86d081', z.object({ userId: z.string() }),); // null once taken or expired
claimOnce stores only the token’s SHA-256 hash. Name a single-use value’s key after a hash of the token that finds it, never the token. A value that fails its schema throws COORDINATION_VALUE_INVALID, without a cause or the value, which might hold a secret.
These keys must never be evicted early, so run the server with maxmemory-policy noeviction, or keep them on a separate instance from the cache. Under noeviction, a full server rejects writes: the cache skips them, and coordination throws.
A blocklist
createBlocklist keeps a list of blocked values, such as IP addresses, in one Redis SET, for a check on every request. Values are compared exactly, so normalize them the same way when adding and checking:
ts
import { createBlocklist } from 'jg-redis';const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' });await ipBlocklist.add(['203.0.113.7']); // and remove([...]), 1,000 values per commandconst isBlocked = await ipBlocklist.has('203.0.113.7');await ipBlocklist.replaceAll(blockedIps); // atomic: readers see the old list or the new one
replaceAll fills a staging SET, ${key}:replace:<uuid>, and renames it over the list, so a large list is never seen half replaced; an empty list deletes the key. Every command failure throws BLOCKLIST_FAILED, with the cause, so a guard can fail closed and refuse the request.
The list has no expiry, but a flush or an eviction empties it, and then it lets everyone through. Keep the authoritative list in a database, restore it with replaceAll at boot and on a schedule, and run the server with maxmemory-policy noeviction. On Redis Cluster, give the key a hash tag, such as {ip-blocklist}, so the staging key lands in the same slot and the rename works.
Errors
Code
When
INVALID_TTL
A time-to-live isn’t a whole number of seconds, from 1
COORDINATION_FAILED
Redis couldn’t complete a claim, a store, or a take
COORDINATION_VALUE_INVALID
A single-use value isn’t valid JSON or doesn’t match its schema
BLOCKLIST_FAILED
Redis couldn’t complete a blocklist command
BLOCKLIST_VALUE_INVALID
A blocklist value isn’t a string of 1 to 512 characters, or a list isn’t an array
INVALID_BLOCKLIST_KEY
A blocklist’s key is empty
Generated from TSDoc
API reference
Every export of [email protected], 15 in all, with its signature, its errors, and an example.
Connects a Redis client that's safe to share: its errors go to onError instead of crashing the process, a command sent while disconnected is rejected instead of queued and replayed later, and connecting gives up after 10 seconds unless the socket options set another timeout. Closing it waits for pending replies and never flushes any data.
Creates a list of blocked values, such as IP addresses, in one Redis SET. Values are compared exactly, so normalize them the same way when adding and checking. The list never expires, so a flush or an eviction empties it: keep the authoritative list in a database and restore it with replaceAll, at boot or on a schedule. Every command failure throws BLOCKLIST_FAILED, with the cause, so a caller can fail closed.
Creates a read-through cache on a Redis client, for values that are slow to load and fine to serve a little stale. A failed read loads the value instead, and a failed or oversized write is skipped, so Redis being down slows the app without breaking it; both failures go to the client's onError. Concurrent misses for one key in this process share one load.
A list of blocked values, such as IP addresses, in one Redis SET. Values are compared exactly, so the caller normalizes them the same way when adding and checking. A value that isn't a string of 1 to 512 characters throws BLOCKLIST_VALUE_INVALID before any command runs, and every command failure throws BLOCKLIST_FAILED, so a caller can fail closed.
the key of the Redis SET that holds the list, such as ip-blocklist, or {ip-blocklist} on Redis Cluster, so replaceAll's staging key is in the same slot
returns the cached value, or loads, caches, and returns it on a miss, sharing one load among the process's concurrent misses for the key. When Redis fails, it loads without caching
invalidateproperty
(key: string) => Promise<void>
deletes a cached value, so the next read loads it again. A load already in flight in this process won't store its older value afterwards
Claims a token once, such as a one-time code that must not be replayed: the first call for a token in its namespace succeeds, and every other call until the time-to-live ends doesn't. The token is stored only as its SHA-256 hash, under <namespace>:<hash>. A Redis error throws instead of letting the token through.
Stores a value that one later call takes, such as a sign-in challenge's record. Name the key after a hash of any token it's looked up by, never the token itself.
Takes a single-use value: reads and deletes it in one command, so of any concurrent calls, one gets it and the rest get null. A value that fails its schema is already deleted, so it throws COORDINATION_VALUE_INVALID, without a cause or the value, which might hold a secret.