strapi-cache 1.7.0 → 1.8.2-rc.1

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
@@ -17,7 +17,7 @@ Boost your API performance with automatic in-memory or Redis caching for REST an
17
17
  - ♻️ **LRU (Least Recently Used) caching strategy**
18
18
  - 🔧 Simple integration with Strapi config
19
19
  - 📦 Lightweight with zero overhead
20
- - 🗄️ **Supports in-memory and Redis caching**
20
+ - 🗄️ **Supports in-memory, Redis and Valkey caching**
21
21
 
22
22
  ---
23
23
 
@@ -50,10 +50,11 @@ In your Strapi project, navigate to `config/plugins.js` and add the following co
50
50
  size: 1024 * 1024 * 1024, // Maximum size of the cache (1 GB) (only for memory cache)
51
51
  allowStale: false, // Allow stale cache items (only for memory cache)
52
52
  cacheableRoutes: ['/api/products', '/api/categories'], // Caches routes which start with these paths (if empty array, all '/api' routes are cached)
53
+ // 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
53
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`.
54
- provider: 'memory', // Cache provider ('memory' or 'redis')
55
- redisConfig: env('REDIS_URL', 'redis://localhost:6379'), // Redis config takes either a string or an object see https://github.com/redis/ioredis for references to what object is available, the object or string is passed directly to ioredis client (if using Redis)
56
- redisClusterNodes: [], // If provided any cluster node (this list is not empty), initialize ioredis redis cluster client. Each object must have keys 'host' and 'port'. See https://github.com/redis/ioredis for references
55
+ provider: 'memory', // Cache provider ('memory', 'redis' or 'valkey')
56
+ 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
+ redisClusterNodes: [], // If provided any cluster node (this list is not empty), initialize cluster client. Each object must have keys 'host' and 'port'
57
58
  redisClusterOptions: {}, // Options for ioredis redis cluster client. redisOptions key is taken from redisConfig parameter above if not set here. See https://github.com/redis/ioredis for references
58
59
  cacheHeaders: true, // Plugin also stores response headers in the cache (set to false if you don't want to cache headers)
59
60
  cacheHeadersDenyList: ['access-control-allow-origin', 'content-encoding'], // Headers to exclude from the cache (must be lowercase, if empty array, no headers are excluded, cacheHeaders must be true)
@@ -61,8 +62,10 @@ In your Strapi project, navigate to `config/plugins.js` and add the following co
61
62
  cacheAuthorizedRequests: false, // Cache requests with authorization headers (set to true if you want to cache authorized requests)
62
63
  cacheGetTimeoutInMs: 1000, // Timeout for getting cached data in milliseconds (default is 1 second)
63
64
  autoPurgeCache: true, // Automatically purge cache on content CRUD operations
65
+ autoPurgeGraphQL: true, // Automatically purge GraphQL cache on content CRUD operations
64
66
  autoPurgeCacheOnStart: true, // Automatically purge cache on Strapi startup
65
67
  disableAdminPopups: false, // Disable popups in the admin panel
68
+ disableAdminButtons: false, // Disable the purge cache buttons in the admin panel (list view and edit view)
66
69
  },
67
70
  },
68
71
  ```
@@ -80,11 +83,11 @@ All of these routes are protected by the policies `admin::isAuthenticatedAdmin`
80
83
 
81
84
  ## 🗂️ How It Works
82
85
 
83
- - **Storage**: The plugin keeps cached data in memory or Redis, depending on the configuration.
84
- - **Packages**: Uses [lru-cache](https://github.com/isaacs/node-lru-cache) for in-memory cache. Uses [ioredis](https://github.com/redis/ioredis) for Redis caching.
86
+ - **Storage**: The plugin keeps cached data in memory, Redis or Valkey, depending on the configuration.
87
+ - **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.
85
88
  - **Automatic Invalidation**: Cache is cleared automatically when content is updated, deleted, or created. (GraphQL cache clears on any content update.)
86
89
  - **`no-cache` Header Support**: Respects the `no-cache` header, letting you skip the cache by setting `Cache-Control: no-cache` in your request.
87
- - **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 content types to cache in the config (only for GET requests).
90
+ - **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.
88
91
 
89
92
  ## 🔮 Planned Features
90
93
 
@@ -3,7 +3,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const jsxRuntime = require("react/jsx-runtime");
4
4
  const designSystem = require("@strapi/design-system");
5
5
  const reactIntl = require("react-intl");
6
- const index = require("./index-Dwrl1jfJ.js");
6
+ const index = require("./index-D_ssKKxu.js");
7
7
  const react = require("react");
8
8
  const SettingsPage = () => {
9
9
  const { formatMessage } = reactIntl.useIntl();
@@ -297,7 +297,11 @@ function PurgeModal({
297
297
  function PurgeCacheButton() {
298
298
  const { contentType } = unstable_useContentManagerContext();
299
299
  const { formatMessage } = useIntl();
300
+ const { config } = useCacheConfig();
300
301
  const keyToUse = contentType?.info.pluralName;
302
+ if (config?.disableAdminButtons) {
303
+ return null;
304
+ }
301
305
  return /* @__PURE__ */ jsx(
302
306
  PurgeModal,
303
307
  {
@@ -312,10 +316,14 @@ function PurgeCacheButton() {
312
316
  function PurgeEntityButton() {
313
317
  const { formatMessage } = useIntl();
314
318
  const { id, isSingleType, contentType } = unstable_useContentManagerContext();
319
+ const { config } = useCacheConfig();
315
320
  const apiPath = isSingleType ? contentType?.info.singularName : id;
316
321
  if (!apiPath) {
317
322
  return null;
318
323
  }
324
+ if (config?.disableAdminButtons) {
325
+ return null;
326
+ }
319
327
  const keyToUse = encodeURIComponent(apiPath);
320
328
  const contentTypeName = isSingleType ? contentType?.info.singularName : contentType?.info.pluralName;
321
329
  return /* @__PURE__ */ jsx(
@@ -363,7 +371,7 @@ const index = {
363
371
  },
364
372
  id: "settings",
365
373
  to: `${PLUGIN_ID}/settings`,
366
- Component: () => import("./index-BInrGFOe.mjs"),
374
+ Component: () => import("./index-CkLhx2ik.mjs"),
367
375
  permissions: []
368
376
  }
369
377
  ]
@@ -1,7 +1,7 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import { Typography, TextInput } from "@strapi/design-system";
3
3
  import { useIntl } from "react-intl";
4
- import { P as PurgeModal } from "./index-BwjcxGep.mjs";
4
+ import { P as PurgeModal } from "./index-BwuX8jJl.mjs";
5
5
  import { useState } from "react";
6
6
  const SettingsPage = () => {
7
7
  const { formatMessage } = useIntl();
@@ -298,7 +298,11 @@ function PurgeModal({
298
298
  function PurgeCacheButton() {
299
299
  const { contentType } = admin.unstable_useContentManagerContext();
300
300
  const { formatMessage } = reactIntl.useIntl();
301
+ const { config } = useCacheConfig();
301
302
  const keyToUse = contentType?.info.pluralName;
303
+ if (config?.disableAdminButtons) {
304
+ return null;
305
+ }
302
306
  return /* @__PURE__ */ jsxRuntime.jsx(
303
307
  PurgeModal,
304
308
  {
@@ -313,10 +317,14 @@ function PurgeCacheButton() {
313
317
  function PurgeEntityButton() {
314
318
  const { formatMessage } = reactIntl.useIntl();
315
319
  const { id, isSingleType, contentType } = admin.unstable_useContentManagerContext();
320
+ const { config } = useCacheConfig();
316
321
  const apiPath = isSingleType ? contentType?.info.singularName : id;
317
322
  if (!apiPath) {
318
323
  return null;
319
324
  }
325
+ if (config?.disableAdminButtons) {
326
+ return null;
327
+ }
320
328
  const keyToUse = encodeURIComponent(apiPath);
321
329
  const contentTypeName = isSingleType ? contentType?.info.singularName : contentType?.info.pluralName;
322
330
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -364,7 +372,7 @@ const index = {
364
372
  },
365
373
  id: "settings",
366
374
  to: `${PLUGIN_ID}/settings`,
367
- Component: () => Promise.resolve().then(() => require("./index-BjBoZhpS.js")),
375
+ Component: () => Promise.resolve().then(() => require("./index-B_MAAg0W.js")),
368
376
  permissions: []
369
377
  }
370
378
  ]
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-Dwrl1jfJ.js");
2
+ const index = require("../_chunks/index-D_ssKKxu.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-BwjcxGep.mjs";
1
+ import { i } from "../_chunks/index-BwuX8jJl.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -1,2 +1,2 @@
1
- declare function PurgeCacheButton(): import("react/jsx-runtime").JSX.Element;
1
+ declare function PurgeCacheButton(): import("react/jsx-runtime").JSX.Element | null;
2
2
  export default PurgeCacheButton;
@@ -1,6 +1,7 @@
1
1
  export type CacheConfig = {
2
2
  cacheableRoutes: string[];
3
3
  disableAdminPopups: boolean;
4
+ disableAdminButtons: boolean;
4
5
  };
5
6
  export declare const useCacheConfig: (enabled?: boolean) => {
6
7
  config: CacheConfig | undefined;
@@ -5,6 +5,7 @@ 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");
8
9
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
9
10
  const Stream__default = /* @__PURE__ */ _interopDefault(Stream);
10
11
  const rawBody__default = /* @__PURE__ */ _interopDefault(rawBody);
@@ -35,6 +36,12 @@ async function invalidateCache(event, cacheStore, strapi2) {
35
36
  const { model } = event;
36
37
  const uid = model.uid;
37
38
  const restApiPrefix = strapi2.config.get("api.rest.prefix", "/api");
39
+ const cacheableEntities = strapi2.plugin("strapi-cache").config("cacheableEntities");
40
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(model.tableName) : true;
41
+ if (!entityIsCacheable) {
42
+ loggy.info(`Not invalidated. ${model.tableName} is not cacheable.`);
43
+ return;
44
+ }
38
45
  try {
39
46
  const contentType = strapi2.contentType(uid);
40
47
  if (!contentType || !contentType.kind) {
@@ -51,13 +58,31 @@ async function invalidateCache(event, cacheStore, strapi2) {
51
58
  loggy.error(error);
52
59
  }
53
60
  }
54
- async function invalidateGraphqlCache(cacheStore) {
61
+ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
55
62
  try {
56
- const graphqlRegex = new RegExp(`^POST:/graphql(:.*)?$`);
63
+ const { model } = event;
64
+ const contentType = strapi2.contentType(model.uid);
65
+ if (!contentType || !contentType.info) {
66
+ loggy.info(`Content type ${model.uid} not found, purging all GraphQL cache`);
67
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
68
+ await cacheStore.clearByRegexp([graphqlRegex2]);
69
+ return;
70
+ }
71
+ const singularName = contentType.info.singularName ?? "";
72
+ const pluralName = contentType.info.pluralName ?? "";
73
+ const fieldNames = [...new Set([singularName, pluralName].filter(Boolean))];
74
+ if (fieldNames.length === 0) {
75
+ loggy.info(`No field names for ${model.uid}, purging all GraphQL cache`);
76
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
77
+ await cacheStore.clearByRegexp([graphqlRegex2]);
78
+ return;
79
+ }
80
+ const escapedNames = fieldNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
81
+ const graphqlRegex = new RegExp(`^(GET|POST):/graphql:[^:]*\\b(${escapedNames})\\b[^:]*:`);
57
82
  await cacheStore.clearByRegexp([graphqlRegex]);
58
- loggy.info(`Invalidated cache for ${graphqlRegex}`);
83
+ loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
59
84
  } catch (error) {
60
- loggy.error("Cache invalidation error:");
85
+ loggy.error("GraphQL cache invalidation error:");
61
86
  loggy.error(error);
62
87
  }
63
88
  }
@@ -73,8 +98,9 @@ const bootstrap = ({ strapi: strapi2 }) => {
73
98
  loggy.info("Initializing");
74
99
  try {
75
100
  const cacheService = strapi2.plugin("strapi-cache").services.service;
76
- const autoPurgeCache = strapi2.plugin("strapi-cache").config("autoPurgeCache");
77
- const autoPurgeCacheOnStart = strapi2.plugin("strapi-cache").config("autoPurgeCacheOnStart");
101
+ const autoPurgeCache = !!strapi2.plugin("strapi-cache").config("autoPurgeCache");
102
+ const autoPurgeGraphQL = !!strapi2.plugin("strapi-cache").config("autoPurgeGraphQL");
103
+ const autoPurgeCacheOnStart = !!strapi2.plugin("strapi-cache").config("autoPurgeCacheOnStart");
78
104
  const cacheStore = cacheService.getCacheInstance();
79
105
  if (!cacheStore) {
80
106
  loggy.error("Plugin could not be initialized");
@@ -85,15 +111,25 @@ const bootstrap = ({ strapi: strapi2 }) => {
85
111
  strapi2.db.lifecycles.subscribe({
86
112
  async afterCreate(event) {
87
113
  await invalidateCache(event, cacheStore, strapi2);
88
- await invalidateGraphqlCache(cacheStore);
89
114
  },
90
115
  async afterUpdate(event) {
91
116
  await invalidateCache(event, cacheStore, strapi2);
92
- await invalidateGraphqlCache(cacheStore);
93
117
  },
94
118
  async afterDelete(event) {
95
119
  await invalidateCache(event, cacheStore, strapi2);
96
- await invalidateGraphqlCache(cacheStore);
120
+ }
121
+ });
122
+ }
123
+ if (autoPurgeGraphQL) {
124
+ strapi2.db.lifecycles.subscribe({
125
+ async afterCreate(event) {
126
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
127
+ },
128
+ async afterUpdate(event) {
129
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
130
+ },
131
+ async afterDelete(event) {
132
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
97
133
  }
98
134
  });
99
135
  }
@@ -116,11 +152,17 @@ const generateCacheKey = (context) => {
116
152
  const { method } = context.request;
117
153
  return `${method}:${url}`;
118
154
  };
119
- const generateGraphqlCacheKey = (payload) => {
155
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
120
156
  const hash = crypto.createHash("sha256").update(payload).digest("base64url");
121
- return `POST:/graphql:${hash}`;
157
+ const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
158
+ return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
122
159
  };
123
160
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ const generateEntityKey = (url, restApiPrefix) => {
162
+ const regex = new RegExp(`${restApiPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([^/?]*)`);
163
+ const match = url.match(regex);
164
+ return match ? match[1] : "";
165
+ };
124
166
  const streamToBuffer = (stream) => {
125
167
  return new Promise((resolve, reject) => {
126
168
  const chunks = [];
@@ -188,6 +230,7 @@ function getCacheHeaderConfig() {
188
230
  }
189
231
  const middleware$1 = async (ctx, next) => {
190
232
  const cacheService = strapi.plugin("strapi-cache").services.service;
233
+ const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
191
234
  const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
192
235
  const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
193
236
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
@@ -198,13 +241,16 @@ const middleware$1 = async (ctx, next) => {
198
241
  const cacheControlHeader = ctx.request.headers["cache-control"];
199
242
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
200
243
  const restApiPrefix = strapi.config.get("api.rest.prefix", "/api");
244
+ const entityKey = generateEntityKey(url, restApiPrefix);
201
245
  const routeIsExcluded = excludeRoutes.some((route) => url.startsWith(route));
202
246
  if (routeIsExcluded) {
203
247
  loggy.info(`Route excluded from cache: ${url}`);
204
248
  await next();
205
249
  return;
206
250
  }
207
- const routeIsCachable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
251
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(entityKey) : void 0;
252
+ const routeIsCacheable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
253
+ const isCacheable = entityIsCacheable ?? routeIsCacheable;
208
254
  const authorizationHeader = ctx.request.headers["authorization"];
209
255
  if (authorizationHeader && !cacheAuthorizedRequests) {
210
256
  loggy.info(`Authorized request bypassing cache: ${key}`);
@@ -242,7 +288,7 @@ const middleware$1 = async (ctx, next) => {
242
288
  return;
243
289
  }
244
290
  await next();
245
- if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && routeIsCachable) {
291
+ if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && isCacheable) {
246
292
  loggy.info(`MISS with key: ${key}`);
247
293
  const headersToStore = getHeadersToStore(
248
294
  ctx,
@@ -274,32 +320,116 @@ const middleware$1 = async (ctx, next) => {
274
320
  }
275
321
  }
276
322
  };
323
+ function parseGraphqlPayload(body, isGet) {
324
+ if (isGet) {
325
+ try {
326
+ const parsed = JSON.parse(body);
327
+ const variables = parsed.variables;
328
+ const variablesParsed = typeof variables === "string" ? variables ? JSON.parse(variables) : null : variables ?? null;
329
+ return {
330
+ query: parsed.query ?? "",
331
+ variables: variablesParsed,
332
+ operationName: parsed.operationName ?? null
333
+ };
334
+ } catch {
335
+ return { query: body, variables: null, operationName: null };
336
+ }
337
+ }
338
+ try {
339
+ const parsed = JSON.parse(body);
340
+ return {
341
+ query: parsed.query ?? "",
342
+ variables: parsed.variables ?? null,
343
+ operationName: parsed.operationName ?? null
344
+ };
345
+ } catch {
346
+ return { query: body, variables: null, operationName: null };
347
+ }
348
+ }
349
+ function getRootFieldsFromQuery(query) {
350
+ const fields = [];
351
+ let depth = 0;
352
+ let i = 0;
353
+ while (i < query.length) {
354
+ if (query[i] === "{") {
355
+ depth++;
356
+ i++;
357
+ if (depth === 1) {
358
+ const rest = query.slice(i);
359
+ const match = rest.match(/^\s*(\w+)\s*([\(\{])/);
360
+ if (match) {
361
+ fields.push(match[1]);
362
+ i += match[0].length - 1;
363
+ }
364
+ }
365
+ } else if (query[i] === "}") {
366
+ depth--;
367
+ i++;
368
+ } else if (depth === 1) {
369
+ const rest = query.slice(i);
370
+ const match = rest.match(/^[\s,]*(\w+)\s*([\(\{])/);
371
+ if (match) {
372
+ fields.push(match[1]);
373
+ i += match[0].length - 1;
374
+ } else {
375
+ i++;
376
+ }
377
+ } else {
378
+ i++;
379
+ }
380
+ }
381
+ return fields;
382
+ }
277
383
  const middleware = async (ctx, next) => {
384
+ const { url, method } = ctx.request;
385
+ if (!url.startsWith("/graphql")) {
386
+ await next();
387
+ return;
388
+ }
278
389
  const cacheService = strapi.plugin("strapi-cache").services.service;
279
390
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
280
391
  const cacheStore = cacheService.getCacheInstance();
281
- const { url } = ctx.request;
282
- const originalReq = ctx.req;
283
- const bodyBuffer = await rawBody__default.default(originalReq);
284
- const body = bodyBuffer.toString();
285
- const clonedReq = new Stream.Readable();
286
- clonedReq.push(bodyBuffer);
287
- clonedReq.push(null);
288
- clonedReq.headers = { ...originalReq.headers };
289
- clonedReq.method = originalReq.method;
290
- clonedReq.url = originalReq.url;
291
- clonedReq.httpVersion = originalReq.httpVersion;
292
- clonedReq.socket = originalReq.socket;
293
- clonedReq.connection = originalReq.connection;
294
- ctx.req = clonedReq;
295
- ctx.request.req = clonedReq;
392
+ const isGet = method === "GET";
393
+ let body;
394
+ if (isGet) {
395
+ const { query, variables, operationName } = ctx.request.query;
396
+ body = JSON.stringify({
397
+ query: query ?? "",
398
+ variables: variables ?? "",
399
+ operationName: operationName ?? ""
400
+ });
401
+ } else {
402
+ const originalReq = ctx.req;
403
+ const bodyBuffer = await rawBody__default.default(originalReq);
404
+ body = bodyBuffer.toString();
405
+ const clonedReq = new Stream.Readable();
406
+ clonedReq.push(bodyBuffer);
407
+ clonedReq.push(null);
408
+ clonedReq.headers = { ...originalReq.headers };
409
+ clonedReq.method = originalReq.method;
410
+ clonedReq.url = originalReq.url;
411
+ clonedReq.httpVersion = originalReq.httpVersion;
412
+ clonedReq.socket = originalReq.socket;
413
+ clonedReq.connection = originalReq.connection;
414
+ ctx.req = clonedReq;
415
+ ctx.request.req = clonedReq;
416
+ }
417
+ const payload = parseGraphqlPayload(body, isGet);
418
+ const rootFields = getRootFieldsFromQuery(payload.query);
419
+ const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields);
420
+ loggy.info(
421
+ `GraphQL request: ${JSON.stringify({
422
+ operationName: payload.operationName,
423
+ variables: payload.variables,
424
+ rootFields: rootFields.length ? rootFields : void 0
425
+ })}`
426
+ );
296
427
  const isIntrospectionQuery = body.includes("IntrospectionQuery");
297
428
  if (isIntrospectionQuery) {
298
429
  loggy.info("Skipping cache for introspection query");
299
430
  await next();
300
431
  return;
301
432
  }
302
- const key = generateGraphqlCacheKey(body);
303
433
  const cacheEntry = await cacheStore.get(key);
304
434
  const cacheControlHeader = ctx.request.headers["cache-control"];
305
435
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
@@ -340,7 +470,8 @@ const middleware = async (ctx, next) => {
340
470
  return;
341
471
  }
342
472
  await next();
343
- if (ctx.method === "POST" && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql")) {
473
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
474
+ if (shouldCache) {
344
475
  loggy.info(`MISS with key: ${key}`);
345
476
  const headers = ctx.request.headers;
346
477
  const authorizationHeader2 = headers["authorization"];
@@ -406,7 +537,8 @@ const config = {
406
537
  cacheGetTimeoutInMs: 1e3,
407
538
  autoPurgeCache: true,
408
539
  autoPurgeCacheOnStart: true,
409
- disableAdminPopups: false
540
+ disableAdminPopups: false,
541
+ disableAdminButtons: false
410
542
  }),
411
543
  validator: (config2) => {
412
544
  if (typeof config2.debug !== "boolean") {
@@ -427,22 +559,27 @@ const config = {
427
559
  if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
428
560
  throw new Error(`Invalid config: cacheableRoutes must be an string array`);
429
561
  }
562
+ if (config2.cacheableEntities !== void 0 && (!Array.isArray(config2.cacheableEntities) || config2.cacheableEntities.some((item) => typeof item !== "string"))) {
563
+ throw new Error(`Invalid config: cacheableEntities must be a string array`);
564
+ }
430
565
  if (!Array.isArray(config2.excludeRoutes) || config2.excludeRoutes.some((item) => typeof item !== "string")) {
431
566
  throw new Error(`Invalid config: excludeRoutes must be a string array`);
432
567
  }
433
568
  if (typeof config2.provider !== "string") {
434
569
  throw new Error(`Invalid config: provider must be a string`);
435
570
  }
436
- if (config2.provider !== "memory" && config2.provider !== "redis") {
437
- throw new Error(`Invalid config: provider must be 'memory' or 'redis'`);
571
+ if (config2.provider !== "memory" && config2.provider !== "redis" && config2.provider !== "valkey") {
572
+ throw new Error(`Invalid config: provider must be 'memory', 'redis' or 'valkey'`);
438
573
  }
439
- if (config2.provider === "redis") {
574
+ if (config2.provider === "redis" || config2.provider === "valkey") {
440
575
  if (!config2.redisConfig) {
441
- throw new Error(`Invalid config: redisConfig must be set when using redis provider`);
576
+ throw new Error(
577
+ `Invalid config: redisConfig must be set when using redis or valkey provider`
578
+ );
442
579
  }
443
580
  if (typeof config2.redisConfig !== "string" && typeof config2.redisConfig !== "object") {
444
581
  throw new Error(
445
- `Invalid config: redisConfig must be a string or object when using redis provider`
582
+ `Invalid config: redisConfig must be a string or object when using redis or valkey provider`
446
583
  );
447
584
  }
448
585
  if (!Array.isArray(config2.redisClusterNodes) || config2.redisClusterNodes.some((item) => !("host" in item && "port" in item))) {
@@ -478,6 +615,9 @@ const config = {
478
615
  if (typeof config2.disableAdminPopups !== "boolean") {
479
616
  throw new Error(`Invalid config: disableAdminPopups must be a boolean`);
480
617
  }
618
+ if (typeof config2.disableAdminButtons !== "boolean") {
619
+ throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
620
+ }
481
621
  }
482
622
  };
483
623
  const contentTypes = {};
@@ -508,8 +648,10 @@ const controller = ({ strapi: strapi2 }) => ({
508
648
  async config(ctx) {
509
649
  try {
510
650
  const config2 = {
651
+ cacheableEntities: strapi2.plugin("strapi-cache").config("cacheableEntities") ?? [],
511
652
  cacheableRoutes: strapi2.plugin("strapi-cache").config("cacheableRoutes") ?? [],
512
- disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false
653
+ disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false,
654
+ disableAdminButtons: strapi2.plugin("strapi-cache").config("disableAdminButtons") ?? false
513
655
  };
514
656
  ctx.body = config2;
515
657
  } catch (error) {
@@ -701,23 +843,41 @@ class RedisCacheProvider {
701
843
  return;
702
844
  }
703
845
  try {
846
+ const provider = this.strapi.plugin("strapi-cache").config("provider") || "redis";
704
847
  const redisConfig = this.strapi.plugin("strapi-cache").config("redisConfig") || "redis://localhost:6379";
705
848
  const redisClusterNodes = this.strapi.plugin("strapi-cache").config("redisClusterNodes");
706
849
  this.cacheGetTimeoutInMs = Number(
707
850
  this.strapi.plugin("strapi-cache").config("cacheGetTimeoutInMs")
708
851
  );
709
852
  this.keyPrefix = this.strapi.plugin("strapi-cache").config("redisConfig")?.["keyPrefix"] ?? "";
710
- if (redisClusterNodes.length) {
711
- const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
712
- if (!redisClusterOptions["redisOptions"]) {
713
- redisClusterOptions.redisOptions = redisConfig;
853
+ if (provider === "valkey") {
854
+ if (redisClusterNodes.length) {
855
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions") ?? {};
856
+ const clusterOptions = { ...redisClusterOptions };
857
+ if (!clusterOptions["redisOptions"]) {
858
+ clusterOptions["redisOptions"] = redisConfig;
859
+ }
860
+ this.client = new iovalkey.Cluster(
861
+ redisClusterNodes,
862
+ clusterOptions
863
+ );
864
+ } else {
865
+ this.client = new iovalkey.Redis(redisConfig);
714
866
  }
715
- this.client = new ioredis.Redis.Cluster(redisClusterNodes, redisClusterOptions);
867
+ loggy.info("Valkey provider initialized");
716
868
  } else {
717
- this.client = new ioredis.Redis(redisConfig);
869
+ if (redisClusterNodes.length) {
870
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
871
+ if (!redisClusterOptions["redisOptions"]) {
872
+ redisClusterOptions.redisOptions = redisConfig;
873
+ }
874
+ this.client = new ioredis.Redis.Cluster(redisClusterNodes, redisClusterOptions);
875
+ } else {
876
+ this.client = new ioredis.Redis(redisConfig);
877
+ }
878
+ loggy.info("Redis provider initialized");
718
879
  }
719
880
  this.initialized = true;
720
- loggy.info("Redis provider initialized");
721
881
  } catch (error) {
722
882
  loggy.error(error);
723
883
  }
@@ -765,6 +925,18 @@ class RedisCacheProvider {
765
925
  return null;
766
926
  }
767
927
  }
928
+ /**
929
+ * Deletes all given keys in Redis pipeline.
930
+ * @param keys to delete from cache
931
+ */
932
+ async delAll(keys) {
933
+ const pipeline = this.client.pipeline();
934
+ keys.forEach((key) => {
935
+ const relativeKey = key.slice(this.keyPrefix.length);
936
+ pipeline.del(relativeKey);
937
+ });
938
+ await pipeline.exec();
939
+ }
768
940
  async keys() {
769
941
  if (!this.ready) return null;
770
942
  try {
@@ -800,7 +972,7 @@ class RedisCacheProvider {
800
972
  return;
801
973
  }
802
974
  const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
803
- await Promise.all(toDelete.map((key) => this.del(key)));
975
+ await this.delAll(toDelete);
804
976
  }
805
977
  }
806
978
  const resolveCacheProvider = (strapi2) => {
@@ -809,6 +981,7 @@ const resolveCacheProvider = (strapi2) => {
809
981
  let instance;
810
982
  switch (providerType) {
811
983
  case "redis":
984
+ case "valkey":
812
985
  instance = new RedisCacheProvider(strapi2);
813
986
  break;
814
987
  default:
@@ -3,7 +3,8 @@ 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 } from "ioredis";
6
+ import { Redis as Redis$1 } from "ioredis";
7
+ import { Cluster, Redis } from "iovalkey";
7
8
  const loggy = {
8
9
  info: (msg) => {
9
10
  const shouldDebug = strapi.plugin("strapi-cache").config("debug") ?? false;
@@ -31,6 +32,12 @@ async function invalidateCache(event, cacheStore, strapi2) {
31
32
  const { model } = event;
32
33
  const uid = model.uid;
33
34
  const restApiPrefix = strapi2.config.get("api.rest.prefix", "/api");
35
+ const cacheableEntities = strapi2.plugin("strapi-cache").config("cacheableEntities");
36
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(model.tableName) : true;
37
+ if (!entityIsCacheable) {
38
+ loggy.info(`Not invalidated. ${model.tableName} is not cacheable.`);
39
+ return;
40
+ }
34
41
  try {
35
42
  const contentType = strapi2.contentType(uid);
36
43
  if (!contentType || !contentType.kind) {
@@ -47,13 +54,31 @@ async function invalidateCache(event, cacheStore, strapi2) {
47
54
  loggy.error(error);
48
55
  }
49
56
  }
50
- async function invalidateGraphqlCache(cacheStore) {
57
+ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
51
58
  try {
52
- const graphqlRegex = new RegExp(`^POST:/graphql(:.*)?$`);
59
+ const { model } = event;
60
+ const contentType = strapi2.contentType(model.uid);
61
+ if (!contentType || !contentType.info) {
62
+ loggy.info(`Content type ${model.uid} not found, purging all GraphQL cache`);
63
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
64
+ await cacheStore.clearByRegexp([graphqlRegex2]);
65
+ return;
66
+ }
67
+ const singularName = contentType.info.singularName ?? "";
68
+ const pluralName = contentType.info.pluralName ?? "";
69
+ const fieldNames = [...new Set([singularName, pluralName].filter(Boolean))];
70
+ if (fieldNames.length === 0) {
71
+ loggy.info(`No field names for ${model.uid}, purging all GraphQL cache`);
72
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
73
+ await cacheStore.clearByRegexp([graphqlRegex2]);
74
+ return;
75
+ }
76
+ const escapedNames = fieldNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
77
+ const graphqlRegex = new RegExp(`^(GET|POST):/graphql:[^:]*\\b(${escapedNames})\\b[^:]*:`);
53
78
  await cacheStore.clearByRegexp([graphqlRegex]);
54
- loggy.info(`Invalidated cache for ${graphqlRegex}`);
79
+ loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
55
80
  } catch (error) {
56
- loggy.error("Cache invalidation error:");
81
+ loggy.error("GraphQL cache invalidation error:");
57
82
  loggy.error(error);
58
83
  }
59
84
  }
@@ -69,8 +94,9 @@ const bootstrap = ({ strapi: strapi2 }) => {
69
94
  loggy.info("Initializing");
70
95
  try {
71
96
  const cacheService = strapi2.plugin("strapi-cache").services.service;
72
- const autoPurgeCache = strapi2.plugin("strapi-cache").config("autoPurgeCache");
73
- const autoPurgeCacheOnStart = strapi2.plugin("strapi-cache").config("autoPurgeCacheOnStart");
97
+ const autoPurgeCache = !!strapi2.plugin("strapi-cache").config("autoPurgeCache");
98
+ const autoPurgeGraphQL = !!strapi2.plugin("strapi-cache").config("autoPurgeGraphQL");
99
+ const autoPurgeCacheOnStart = !!strapi2.plugin("strapi-cache").config("autoPurgeCacheOnStart");
74
100
  const cacheStore = cacheService.getCacheInstance();
75
101
  if (!cacheStore) {
76
102
  loggy.error("Plugin could not be initialized");
@@ -81,15 +107,25 @@ const bootstrap = ({ strapi: strapi2 }) => {
81
107
  strapi2.db.lifecycles.subscribe({
82
108
  async afterCreate(event) {
83
109
  await invalidateCache(event, cacheStore, strapi2);
84
- await invalidateGraphqlCache(cacheStore);
85
110
  },
86
111
  async afterUpdate(event) {
87
112
  await invalidateCache(event, cacheStore, strapi2);
88
- await invalidateGraphqlCache(cacheStore);
89
113
  },
90
114
  async afterDelete(event) {
91
115
  await invalidateCache(event, cacheStore, strapi2);
92
- await invalidateGraphqlCache(cacheStore);
116
+ }
117
+ });
118
+ }
119
+ if (autoPurgeGraphQL) {
120
+ strapi2.db.lifecycles.subscribe({
121
+ async afterCreate(event) {
122
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
123
+ },
124
+ async afterUpdate(event) {
125
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
126
+ },
127
+ async afterDelete(event) {
128
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
93
129
  }
94
130
  });
95
131
  }
@@ -112,11 +148,17 @@ const generateCacheKey = (context) => {
112
148
  const { method } = context.request;
113
149
  return `${method}:${url}`;
114
150
  };
115
- const generateGraphqlCacheKey = (payload) => {
151
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
116
152
  const hash = createHash("sha256").update(payload).digest("base64url");
117
- return `POST:/graphql:${hash}`;
153
+ const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
154
+ return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
118
155
  };
119
156
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
157
+ const generateEntityKey = (url, restApiPrefix) => {
158
+ const regex = new RegExp(`${restApiPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([^/?]*)`);
159
+ const match = url.match(regex);
160
+ return match ? match[1] : "";
161
+ };
120
162
  const streamToBuffer = (stream) => {
121
163
  return new Promise((resolve, reject) => {
122
164
  const chunks = [];
@@ -184,6 +226,7 @@ function getCacheHeaderConfig() {
184
226
  }
185
227
  const middleware$1 = async (ctx, next) => {
186
228
  const cacheService = strapi.plugin("strapi-cache").services.service;
229
+ const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
187
230
  const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
188
231
  const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
189
232
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
@@ -194,13 +237,16 @@ const middleware$1 = async (ctx, next) => {
194
237
  const cacheControlHeader = ctx.request.headers["cache-control"];
195
238
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
196
239
  const restApiPrefix = strapi.config.get("api.rest.prefix", "/api");
240
+ const entityKey = generateEntityKey(url, restApiPrefix);
197
241
  const routeIsExcluded = excludeRoutes.some((route) => url.startsWith(route));
198
242
  if (routeIsExcluded) {
199
243
  loggy.info(`Route excluded from cache: ${url}`);
200
244
  await next();
201
245
  return;
202
246
  }
203
- const routeIsCachable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
247
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(entityKey) : void 0;
248
+ const routeIsCacheable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
249
+ const isCacheable = entityIsCacheable ?? routeIsCacheable;
204
250
  const authorizationHeader = ctx.request.headers["authorization"];
205
251
  if (authorizationHeader && !cacheAuthorizedRequests) {
206
252
  loggy.info(`Authorized request bypassing cache: ${key}`);
@@ -238,7 +284,7 @@ const middleware$1 = async (ctx, next) => {
238
284
  return;
239
285
  }
240
286
  await next();
241
- if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && routeIsCachable) {
287
+ if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && isCacheable) {
242
288
  loggy.info(`MISS with key: ${key}`);
243
289
  const headersToStore = getHeadersToStore(
244
290
  ctx,
@@ -270,32 +316,116 @@ const middleware$1 = async (ctx, next) => {
270
316
  }
271
317
  }
272
318
  };
319
+ function parseGraphqlPayload(body, isGet) {
320
+ if (isGet) {
321
+ try {
322
+ const parsed = JSON.parse(body);
323
+ const variables = parsed.variables;
324
+ const variablesParsed = typeof variables === "string" ? variables ? JSON.parse(variables) : null : variables ?? null;
325
+ return {
326
+ query: parsed.query ?? "",
327
+ variables: variablesParsed,
328
+ operationName: parsed.operationName ?? null
329
+ };
330
+ } catch {
331
+ return { query: body, variables: null, operationName: null };
332
+ }
333
+ }
334
+ try {
335
+ const parsed = JSON.parse(body);
336
+ return {
337
+ query: parsed.query ?? "",
338
+ variables: parsed.variables ?? null,
339
+ operationName: parsed.operationName ?? null
340
+ };
341
+ } catch {
342
+ return { query: body, variables: null, operationName: null };
343
+ }
344
+ }
345
+ function getRootFieldsFromQuery(query) {
346
+ const fields = [];
347
+ let depth = 0;
348
+ let i = 0;
349
+ while (i < query.length) {
350
+ if (query[i] === "{") {
351
+ depth++;
352
+ i++;
353
+ if (depth === 1) {
354
+ const rest = query.slice(i);
355
+ const match = rest.match(/^\s*(\w+)\s*([\(\{])/);
356
+ if (match) {
357
+ fields.push(match[1]);
358
+ i += match[0].length - 1;
359
+ }
360
+ }
361
+ } else if (query[i] === "}") {
362
+ depth--;
363
+ i++;
364
+ } else if (depth === 1) {
365
+ const rest = query.slice(i);
366
+ const match = rest.match(/^[\s,]*(\w+)\s*([\(\{])/);
367
+ if (match) {
368
+ fields.push(match[1]);
369
+ i += match[0].length - 1;
370
+ } else {
371
+ i++;
372
+ }
373
+ } else {
374
+ i++;
375
+ }
376
+ }
377
+ return fields;
378
+ }
273
379
  const middleware = async (ctx, next) => {
380
+ const { url, method } = ctx.request;
381
+ if (!url.startsWith("/graphql")) {
382
+ await next();
383
+ return;
384
+ }
274
385
  const cacheService = strapi.plugin("strapi-cache").services.service;
275
386
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
276
387
  const cacheStore = cacheService.getCacheInstance();
277
- const { url } = ctx.request;
278
- const originalReq = ctx.req;
279
- const bodyBuffer = await rawBody(originalReq);
280
- const body = bodyBuffer.toString();
281
- const clonedReq = new Readable();
282
- clonedReq.push(bodyBuffer);
283
- clonedReq.push(null);
284
- clonedReq.headers = { ...originalReq.headers };
285
- clonedReq.method = originalReq.method;
286
- clonedReq.url = originalReq.url;
287
- clonedReq.httpVersion = originalReq.httpVersion;
288
- clonedReq.socket = originalReq.socket;
289
- clonedReq.connection = originalReq.connection;
290
- ctx.req = clonedReq;
291
- ctx.request.req = clonedReq;
388
+ const isGet = method === "GET";
389
+ let body;
390
+ if (isGet) {
391
+ const { query, variables, operationName } = ctx.request.query;
392
+ body = JSON.stringify({
393
+ query: query ?? "",
394
+ variables: variables ?? "",
395
+ operationName: operationName ?? ""
396
+ });
397
+ } else {
398
+ const originalReq = ctx.req;
399
+ const bodyBuffer = await rawBody(originalReq);
400
+ body = bodyBuffer.toString();
401
+ const clonedReq = new Readable();
402
+ clonedReq.push(bodyBuffer);
403
+ clonedReq.push(null);
404
+ clonedReq.headers = { ...originalReq.headers };
405
+ clonedReq.method = originalReq.method;
406
+ clonedReq.url = originalReq.url;
407
+ clonedReq.httpVersion = originalReq.httpVersion;
408
+ clonedReq.socket = originalReq.socket;
409
+ clonedReq.connection = originalReq.connection;
410
+ ctx.req = clonedReq;
411
+ ctx.request.req = clonedReq;
412
+ }
413
+ const payload = parseGraphqlPayload(body, isGet);
414
+ const rootFields = getRootFieldsFromQuery(payload.query);
415
+ const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields);
416
+ loggy.info(
417
+ `GraphQL request: ${JSON.stringify({
418
+ operationName: payload.operationName,
419
+ variables: payload.variables,
420
+ rootFields: rootFields.length ? rootFields : void 0
421
+ })}`
422
+ );
292
423
  const isIntrospectionQuery = body.includes("IntrospectionQuery");
293
424
  if (isIntrospectionQuery) {
294
425
  loggy.info("Skipping cache for introspection query");
295
426
  await next();
296
427
  return;
297
428
  }
298
- const key = generateGraphqlCacheKey(body);
299
429
  const cacheEntry = await cacheStore.get(key);
300
430
  const cacheControlHeader = ctx.request.headers["cache-control"];
301
431
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
@@ -336,7 +466,8 @@ const middleware = async (ctx, next) => {
336
466
  return;
337
467
  }
338
468
  await next();
339
- if (ctx.method === "POST" && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql")) {
469
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
470
+ if (shouldCache) {
340
471
  loggy.info(`MISS with key: ${key}`);
341
472
  const headers = ctx.request.headers;
342
473
  const authorizationHeader2 = headers["authorization"];
@@ -402,7 +533,8 @@ const config = {
402
533
  cacheGetTimeoutInMs: 1e3,
403
534
  autoPurgeCache: true,
404
535
  autoPurgeCacheOnStart: true,
405
- disableAdminPopups: false
536
+ disableAdminPopups: false,
537
+ disableAdminButtons: false
406
538
  }),
407
539
  validator: (config2) => {
408
540
  if (typeof config2.debug !== "boolean") {
@@ -423,22 +555,27 @@ const config = {
423
555
  if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
424
556
  throw new Error(`Invalid config: cacheableRoutes must be an string array`);
425
557
  }
558
+ if (config2.cacheableEntities !== void 0 && (!Array.isArray(config2.cacheableEntities) || config2.cacheableEntities.some((item) => typeof item !== "string"))) {
559
+ throw new Error(`Invalid config: cacheableEntities must be a string array`);
560
+ }
426
561
  if (!Array.isArray(config2.excludeRoutes) || config2.excludeRoutes.some((item) => typeof item !== "string")) {
427
562
  throw new Error(`Invalid config: excludeRoutes must be a string array`);
428
563
  }
429
564
  if (typeof config2.provider !== "string") {
430
565
  throw new Error(`Invalid config: provider must be a string`);
431
566
  }
432
- if (config2.provider !== "memory" && config2.provider !== "redis") {
433
- throw new Error(`Invalid config: provider must be 'memory' or 'redis'`);
567
+ if (config2.provider !== "memory" && config2.provider !== "redis" && config2.provider !== "valkey") {
568
+ throw new Error(`Invalid config: provider must be 'memory', 'redis' or 'valkey'`);
434
569
  }
435
- if (config2.provider === "redis") {
570
+ if (config2.provider === "redis" || config2.provider === "valkey") {
436
571
  if (!config2.redisConfig) {
437
- throw new Error(`Invalid config: redisConfig must be set when using redis provider`);
572
+ throw new Error(
573
+ `Invalid config: redisConfig must be set when using redis or valkey provider`
574
+ );
438
575
  }
439
576
  if (typeof config2.redisConfig !== "string" && typeof config2.redisConfig !== "object") {
440
577
  throw new Error(
441
- `Invalid config: redisConfig must be a string or object when using redis provider`
578
+ `Invalid config: redisConfig must be a string or object when using redis or valkey provider`
442
579
  );
443
580
  }
444
581
  if (!Array.isArray(config2.redisClusterNodes) || config2.redisClusterNodes.some((item) => !("host" in item && "port" in item))) {
@@ -474,6 +611,9 @@ const config = {
474
611
  if (typeof config2.disableAdminPopups !== "boolean") {
475
612
  throw new Error(`Invalid config: disableAdminPopups must be a boolean`);
476
613
  }
614
+ if (typeof config2.disableAdminButtons !== "boolean") {
615
+ throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
616
+ }
477
617
  }
478
618
  };
479
619
  const contentTypes = {};
@@ -504,8 +644,10 @@ const controller = ({ strapi: strapi2 }) => ({
504
644
  async config(ctx) {
505
645
  try {
506
646
  const config2 = {
647
+ cacheableEntities: strapi2.plugin("strapi-cache").config("cacheableEntities") ?? [],
507
648
  cacheableRoutes: strapi2.plugin("strapi-cache").config("cacheableRoutes") ?? [],
508
- disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false
649
+ disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false,
650
+ disableAdminButtons: strapi2.plugin("strapi-cache").config("disableAdminButtons") ?? false
509
651
  };
510
652
  ctx.body = config2;
511
653
  } catch (error) {
@@ -697,23 +839,41 @@ class RedisCacheProvider {
697
839
  return;
698
840
  }
699
841
  try {
842
+ const provider = this.strapi.plugin("strapi-cache").config("provider") || "redis";
700
843
  const redisConfig = this.strapi.plugin("strapi-cache").config("redisConfig") || "redis://localhost:6379";
701
844
  const redisClusterNodes = this.strapi.plugin("strapi-cache").config("redisClusterNodes");
702
845
  this.cacheGetTimeoutInMs = Number(
703
846
  this.strapi.plugin("strapi-cache").config("cacheGetTimeoutInMs")
704
847
  );
705
848
  this.keyPrefix = this.strapi.plugin("strapi-cache").config("redisConfig")?.["keyPrefix"] ?? "";
706
- if (redisClusterNodes.length) {
707
- const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
708
- if (!redisClusterOptions["redisOptions"]) {
709
- redisClusterOptions.redisOptions = redisConfig;
849
+ if (provider === "valkey") {
850
+ if (redisClusterNodes.length) {
851
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions") ?? {};
852
+ const clusterOptions = { ...redisClusterOptions };
853
+ if (!clusterOptions["redisOptions"]) {
854
+ clusterOptions["redisOptions"] = redisConfig;
855
+ }
856
+ this.client = new Cluster(
857
+ redisClusterNodes,
858
+ clusterOptions
859
+ );
860
+ } else {
861
+ this.client = new Redis(redisConfig);
710
862
  }
711
- this.client = new Redis.Cluster(redisClusterNodes, redisClusterOptions);
863
+ loggy.info("Valkey provider initialized");
712
864
  } else {
713
- this.client = new Redis(redisConfig);
865
+ if (redisClusterNodes.length) {
866
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
867
+ if (!redisClusterOptions["redisOptions"]) {
868
+ redisClusterOptions.redisOptions = redisConfig;
869
+ }
870
+ this.client = new Redis$1.Cluster(redisClusterNodes, redisClusterOptions);
871
+ } else {
872
+ this.client = new Redis$1(redisConfig);
873
+ }
874
+ loggy.info("Redis provider initialized");
714
875
  }
715
876
  this.initialized = true;
716
- loggy.info("Redis provider initialized");
717
877
  } catch (error) {
718
878
  loggy.error(error);
719
879
  }
@@ -761,6 +921,18 @@ class RedisCacheProvider {
761
921
  return null;
762
922
  }
763
923
  }
924
+ /**
925
+ * Deletes all given keys in Redis pipeline.
926
+ * @param keys to delete from cache
927
+ */
928
+ async delAll(keys) {
929
+ const pipeline = this.client.pipeline();
930
+ keys.forEach((key) => {
931
+ const relativeKey = key.slice(this.keyPrefix.length);
932
+ pipeline.del(relativeKey);
933
+ });
934
+ await pipeline.exec();
935
+ }
764
936
  async keys() {
765
937
  if (!this.ready) return null;
766
938
  try {
@@ -796,7 +968,7 @@ class RedisCacheProvider {
796
968
  return;
797
969
  }
798
970
  const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
799
- await Promise.all(toDelete.map((key) => this.del(key)));
971
+ await this.delAll(toDelete);
800
972
  }
801
973
  }
802
974
  const resolveCacheProvider = (strapi2) => {
@@ -805,6 +977,7 @@ const resolveCacheProvider = (strapi2) => {
805
977
  let instance;
806
978
  switch (providerType) {
807
979
  case "redis":
980
+ case "valkey":
808
981
  instance = new RedisCacheProvider(strapi2);
809
982
  break;
810
983
  default:
@@ -21,6 +21,7 @@ declare const _default: {
21
21
  autoPurgeCache: boolean;
22
22
  autoPurgeCacheOnStart: boolean;
23
23
  disableAdminPopups: boolean;
24
+ disableAdminButtons: boolean;
24
25
  };
25
26
  validator: (config: any) => void;
26
27
  };
@@ -30,6 +30,7 @@ declare const _default: {
30
30
  autoPurgeCache: boolean;
31
31
  autoPurgeCacheOnStart: boolean;
32
32
  disableAdminPopups: boolean;
33
+ disableAdminButtons: boolean;
33
34
  };
34
35
  validator: (config: any) => void;
35
36
  };
@@ -12,6 +12,11 @@ export declare class RedisCacheProvider implements CacheProvider {
12
12
  get(key: string): Promise<any | null>;
13
13
  set(key: string, val: any): Promise<any | null>;
14
14
  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>;
15
20
  keys(): Promise<string[] | null>;
16
21
  reset(): Promise<any | null>;
17
22
  clearByRegexp(regExps: RegExp[]): Promise<void>;
@@ -0,0 +1,6 @@
1
+ export declare function parseGraphqlPayload(body: string, isGet: boolean): {
2
+ query: string;
3
+ variables: Record<string, unknown> | string | null;
4
+ operationName: string | null;
5
+ };
6
+ export declare function getRootFieldsFromQuery(query: string): string[];
@@ -1,4 +1,8 @@
1
1
  import { Core } from '@strapi/strapi';
2
2
  import { CacheProvider } from 'src/types/cache.types';
3
3
  export declare function invalidateCache(event: any, cacheStore: CacheProvider, strapi: Core.Strapi): Promise<void>;
4
- export declare function invalidateGraphqlCache(cacheStore: CacheProvider): Promise<void>;
4
+ export declare function invalidateGraphqlCache(event: {
5
+ model: {
6
+ uid: string;
7
+ };
8
+ }, cacheStore: CacheProvider, strapi: Core.Strapi): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { Context } from 'koa';
2
2
  export declare const generateCacheKey: (context: Context) => string;
3
- export declare const generateGraphqlCacheKey: (payload: string) => string;
3
+ export declare const generateGraphqlCacheKey: (payload: string, method?: 'GET' | 'POST', rootFields?: string[]) => string;
4
4
  export declare const escapeRegExp: (s: string) => string;
5
+ export declare const generateEntityKey: (url: string, restApiPrefix: string) => string;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.7.0",
2
+ "version": "1.8.2-rc.1",
3
3
  "keywords": [
4
4
  "strapi cache",
5
5
  "strapi rest cache",
@@ -34,12 +34,15 @@
34
34
  "verify": "strapi-plugin verify",
35
35
  "test:ts:front": "run -T tsc -p admin/tsconfig.json",
36
36
  "test:ts:back": "run -T tsc -p server/tsconfig.json",
37
- "test": "vitest"
37
+ "test": "vitest run --exclude 'test/integration/**'",
38
+ "test:integration": "npm run build && rm -rf playground/.cache && mkdir -p playground/.yalc/strapi-cache && cp -r dist package.json LICENSE README.md playground/.yalc/strapi-cache/ && cd playground && npm install && npm run build && cd .. && NODE_ENV=test jest --config jest.integration.config.js --runInBand --forceExit --detectOpenHandles",
39
+ "test:all": "npm test -- --run && npm run test:integration"
38
40
  },
39
41
  "dependencies": {
40
42
  "@strapi/design-system": "^2.0.1",
41
43
  "@strapi/icons": "^2.0.1",
42
44
  "ioredis": "^5.6.1",
45
+ "iovalkey": "^0.3.3",
43
46
  "lru-cache": "^11.1.0",
44
47
  "raw-body": "^3.0.0",
45
48
  "react-intl": "^7.1.10"
@@ -48,14 +51,21 @@
48
51
  "@strapi/sdk-plugin": "^5.3.2",
49
52
  "@strapi/strapi": "^5.0.0",
50
53
  "@strapi/typescript-utils": "^5.0.0",
54
+ "@types/jest": "^30.0.0",
51
55
  "@types/koa": "^2.15.0",
52
56
  "@types/react": "^19.1.0",
53
57
  "@types/react-dom": "^19.1.1",
58
+ "@types/supertest": "^6.0.3",
59
+ "better-sqlite3": "^12.6.2",
60
+ "jest": "^30.2.0",
54
61
  "prettier": "^3.5.3",
55
62
  "react": "^18.3.1",
56
63
  "react-dom": "^18.3.1",
57
64
  "react-router-dom": "^6.30.0",
58
65
  "styled-components": "^6.1.17",
66
+ "supertest": "^7.2.2",
67
+ "ts-jest": "^29.4.6",
68
+ "ts-node": "^10.9.2",
59
69
  "typescript": "^5.8.3",
60
70
  "vitest": "^3.1.1"
61
71
  },
@@ -67,6 +77,11 @@
67
77
  "react-router-dom": "^6.30.0",
68
78
  "styled-components": "^6.1.17"
69
79
  },
80
+ "optionalDependencies": {
81
+ "@img/sharp-linux-x64": "*",
82
+ "@rollup/rollup-linux-x64-gnu": "*",
83
+ "@swc/core-linux-x64-gnu": "*"
84
+ },
70
85
  "strapi": {
71
86
  "kind": "plugin",
72
87
  "name": "strapi-cache",