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

@wolfstar/plugin-cache

v0.9.1

Published

Pluggable, storage-agnostic Discord entity cache (in-memory and Redis, with optional compression) for @wolfstar/plugin-gateway

Readme

A storage-agnostic Discord entity cache, in memory or in Redis.

version downloads license

Description

The cache behind @wolfstar/plugin-gateway's managers, specified in wolfstar-project/plugins#55.

A Cache is one Map-like EntityCache per Discord entity kind (guilds, users, channels, messages, members, roles, ...) and nothing else. It stores raw, JSON-serializable API data only: building Structures on top of it is the managers' job, which is what lets the same data live in a Map or in Redis.

Swapping the store never changes the call sites:

| Store | Backed by | Extras | | --------------------- | --------- | ---------------------------------------------------- | | createInMemoryCache | Maps | Optional LRU bound, global or per entity | | createRedisCache | Redis | Per-entity TTL, optional gzip or brotli compression | | your own | anything | Implement Cache, every method may return a promise |

Installation

pnpm add @wolfstar/plugin-cache

Usage

Most of the time the cache is handed to a GatewayClient, which writes every dispatch into it:

import { createInMemoryCache } from "@wolfstar/plugin-cache";
import { GatewayClient } from "@wolfstar/plugin-gateway";

const client = new GatewayClient({
  intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
  // Keep at most 1000 messages, every other entity cache is unbounded.
  cache: createInMemoryCache({ maxSize: { messages: 1_000 } }),
});

It can also be used on its own, with any gateway dispatch source:

import { attachCacheToGateway, createInMemoryCache } from "@wolfstar/plugin-cache";

const cache = createInMemoryCache();
const detach = attachCacheToGateway(gateway, cache, {
  onError: (error) => console.error(error),
});

const guild = await cache.guilds?.get(guildId);

applyGatewayDispatch also takes care of the cascades: a CHANNEL_DELETE drops that channel's messages, a GUILD_DELETE drops every entity of that guild, and so on. It only relies on the Cache interface, so custom stores get them for free.

Reactions and poll votes update the cached message: counts, me, and me_voted. Their dispatches only carry the voter's ID, so pass the bot's user ID for the me flags:

await applyGatewayDispatch(cache, payload, { clientUserId: botId });

A reaction or vote on a message the cache does not hold is ignored, since there is nothing to count it into.

Redis

createRedisCache takes any client exposing the handful of commands it needs (RedisClientLike), which an ioredis Redis or Cluster instance satisfies as is:

import { createRedisCache } from "@wolfstar/plugin-cache";
import { Redis } from "ioredis";

const cache = createRedisCache({
  redis: new Redis(process.env.REDIS_URL!),
  prefix: "my-bot:cache",
  compression: "gzip",
  compressionThreshold: 1024,
  ttl: { guilds: 60 * 60, users: 30 * 60 },
});

| Option | Default | Description | | ---------------------- | ------------------ | --------------------------------------------------------------------------------------------------- | | redis | — | The client to use. | | prefix | "wolfstar:cache" | Prefix of every key, entries live at <prefix>:<entity>:<key>. | | compression | "none" | "gzip", "brotli", or "none". | | compressionThreshold | 1024 | Minimum serialized size, in bytes, for a value to be compressed. | | codec | jsonCodec() | Encodes/decodes values before compression, see below. | | legacyCodecs | [] | Former codec values still tagging entries, tried before falling back to jsonCodec(), see below. | | ttl | {} | Time-to-live in seconds, per entity cache. Omitted ones never expire. | | indexGuilds | true | Index guild-scoped entity caches by guild, see below. |

Compressed values are tagged, so turning compression on or off never breaks reading the values already stored. Each entity cache keeps a sorted set index (<prefix>:<entity>:@index) used by keys, entries, getSize, and clear. A value and its index entry are written and deleted in one MULTI transaction, and with a ttl every write also prunes the expired index entries, so the index stays bounded even when nothing enumerates it. The client must therefore support multi(), which ioredis does.

By default, values are JSON (jsonCodec()), then optionally compressed. A denser, still lossless alternative is msgpackCodec(), from the @wolfstar/plugin-cache/msgpack subpath (backed by the optional peer dependency msgpackr, and natively lossless for bigint and binary values):

import { createRedisCache } from "@wolfstar/plugin-cache";
import { msgpackCodec } from "@wolfstar/plugin-cache/msgpack";

const cache = createRedisCache({ redis, codec: msgpackCodec() });

Like compressed values, encoded values are tagged with the codec's name, so switching to a codec never breaks reading entries written under the default jsonCodec(). Switching away from a previously configured non-default codec is different: nothing else records which codec wrote which entry, so list it in legacyCodecs to keep reading its entries:

const cache = createRedisCache({ redis, codec: jsonCodec(), legacyCodecs: [msgpackCodec()] });

Implement CacheCodec (name, encode, decode) for any other format. Codec names "gz", "br", and "b64" are reserved (they collide with the compression/raw-binary markers); a codec named "json" is always treated as the untagged default, regardless of its own encode/decode.

With indexGuilds, the entity caches holding guild data (channels, messages, members, roles, ...) also keep one sorted set per guild (<prefix>:<entity>:@guild:<guildId>). A GUILD_DELETE then reads the guild's keys from it instead of scanning every entry of every entity cache, which on a large cache means reading and decompressing every stored message. The price is one more index write per write, and a read before each delete. Entries written while the option was off are not indexed, so turning it on for a populated cache leaves them behind on GUILD_DELETE until they expire or the cache is cleared. When the transaction exposes pexpire (ioredis does), the guild indexes of an entity cache with a ttl expire with their entries.

Errors

A missing value is not an error: get resolves to undefined. A value that cannot be read back (invalid JSON, or compressed bytes that fail to decompress) rejects with a CacheValueError carrying the Redis key, and the original error as cause. Connection errors are not wrapped, they propagate as the client throws them. @wolfstar/plugin-gateway surfaces both as an error event.

Gateway sessions

createRedisSessionStore stores the gateway shards' sessions for @wolfstar/plugin-gateway's sessionStore option, so a restarted process resumes them instead of identifying again (see Resuming sessions across restarts). It only needs get, set, and del, so it can share the cache's client:

import { createRedisCache, createRedisSessionStore } from "@wolfstar/plugin-cache";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);
const cache = createRedisCache({ redis });
const sessionStore = createRedisSessionStore({ redis, prefix: "my-bot:sessions" });

| Option | Default | Description | | -------- | --------------------- | -------------------------------------------------------------------------------- | | redis | — | The client to use. | | prefix | "wolfstar:sessions" | Prefix of every key, sessions live at <prefix>:<shardId> as JSON. | | ttl | 600 | Seconds a session is kept after its last write, null to keep it until dropped. |

Discord only lets a session be resumed for a while after its connection closes, and does not say for how long: the ttl spares a restart the attempt to resume a session long gone (which costs a connection before identifying anyway) and keeps Redis tidy. Each write pushes the expiration back, and the gateway writes on every dispatch, so only a shard receiving no dispatch for longer than the ttl identifies on the next restart. A value that is not valid JSON rejects with a CacheValueError, which the gateway reports before identifying.

The store is a GatewaySessionStore, any object with the same two methods works:

interface GatewaySessionStore {
  get(shardId: number): Awaitable<GatewaySessionInfo | null>;
  // `null` once the session can no longer be resumed.
  set(shardId: number, info: GatewaySessionInfo | null): Awaitable<void>;
}

GatewaySessionInfo has the same shape as @discordjs/ws's SessionInfo, without the package depending on it.

Cache control

Every entity cache is optional: an entity kind the cache does not hold is simply not cached, and an empty cache is a fully supported configuration (see @wolfstar/plugin-gateway's zero caching). Pick what to cache, and for how long:

import { createCache, createInMemoryCache, MemoryEntityCache } from "@wolfstar/plugin-cache";

// Only these entity kinds, with a default time-to-live (milliseconds) and a periodic sweep.
const cache = createInMemoryCache({
  entities: ["guilds", "channels", "roles", "users", "messages"],
  maxSize: { messages: 1_000 },
  ttl: { messages: 3_600_000 },
  sweepInterval: 60_000,
  // Entry by entry: do not cache bots.
  policies: { users: { filter: (user) => !user.bot } },
});

// Or any store per entity kind, the `CacheConstructor` of the discord.js RFC #11426.
const custom = createCache({
  makeCache: (entity) => (entity === "messages" ? null : new MemoryEntityCache()),
  policies: { roles: { ttl: () => 600_000 } },
});

createRedisCache takes the same entities and policies options. A policy's filter returning false skips the write and deletes an entry already cached under the key, so the cache never serves outdated data; its ttl returns the entry's time-to-live in milliseconds, or null for none. withPolicy(store, policy) applies a policy to any single store.

Custom stores

interface EntityCache<Raw> {
  // Optional: `true` when no method ever returns a promise.
  readonly synchronous?: boolean;
  get(key: string): Awaitable<Raw | undefined>;
  // `ttl` in milliseconds, `null` for none, `undefined` for the store's default.
  set(key: string, value: Raw, options?: { ttl?: number | null }): Awaitable<void>;
  // The RFC's `add`: shallow-merges `data` into the entry unless `overwrite`, and returns both.
  upsert(
    key: string,
    data: Partial<Raw>,
    options?: { overwrite?: boolean; ttl?: number | null },
  ): Awaitable<{ existing?: Raw; added: Raw }>;
  has(key: string): Awaitable<boolean>;
  delete(key: string): Awaitable<boolean>;
  clear(): Awaitable<void>;
  getSize(): Awaitable<number>;
  // Optional: `null` when the store does not index its entries by guild.
  deleteGuild?(guildId: string): Awaitable<number | null>;
}

// Optional extension, see `isIterableCache`.
interface IterableEntityCache<Raw> extends EntityCache<Raw> {
  keys(): Awaitable<string[]>;
  values(): Awaitable<Raw[]>;
  entries(): Awaitable<[key: string, value: Raw][]>;
}

Enumerating a store is optional, since a remote store may not do it cheaply. keys, values, and entries return snapshots rather than live iterators, which keeps the semantics identical between synchronous and asynchronous stores. A store implementing deleteGuild lets applyGatewayDispatch skip the scans of a GUILD_DELETE; without it, or when it resolves to null, the scans run as before, and a store that cannot enumerate its entries is not scanned at all.

applyCacheOperations and applyGatewayDispatch resolve to one result per entry written or deleted, { entity, key, type, existing, added }, so a caller gets the previous state without a second read.

Since an Awaitable cannot be told apart from a promise without calling the method, a store tells its consumers it never returns one through synchronous: the MemoryEntityCaches of createInMemoryCache set it to true, the RedisEntityCaches of createRedisCache to false (compressed or not), and a store leaving it out is treated as asynchronous. It is what lets @wolfstar/plugin-gateway's cached accessors read the cache without awaiting it.

Keys

Entities are keyed by their ID, except the ones scoped to a guild or a channel, which use the guildId:id / channelId:id helpers exported by the package (memberKey, messageKey, roleKey, ...).

Stars module

On framework 6.1 and later, list the module in modules in stars.config (needs the optional @wolfstar/kit peer) to add the package to the auto imports:

// stars.config.ts
export default defineConfig({
  modules: ["@wolfstar/plugin-cache/module"],
});

The module registers no plugin and takes no options: the cache itself is still constructed by @wolfstar/plugin-gateway. It owns the cache key of stars.config (reserved for future options), so no other module can claim it.

Credits

The dispatch-to-cache table is adapted from suneettipirneni's @discordjs/cache prototype (Apache-2.0), itself following discordjs/discord.js#11426.