Skip to main content
jg-packages

Search

    Redis

    jg-redis

    A read-through cache that keeps working when Redis fails, and fail-closed one-time claims and single-use values, on node-redis.

    v0.2.0Node.jsESMMIT

    npm install jg-redis

    Usage

    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.

    A read-through cache

    ts
    import { connectRedis, createCache } from 'jg-redis';
    import { z } from 'zod';
    
    declare const redis: Awaited<ReturnType<typeof connectRedis>>;
    declare const loadProfile: (id: string) => Promise<{ name: string } | null>;
    
    const cache = createCache(redis);
    
    const profile = await cache.getOrLoad('user:42:profile', {
      schema: z.object({ name: z.string() }).nullable(),
      ttlSeconds: 300,
      load: () => loadProfile('42'),
    });
    
    await cache.invalidate('user:42:profile'); // after the profile changes
    • 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 replay
    const 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 command
    const 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.

    jg-redis

    Functionsjg-redis

    connectRedis
    function

    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.

    Signature
    connectRedis(options: IRedisOptions): Promise<IRedis>

    Parameters

    optionsIRedisOptions

    node-redis's client options, plus onError.

    Returns

    Promise<IRedis>

    A promise that resolves to the connected client.

    Example
    import { connectRedis } from 'jg-redis';
    
    const redis = await connectRedis({
      socket: { host: 'localhost', port: 6379 },
      password: 'secret',
      onError: (error) => console.error('Redis', error),
    });
    
    await redis.client.set('greeting', 'hello', { EX: 60 });
    await redis.close();
    createBlocklist
    function

    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.

    Signature
    createBlocklist(redis: IRedis, options: IBlocklistOptions): IBlocklist

    Parameters

    redisIRedis

    The client.

    optionsIBlocklistOptions

    The key of the SET.

    Returns

    IBlocklist

    The list.

    Throws

    • INVALID_BLOCKLIST_KEYThe blocklist key must be a non-empty string.
    Example
    import { connectRedis, createBlocklist } from 'jg-redis';
    
    declare const redis: Awaited<ReturnType<typeof connectRedis>>;
    
    const ipBlocklist = createBlocklist(redis, { key: 'ip-blocklist' });
    await ipBlocklist.add(['203.0.113.7']);
    await ipBlocklist.has('203.0.113.7'); // true
    await ipBlocklist.replaceAll(['198.51.100.1', '198.51.100.2']); // 203.0.113.7 is gone
    createCache
    function

    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.

    Signature
    createCache(redis: IRedis): ICache

    Parameters

    redisIRedis

    The client, from connectRedis.

    Returns

    ICache

    The cache.

    Example
    import { connectRedis, createCache } from 'jg-redis';
    import { z } from 'zod';
    
    declare const redis: Awaited<ReturnType<typeof connectRedis>>;
    declare const loadProfile: (id: string) => Promise<{ name: string }>;
    
    const cache = createCache(redis);
    const profile = await cache.getOrLoad('user:42:profile', {
      schema: z.object({ name: z.string() }),
      ttlSeconds: 300,
      load: () => loadProfile('42'),
    });

    Constantsjg-redis

    ERRORS
    const

    the codes for every error thrown by the package

    Declaration
    const ERRORS: { INVALID_TTL: "INVALID_TTL"; COORDINATION_FAILED: "COORDINATION_FAILED"; COORDINATION_VALUE_INVALID: "COORDINATION_VALUE_INVALID"; BLOCKLIST_FAILED: "BLOCKLIST_FAILED"; BLOCKLIST_VALUE_INVALID: "BLOCKLIST_VALUE_INVALID"; INVALID_BLOCKLIST_KEY: "INVALID_BLOCKLIST_KEY" }

    Typesjg-redis

    IBlocklist
    type

    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.

    Declaration
    type IBlocklist = {
        has: (value: string) => Promise<boolean>;
        add: (values: readonly string[]) => Promise<void>;
        remove: (values: readonly string[]) => Promise<void>;
        replaceAll: (values: readonly string[]) => Promise<void>;
    };

    Members

    • hasproperty
      (value: string) => Promise<boolean>

      resolves to whether the value is in the list

    • addproperty
      (values: readonly string[]) => Promise<void>

      adds the values, 1,000 at a time

    • removeproperty
      (values: readonly string[]) => Promise<void>

      removes the values, 1,000 at a time

    • replaceAllproperty
      (values: readonly string[]) => Promise<void>

      replaces the whole list at once: readers see the old list or the new one, never part of it. An empty list deletes the key

    IBlocklistOptions
    type

    what createBlocklist takes

    Declaration
    type IBlocklistOptions = {
        key: string;
    };

    Members

    • keyproperty
      string

      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

    ICache
    type

    a read-through cache on a Redis client

    Declaration
    type ICache = {
        getOrLoad: <T>(key: string, options: IGetOrLoadOptions<T>) => Promise<T>;
        invalidate: (key: string) => Promise<void>;
    };

    Members

    • getOrLoadproperty
      (key: string, options: IGetOrLoadOptions<T>) => Promise<T>

      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

    IGetOrLoadOptions
    type

    what getOrLoad reads and, on a miss, loads and stores

    Declaration
    type IGetOrLoadOptions<T> = {
        schema: ISchema<T>;
        ttlSeconds: number;
        load: () => Promise<T>;
    };

    Members

    • schemaproperty
      ISchema<T>

      checks a cached value before it's returned, such as a Zod schema. A value it rejects is deleted and loaded again

    • ttlSecondsproperty
      number

      how long a loaded value stays cached, in whole seconds, from 1

    • loadproperty
      () => Promise<T>

      loads the value on a miss, such as with a database query. Its error propagates, and nothing is cached

    IRedis
    type

    a connected Redis client

    Declaration
    type IRedis = {
        readonly client: IRedisClient;
        close: () => Promise<void>;
    };

    Members

    • clientproperty
      IRedisClient

      the node-redis client, for the commands the helpers don't cover

    • closeproperty
      () => Promise<void>

      closes the client once its pending replies arrive. It never flushes any data

    IRedisClient
    type

    a node-redis client, as createClient returns it

    Declaration
    type IRedisClient = ReturnType<typeof createClient>;
    IRedisOptions
    type

    what connectRedis takes: node-redis's client options, such as the URL or the socket's host and port, plus where the client's errors go

    Declaration
    type IRedisOptions = Omit<RedisClientOptions, 'disableOfflineQueue'> & {
        onError?: ((error: unknown) => void) | undefined;
    };
    ISchema
    type

    validates data and returns it typed, which a Zod schema does

    Declaration
    type ISchema<T> = {
        parse(value: unknown): T;
    };

    Members

    • parsemethod
      parse(value: unknown): T

    jg-redis/coordination

    Functionsjg-redis/coordination

    claimOnce
    function

    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.

    Signature
    claimOnce(
      redis: IRedis,
      namespace: string,
      token: string,
      ttlSeconds: number,
    ): Promise<boolean>

    Parameters

    redisIRedis

    The client, from connectRedis.

    namespacestring

    The key's prefix, such as user:42:used-otp.

    tokenstring

    The token to claim.

    ttlSecondsnumber

    How long the claim lasts, in whole seconds: at least as long as the token stays valid.

    Returns

    Promise<boolean>

    A promise that resolves to whether this call made the claim.

    Throws

    • INVALID_TTLThe time-to-live must be a whole number of seconds, from 1.
    • COORDINATION_FAILEDRedis could not complete the coordination command.
    Example
    import { connectRedis } from 'jg-redis';
    import { claimOnce } from 'jg-redis/coordination';
    
    declare const redis: Awaited<ReturnType<typeof connectRedis>>;
    
    if (!(await claimOnce(redis, 'user:42:used-otp', '123456', 90))) {
      // the code was already used
    }
    storeSingleUseValue
    function

    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.

    Signature
    storeSingleUseValue(
      redis: IRedis,
      key: string,
      value: unknown,
      ttlSeconds: number,
    ): Promise<void>

    Parameters

    redisIRedis

    The client, from connectRedis.

    keystring

    The key, such as user-auth:sign-in-challenge:<token hash>.

    valueunknown

    The value, stored as JSON.

    ttlSecondsnumber

    How long it can be taken, in whole seconds.

    Returns

    Promise<void>

    A promise that resolves once it's stored.

    Throws

    • INVALID_TTLThe time-to-live must be a whole number of seconds, from 1.
    • COORDINATION_FAILEDRedis could not complete the coordination command.
    Example
    import { connectRedis } from 'jg-redis';
    import { storeSingleUseValue } from 'jg-redis/coordination';
    
    declare const redis: Awaited<ReturnType<typeof connectRedis>>;
    
    await storeSingleUseValue(redis, 'user-auth:challenge:9f86d081', { userId: '42' }, 300);
    takeSingleUseValue
    function

    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.

    Signature
    takeSingleUseValue<T>(
      redis: IRedis,
      key: string,
      schema: ISchema<T>,
    ): Promise<T | null>

    Parameters

    redisIRedis

    The client, from connectRedis.

    keystring

    The value's key.

    schemaISchema<T>

    Checks the value, such as a Zod schema.

    Returns

    Promise<T | null>

    A promise that resolves to the value, or null when it's missing, taken, or expired.

    Throws

    • COORDINATION_FAILEDRedis could not complete the coordination command.
    • COORDINATION_VALUE_INVALIDThe single-use value is not valid JSON or does not match its schema.
    Example
    import { connectRedis } from 'jg-redis';
    import { takeSingleUseValue } from 'jg-redis/coordination';
    import { z } from 'zod';
    
    declare const redis: Awaited<ReturnType<typeof connectRedis>>;
    
    const challenge = await takeSingleUseValue(
      redis,
      'user-auth:challenge:9f86d081',
      z.object({ userId: z.string() }),
    );