diff --git a/packages/fetchye/package.json b/packages/fetchye/package.json index 0126492..56a3851 100644 --- a/packages/fetchye/package.json +++ b/packages/fetchye/package.json @@ -61,6 +61,7 @@ ], "dependencies": { "@babel/runtime": "^7.11.2", + "@keyv/redis": "^5.1.6", "cacheable": "^2.5.0", "fetchye-core": "^1.9.0", "object-hash": "^2.0.3", diff --git a/packages/fetchye/src/react-server/index.js b/packages/fetchye/src/react-server/index.js index 8be81f4..10afe3e 100644 --- a/packages/fetchye/src/react-server/index.js +++ b/packages/fetchye/src/react-server/index.js @@ -17,11 +17,12 @@ import makeServerFetchye from './makeServerFetchye'; import { ignoreHeadersByKey } from '../mapOptionsToKeyHelpers'; import { computeKey } from '../computeKey'; -import { invalidateFetchyeTags } from './persistentCache'; +import { invalidateFetchyeTags, connectToRedis } from './persistentCache'; export { makeServerFetchye, ignoreHeadersByKey, computeKey, invalidateFetchyeTags, + connectToRedis, }; diff --git a/packages/fetchye/src/react-server/makeServerFetchye.js b/packages/fetchye/src/react-server/makeServerFetchye.js index 08bc098..6458333 100644 --- a/packages/fetchye/src/react-server/makeServerFetchye.js +++ b/packages/fetchye/src/react-server/makeServerFetchye.js @@ -19,7 +19,7 @@ import { runAsync } from './runAsync'; import { computeKey } from '../computeKey'; import { coerceSsrField } from '../queryHelpers'; import { - persistentCacheStore, + getPersistentCacheStore, validatePersistentCache, getPersistentCacheKey, } from './persistentCache'; @@ -62,7 +62,7 @@ const makeServerFetchye = ({ if (persistentCache) { const { isolationKey } = persistentCache; const persistentCacheKey = getPersistentCacheKey(computedKey, isolationKey); - const cachedResponse = await persistentCacheStore.get(persistentCacheKey); + const cachedResponse = await getPersistentCacheStore().get(persistentCacheKey); if (cachedResponse) { return { @@ -94,7 +94,7 @@ const makeServerFetchye = ({ const storeOptions = { ttl, tags, }; - await persistentCacheStore.set(persistentCacheKey, res, storeOptions); + await getPersistentCacheStore().set(persistentCacheKey, res, storeOptions); } return { data: coerceSsrField(res.data), diff --git a/packages/fetchye/src/react-server/persistentCache.js b/packages/fetchye/src/react-server/persistentCache.js index d5995dd..c0db493 100644 --- a/packages/fetchye/src/react-server/persistentCache.js +++ b/packages/fetchye/src/react-server/persistentCache.js @@ -15,6 +15,7 @@ */ import { Cacheable } from 'cacheable'; +import KeyvRedis from '@keyv/redis'; import computeHash from 'object-hash'; // Applied to newly created entries when a persistentCache config does not provide @@ -22,24 +23,64 @@ import computeHash from 'object-hash'; export const DEFAULT_PERSISTENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes /** - * A single, module-level cache shared by every call made with a `persistentCache` - * option. Unlike the promiseStore (which is only used to dedupe in-flight requests for - * the lifetime of a single request/render), this cache is intended to live across - * requests, which is what makes it "persistent". + * Returns the single, process-wide cache shared by every call made with a + * `persistentCache` option. Unlike the promiseStore (which is only used to dedupe + * in-flight requests for the lifetime of a single request/render), this cache is + * intended to live across requests, which is what makes it "persistent". * - * This is a `cacheable` `Cacheable` instance. Its default (and only, unless a - * `secondary` store is explicitly configured) `primary` store is an in-memory TTL cache + * This is backed by `cacheable`'s shared static instance (`Cacheable.getStaticInstance`), + * so it is safe to call this multiple times (or from multiple files) and always get the + * same underlying cache. Its default (and only, unless a `secondary` store has been + * configured via `connectToRedis`) `primary` store is an in-memory TTL cache * (`CacheableMemory`, via `createKeyv()`), so entries always expire on their own. * * All reads/writes go through cacheable's native, promise-based `get`/`set` API. * * Tag-based invalidation is enabled to support invalidating groups of cache entries by tags. + * + * This is a function rather than a direct reference to a `Cacheable` instance so that, + * if `connectToRedis` later swaps in a new static instance (backed by Redis) via + * `Cacheable.setStaticInstance`, every caller transparently starts using the new + * instance on their next call, without needing to re-import anything. */ -export const persistentCacheStore = new Cacheable({ +export const getPersistentCacheStore = () => Cacheable.getStaticInstance({ ttl: DEFAULT_PERSISTENT_CACHE_TTL, tags: true, }); +/** + * Connects the shared persistent cache to a Redis server, using it as a `secondary` + * store alongside the existing in-memory `primary` store. This allows the persistent + * cache to be shared across multiple server instances/processes. + * + * If the connection attempt fails, the existing (in-memory only) static instance is + * left untouched, and `false` is returned so the caller can decide how to handle the + * failure (eg. logging, retrying, or simply continuing with an in-memory-only cache). + * + * @param {string} connectionString - A Redis connection string (eg. redis://localhost:6379) + * @returns {Promise} Whether the connection succeeded and the static instance + * was updated to use Redis as its secondary store. + */ +export const connectToRedis = async (connectionString) => { + const keyvRedis = new KeyvRedis(connectionString); + + try { + // getClient() establishes (and awaits) the underlying Redis connection, throwing + // if it fails to connect (KeyvRedis defaults to throwOnConnectError: true). + await keyvRedis.getClient(); + } catch (error) { + return false; + } + + Cacheable.setStaticInstance(new Cacheable({ + ttl: DEFAULT_PERSISTENT_CACHE_TTL, + tags: true, + secondary: keyvRedis, + })); + + return true; +}; + /** * Validates the shape of a `persistentCache` option. Throws when invalid. */ @@ -85,5 +126,5 @@ export const invalidateFetchyeTags = async (tags) => { throw new Error('invalidateFetchyeTags requires at least one tag'); } - await persistentCacheStore.tags.invalidateTags(tags); + await getPersistentCacheStore().tags.invalidateTags(tags); }; diff --git a/yarn.lock b/yarn.lock index 50c5081..7fca31e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1843,6 +1843,15 @@ hashery "^1.4.0" hookified "^1.15.0" +"@keyv/redis@^5.1.6": + version "5.1.6" + resolved "https://registry.yarnpkg.com/@keyv/redis/-/redis-5.1.6.tgz#f56030cc2883fa30401da814761d350aad87adee" + integrity sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ== + dependencies: + "@redis/client" "^5.10.0" + cluster-key-slot "^1.1.2" + hookified "^1.13.0" + "@keyv/serialize@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz" @@ -2277,6 +2286,13 @@ dependencies: "@octokit/openapi-types" "^24.2.0" +"@redis/client@^5.10.0": + version "5.12.1" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-5.12.1.tgz#a35a2bac546c727d7915d2d91b63a77111e51ebd" + integrity sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA== + dependencies: + cluster-key-slot "1.1.2" + "@sigstore/bundle@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-4.0.0.tgz" @@ -3582,6 +3598,11 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= +cluster-key-slot@1.1.2, cluster-key-slot@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + cmd-shim@6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-6.0.3.tgz" @@ -5764,7 +5785,7 @@ hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" -hookified@^1.15.0, hookified@^1.15.1: +hookified@^1.13.0, hookified@^1.15.0, hookified@^1.15.1: version "1.15.1" resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.15.1.tgz" integrity sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==