strapi-cache 1.8.3 → 1.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,9 +35,31 @@ or
35
35
  yarn add strapi-cache
36
36
  ```
37
37
 
38
- ## ⚙️ Configuration
38
+ ## Quickstart
39
+
40
+ ```javascript
41
+ // config/plugins.{js,ts}
42
+ 'strapi-cache': {
43
+ enabled: true,
44
+ },
45
+ ```
46
+
47
+ To use **Redis** or **Valkey** instead of memory, set `provider` and `redisConfig` (required for those providers):
48
+
49
+ ```javascript
50
+ // config/plugins.{js,ts}
51
+ 'strapi-cache': {
52
+ enabled: true,
53
+ config: {
54
+ provider: 'redis', // or 'valkey'
55
+ redisConfig: env('REDIS_URL', 'redis://127.0.0.1:6379'),
56
+ },
57
+ },
58
+ ```
59
+
60
+ See [ioredis](https://github.com/redis/ioredis) (Redis) or [iovalkey](https://github.com/valkey-io/iovalkey) (Valkey) for advanced `redisConfig` shapes (URL string or client options object).
39
61
 
40
- In your Strapi project, navigate to `config/plugins.js` and add the following configuration:
62
+ Full configuration example:
41
63
 
42
64
  ```javascript
43
65
  // config/plugins.{js,ts}
@@ -51,7 +73,7 @@ In your Strapi project, navigate to `config/plugins.js` and add the following co
51
73
  allowStale: false, // Allow stale cache items (only for memory cache)
52
74
  cacheableRoutes: ['/api/products', '/api/categories'], // Caches routes which start with these paths (if empty array, all '/api' routes are cached)
53
75
  // cacheableEntities: ['products', 'categories'], // (Optional) Specify which entities to cache. When set, only these entities will be cached (ignores cacheableRoutes). If not set (undefined), cacheableRoutes logic is used
54
- excludeRoutes: ['/api/products/private'], // (NEW) Exclude routes which start with these paths from being cached (takes precedence over cacheableRoutes). **Note:** `excludeRoutes` takes precedence over `cacheableRoutes`.
76
+ excludeRoutes: ['/api/products/private'], // Exclude routes which start with these paths from being cached (takes precedence over cacheableRoutes). **Note:** `excludeRoutes` takes precedence over `cacheableRoutes`.
55
77
  provider: 'memory', // Cache provider ('memory', 'redis' or 'valkey')
56
78
  redisConfig: env('REDIS_URL', 'redis://localhost:6379'), // Redis/Valkey config: string or object. See https://github.com/redis/ioredis (Redis) or https://github.com/valkey-io/iovalkey (Valkey)
57
79
  redisClusterNodes: [], // If provided any cluster node (this list is not empty), initialize cluster client. Each object must have keys 'host' and 'port'
@@ -70,6 +92,36 @@ In your Strapi project, navigate to `config/plugins.js` and add the following co
70
92
  },
71
93
  ```
72
94
 
95
+ ## ⚙️ Configuration
96
+
97
+ Possible configuration keys are listed below; omitted keys keep the plugin defaults.
98
+
99
+ | Key | Description | Possible values |
100
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
101
+ | `debug` | Log cache decisions and operations to the server console | `true` or `false` (default: `false`) |
102
+ | `provider` | Where entries are stored | `'memory'`, `'redis'`, or `'valkey'` (default: `'memory'`) |
103
+ | `redisConfig` | Redis/Valkey connection: URL string or client options passed to ioredis/iovalkey | String or object; **required** when `provider` is `'redis'` or `'valkey'`. Default: value of `REDIS_URL` from the environment |
104
+ | `redisClusterNodes` | Seed nodes for Redis cluster mode; non-empty list switches to a cluster client | Array of `{ host: string, port: number }` (default: `[]`) |
105
+ | `redisClusterOptions` | Options for the cluster client (e.g. `scaleReads`); `redisOptions` often come from `redisConfig` | Object (default: `{}`) |
106
+ | `redisScanDeleteCount` | `COUNT` hint for `SCAN` when purging keys (Redis/Valkey) | Positive number (default: `100`) |
107
+ | `max` | Maximum number of entries (in-memory provider only) | Positive integer (default: `1000`) |
108
+ | `ttl` | Time-to-live for each entry, in milliseconds | Non-negative number (default: `3600000`, i.e. 1 hour) |
109
+ | `size` | Approximate max total size in bytes (in-memory provider only) | Positive integer (default: `10485760`, i.e. 10 MB) |
110
+ | `allowStale` | Whether stale entries may be returned (in-memory provider only) | `true` or `false` (default: `false`) |
111
+ | `cacheableRoutes` | Only URLs starting with one of these paths are cached; if empty, every URL under the REST API prefix matches | Array of path prefix strings (default: `[]` meaning “all API routes”) |
112
+ | `cacheableEntities` | If non-empty, only these API “entity” segments are cached; **when set, this drives eligibility instead of** `cacheableRoutes` | Array of strings (e.g. collection/table names), or omit / leave empty to use `cacheableRoutes` |
113
+ | `excludeRoutes` | URLs starting with any of these prefixes are **never** cached; evaluated before `cacheableRoutes` / entities | Array of path prefix strings (default: `[]`) |
114
+ | `cacheHeaders` | Store and replay response headers with the body | `true` or `false` (default: `true`) |
115
+ | `cacheHeadersDenyList` | Header names (lowercase) to strip when `cacheHeaders` is `true` | Array of strings (default: `[]`) |
116
+ | `cacheHeadersAllowList` | If non-empty, only these header names (lowercase) are stored; if empty, all headers are stored (subject to deny list) | Array of strings (default: `[]`) |
117
+ | `cacheAuthorizedRequests` | Whether to cache requests that include an `Authorization` header | `true` or `false` (default: `false`) |
118
+ | `cacheGetTimeoutInMs` | Max time to wait for a cache read before treating it as a miss | Milliseconds (default: `1000`) |
119
+ | `autoPurgeCache` | Invalidate relevant REST cache entries after content create/update/delete | `true` or `false` (default: `true`) |
120
+ | `autoPurgeGraphQL` | Invalidate GraphQL cache after content create/update/delete | `true` or `false` (default: `false` if omitted; set `true` to enable) |
121
+ | `autoPurgeCacheOnStart` | Clear the cache when Strapi starts | `true` or `false` (default: `true`) |
122
+ | `disableAdminPopups` | Turn off admin UI notifications for cache actions | `true` or `false` (default: `false`) |
123
+ | `disableAdminButtons` | Hide manual purge controls in the admin (list and edit views) | `true` or `false` (default: `false`) |
124
+
73
125
  ## 🔍 Routes
74
126
 
75
127
  The plugin creates three new routes
@@ -85,7 +137,7 @@ All of these routes are protected by the policies `admin::isAuthenticatedAdmin`
85
137
 
86
138
  - **Storage**: The plugin keeps cached data in memory, Redis or Valkey, depending on the configuration.
87
139
  - **Packages**: Uses [lru-cache](https://github.com/isaacs/node-lru-cache) for in-memory cache. Uses [ioredis](https://github.com/redis/ioredis) for Redis and [iovalkey](https://github.com/valkey-io/iovalkey) for Valkey caching.
88
- - **Automatic Invalidation**: Cache is cleared automatically when content is updated, deleted, or created. (GraphQL cache clears on any content update.)
140
+ - **Automatic Invalidation**: When `autoPurgeCache` is enabled (default), relevant REST cache entries are invalidated on content create, update, or delete. When `autoPurgeGraphQL` is enabled, GraphQL cache is invalidated the same way (it is off unless you set it in config).
89
141
  - **`no-cache` Header Support**: Respects the `no-cache` header, letting you skip the cache by setting `Cache-Control: no-cache` in your request.
90
142
  - **Default Cached Requests**: By default, caches all GET requests to `/api` (or whatever prefix you defined) and POST requests to `/graphql`. You can customize which routes or entities to cache using `cacheableRoutes` or `cacheableEntities` config options.
91
143
 
@@ -5,7 +5,6 @@ const zlib = require("zlib");
5
5
  const rawBody = require("raw-body");
6
6
  const lruCache = require("lru-cache");
7
7
  const ioredis = require("ioredis");
8
- const iovalkey = require("iovalkey");
9
8
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
10
9
  const Stream__default = /* @__PURE__ */ _interopDefault(Stream);
11
10
  const rawBody__default = /* @__PURE__ */ _interopDefault(rawBody);
@@ -274,6 +273,9 @@ const middleware$1 = async (ctx, next) => {
274
273
  if (cacheEntry && !noCache) {
275
274
  loggy.info(`HIT with key: ${key}`);
276
275
  ctx.status = 200;
276
+ if (cacheEntry?.body?.type === "Buffer" && Array.isArray(cacheEntry.body.data)) {
277
+ cacheEntry.body = Buffer.from(cacheEntry.body.data);
278
+ }
277
279
  ctx.body = cacheEntry.body;
278
280
  if (cacheHeaders) {
279
281
  ctx.set(cacheEntry.headers);
@@ -544,7 +546,8 @@ const config = {
544
546
  autoPurgeCache: true,
545
547
  autoPurgeCacheOnStart: true,
546
548
  disableAdminPopups: false,
547
- disableAdminButtons: false
549
+ disableAdminButtons: false,
550
+ redisScanDeleteCount: 100
548
551
  }),
549
552
  validator: (config2) => {
550
553
  if (typeof config2.debug !== "boolean") {
@@ -624,6 +627,9 @@ const config = {
624
627
  if (typeof config2.disableAdminButtons !== "boolean") {
625
628
  throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
626
629
  }
630
+ if (typeof config2.redisScanDeleteCount !== "number") {
631
+ throw new Error(`Invalid config: redisScanDeleteCount must be a number`);
632
+ }
627
633
  }
628
634
  };
629
635
  const contentTypes = {};
@@ -856,34 +862,20 @@ class RedisCacheProvider {
856
862
  this.strapi.plugin("strapi-cache").config("cacheGetTimeoutInMs")
857
863
  );
858
864
  this.keyPrefix = this.strapi.plugin("strapi-cache").config("redisConfig")?.["keyPrefix"] ?? "";
859
- if (provider === "valkey") {
860
- if (redisClusterNodes.length) {
861
- const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions") ?? {};
862
- const clusterOptions = { ...redisClusterOptions };
863
- if (!clusterOptions["redisOptions"]) {
864
- clusterOptions["redisOptions"] = redisConfig;
865
- }
866
- this.client = new iovalkey.Cluster(
867
- redisClusterNodes,
868
- clusterOptions
869
- );
870
- } else {
871
- this.client = new iovalkey.Redis(redisConfig);
865
+ this.redisScanDeleteCount = Number(
866
+ this.strapi.plugin("strapi-cache").config("redisScanDeleteCount")
867
+ );
868
+ if (redisClusterNodes.length) {
869
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
870
+ if (!redisClusterOptions["redisOptions"]) {
871
+ redisClusterOptions.redisOptions = redisConfig;
872
872
  }
873
- loggy.info("Valkey provider initialized");
873
+ this.client = new ioredis.Redis.Cluster(redisClusterNodes, redisClusterOptions);
874
874
  } else {
875
- if (redisClusterNodes.length) {
876
- const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
877
- if (!redisClusterOptions["redisOptions"]) {
878
- redisClusterOptions.redisOptions = redisConfig;
879
- }
880
- this.client = new ioredis.Redis.Cluster(redisClusterNodes, redisClusterOptions);
881
- } else {
882
- this.client = new ioredis.Redis(redisConfig);
883
- }
884
- loggy.info("Redis provider initialized");
875
+ this.client = new ioredis.Redis(redisConfig);
885
876
  }
886
877
  this.initialized = true;
878
+ loggy.info(`${provider === "valkey" ? "Valkey" : "Redis"} provider initialized`);
887
879
  } catch (error) {
888
880
  loggy.error(error);
889
881
  }
@@ -931,18 +923,6 @@ class RedisCacheProvider {
931
923
  return null;
932
924
  }
933
925
  }
934
- /**
935
- * Deletes all given keys in Redis pipeline.
936
- * @param keys to delete from cache
937
- */
938
- async delAll(keys) {
939
- const pipeline = this.client.pipeline();
940
- keys.forEach((key) => {
941
- const relativeKey = key.slice(this.keyPrefix.length);
942
- pipeline.del(relativeKey);
943
- });
944
- await pipeline.exec();
945
- }
946
926
  async keys() {
947
927
  if (!this.ready) return null;
948
928
  try {
@@ -972,13 +952,37 @@ class RedisCacheProvider {
972
952
  return null;
973
953
  }
974
954
  }
955
+ async deleteBatch(client, batch, regExps) {
956
+ const toDelete = batch.filter((key) => regExps.some((re) => re.test(key)));
957
+ if (toDelete.length === 0) return;
958
+ const pipeline = client.pipeline();
959
+ for (const key of toDelete) {
960
+ const relativeKey = key.startsWith(this.keyPrefix) ? key.slice(this.keyPrefix.length) : key;
961
+ pipeline.del(relativeKey);
962
+ }
963
+ await pipeline.exec();
964
+ }
965
+ scanAndDelete(client, regExps) {
966
+ return new Promise((resolve, reject) => {
967
+ const stream = client.scanStream({ match: `${this.keyPrefix}*`, count: this.redisScanDeleteCount });
968
+ stream.on("data", (batch) => {
969
+ stream.pause();
970
+ this.deleteBatch(client, batch, regExps).then(() => stream.resume()).catch(reject);
971
+ });
972
+ stream.on("end", resolve);
973
+ stream.on("error", reject);
974
+ });
975
+ }
976
+ /**
977
+ * ScanStream keys and batch delete for Redis,
978
+ * iterates over master nodes in case of Cluster.
979
+ */
975
980
  async clearByRegexp(regExps) {
976
- const keys = await this.keys();
977
- if (!keys) {
978
- return;
981
+ if (this.client instanceof ioredis.Redis) {
982
+ return this.scanAndDelete(this.client, regExps);
979
983
  }
980
- const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
981
- await this.delAll(toDelete);
984
+ const nodes = this.client.nodes("master");
985
+ await Promise.all(nodes.map((node) => this.scanAndDelete(node, regExps)));
982
986
  }
983
987
  }
984
988
  const resolveCacheProvider = (strapi2) => {
@@ -3,8 +3,7 @@ import Stream, { Readable } from "stream";
3
3
  import { createInflate, createBrotliDecompress, createGunzip } from "zlib";
4
4
  import rawBody from "raw-body";
5
5
  import { LRUCache } from "lru-cache";
6
- import { Redis as Redis$1 } from "ioredis";
7
- import { Cluster, Redis } from "iovalkey";
6
+ import { Redis } from "ioredis";
8
7
  const loggy = {
9
8
  info: (msg) => {
10
9
  const shouldDebug = strapi.plugin("strapi-cache").config("debug") ?? false;
@@ -270,6 +269,9 @@ const middleware$1 = async (ctx, next) => {
270
269
  if (cacheEntry && !noCache) {
271
270
  loggy.info(`HIT with key: ${key}`);
272
271
  ctx.status = 200;
272
+ if (cacheEntry?.body?.type === "Buffer" && Array.isArray(cacheEntry.body.data)) {
273
+ cacheEntry.body = Buffer.from(cacheEntry.body.data);
274
+ }
273
275
  ctx.body = cacheEntry.body;
274
276
  if (cacheHeaders) {
275
277
  ctx.set(cacheEntry.headers);
@@ -540,7 +542,8 @@ const config = {
540
542
  autoPurgeCache: true,
541
543
  autoPurgeCacheOnStart: true,
542
544
  disableAdminPopups: false,
543
- disableAdminButtons: false
545
+ disableAdminButtons: false,
546
+ redisScanDeleteCount: 100
544
547
  }),
545
548
  validator: (config2) => {
546
549
  if (typeof config2.debug !== "boolean") {
@@ -620,6 +623,9 @@ const config = {
620
623
  if (typeof config2.disableAdminButtons !== "boolean") {
621
624
  throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
622
625
  }
626
+ if (typeof config2.redisScanDeleteCount !== "number") {
627
+ throw new Error(`Invalid config: redisScanDeleteCount must be a number`);
628
+ }
623
629
  }
624
630
  };
625
631
  const contentTypes = {};
@@ -852,34 +858,20 @@ class RedisCacheProvider {
852
858
  this.strapi.plugin("strapi-cache").config("cacheGetTimeoutInMs")
853
859
  );
854
860
  this.keyPrefix = this.strapi.plugin("strapi-cache").config("redisConfig")?.["keyPrefix"] ?? "";
855
- if (provider === "valkey") {
856
- if (redisClusterNodes.length) {
857
- const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions") ?? {};
858
- const clusterOptions = { ...redisClusterOptions };
859
- if (!clusterOptions["redisOptions"]) {
860
- clusterOptions["redisOptions"] = redisConfig;
861
- }
862
- this.client = new Cluster(
863
- redisClusterNodes,
864
- clusterOptions
865
- );
866
- } else {
867
- this.client = new Redis(redisConfig);
861
+ this.redisScanDeleteCount = Number(
862
+ this.strapi.plugin("strapi-cache").config("redisScanDeleteCount")
863
+ );
864
+ if (redisClusterNodes.length) {
865
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
866
+ if (!redisClusterOptions["redisOptions"]) {
867
+ redisClusterOptions.redisOptions = redisConfig;
868
868
  }
869
- loggy.info("Valkey provider initialized");
869
+ this.client = new Redis.Cluster(redisClusterNodes, redisClusterOptions);
870
870
  } else {
871
- if (redisClusterNodes.length) {
872
- const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
873
- if (!redisClusterOptions["redisOptions"]) {
874
- redisClusterOptions.redisOptions = redisConfig;
875
- }
876
- this.client = new Redis$1.Cluster(redisClusterNodes, redisClusterOptions);
877
- } else {
878
- this.client = new Redis$1(redisConfig);
879
- }
880
- loggy.info("Redis provider initialized");
871
+ this.client = new Redis(redisConfig);
881
872
  }
882
873
  this.initialized = true;
874
+ loggy.info(`${provider === "valkey" ? "Valkey" : "Redis"} provider initialized`);
883
875
  } catch (error) {
884
876
  loggy.error(error);
885
877
  }
@@ -927,18 +919,6 @@ class RedisCacheProvider {
927
919
  return null;
928
920
  }
929
921
  }
930
- /**
931
- * Deletes all given keys in Redis pipeline.
932
- * @param keys to delete from cache
933
- */
934
- async delAll(keys) {
935
- const pipeline = this.client.pipeline();
936
- keys.forEach((key) => {
937
- const relativeKey = key.slice(this.keyPrefix.length);
938
- pipeline.del(relativeKey);
939
- });
940
- await pipeline.exec();
941
- }
942
922
  async keys() {
943
923
  if (!this.ready) return null;
944
924
  try {
@@ -968,13 +948,37 @@ class RedisCacheProvider {
968
948
  return null;
969
949
  }
970
950
  }
951
+ async deleteBatch(client, batch, regExps) {
952
+ const toDelete = batch.filter((key) => regExps.some((re) => re.test(key)));
953
+ if (toDelete.length === 0) return;
954
+ const pipeline = client.pipeline();
955
+ for (const key of toDelete) {
956
+ const relativeKey = key.startsWith(this.keyPrefix) ? key.slice(this.keyPrefix.length) : key;
957
+ pipeline.del(relativeKey);
958
+ }
959
+ await pipeline.exec();
960
+ }
961
+ scanAndDelete(client, regExps) {
962
+ return new Promise((resolve, reject) => {
963
+ const stream = client.scanStream({ match: `${this.keyPrefix}*`, count: this.redisScanDeleteCount });
964
+ stream.on("data", (batch) => {
965
+ stream.pause();
966
+ this.deleteBatch(client, batch, regExps).then(() => stream.resume()).catch(reject);
967
+ });
968
+ stream.on("end", resolve);
969
+ stream.on("error", reject);
970
+ });
971
+ }
972
+ /**
973
+ * ScanStream keys and batch delete for Redis,
974
+ * iterates over master nodes in case of Cluster.
975
+ */
971
976
  async clearByRegexp(regExps) {
972
- const keys = await this.keys();
973
- if (!keys) {
974
- return;
977
+ if (this.client instanceof Redis) {
978
+ return this.scanAndDelete(this.client, regExps);
975
979
  }
976
- const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
977
- await this.delAll(toDelete);
980
+ const nodes = this.client.nodes("master");
981
+ await Promise.all(nodes.map((node) => this.scanAndDelete(node, regExps)));
978
982
  }
979
983
  }
980
984
  const resolveCacheProvider = (strapi2) => {
@@ -22,6 +22,7 @@ declare const _default: {
22
22
  autoPurgeCacheOnStart: boolean;
23
23
  disableAdminPopups: boolean;
24
24
  disableAdminButtons: boolean;
25
+ redisScanDeleteCount: number;
25
26
  };
26
27
  validator: (config: any) => void;
27
28
  };
@@ -31,6 +31,7 @@ declare const _default: {
31
31
  autoPurgeCacheOnStart: boolean;
32
32
  disableAdminPopups: boolean;
33
33
  disableAdminButtons: boolean;
34
+ redisScanDeleteCount: number;
34
35
  };
35
36
  validator: (config: any) => void;
36
37
  };
@@ -6,18 +6,20 @@ export declare class RedisCacheProvider implements CacheProvider {
6
6
  private client;
7
7
  private cacheGetTimeoutInMs;
8
8
  private keyPrefix;
9
+ private redisScanDeleteCount;
9
10
  constructor(strapi: Core.Strapi);
10
11
  init(): void;
11
12
  get ready(): boolean;
12
13
  get(key: string): Promise<any | null>;
13
14
  set(key: string, val: any): Promise<any | null>;
14
15
  del(key: string): Promise<any | null>;
15
- /**
16
- * Deletes all given keys in Redis pipeline.
17
- * @param keys to delete from cache
18
- */
19
- delAll(keys: string[]): Promise<void>;
20
16
  keys(): Promise<string[] | null>;
21
17
  reset(): Promise<any | null>;
18
+ private deleteBatch;
19
+ private scanAndDelete;
20
+ /**
21
+ * ScanStream keys and batch delete for Redis,
22
+ * iterates over master nodes in case of Cluster.
23
+ */
22
24
  clearByRegexp(regExps: RegExp[]): Promise<void>;
23
25
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.3",
2
+ "version": "1.8.8",
3
3
  "keywords": [
4
4
  "strapi cache",
5
5
  "strapi rest cache",
@@ -42,7 +42,6 @@
42
42
  "@strapi/design-system": "^2.0.1",
43
43
  "@strapi/icons": "^2.0.1",
44
44
  "ioredis": "^5.6.1",
45
- "iovalkey": "^0.3.3",
46
45
  "lru-cache": "^11.1.0",
47
46
  "raw-body": "^3.0.0",
48
47
  "react-intl": "^7.1.10"