strapi-cache 1.7.0 → 1.8.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
@@ -50,6 +50,7 @@ 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
55
  provider: 'memory', // Cache provider ('memory' or 'redis')
55
56
  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)
@@ -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
  ```
@@ -84,7 +87,7 @@ All of these routes are protected by the policies `admin::isAuthenticatedAdmin`
84
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 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;
@@ -35,6 +35,12 @@ async function invalidateCache(event, cacheStore, strapi2) {
35
35
  const { model } = event;
36
36
  const uid = model.uid;
37
37
  const restApiPrefix = strapi2.config.get("api.rest.prefix", "/api");
38
+ const cacheableEntities = strapi2.plugin("strapi-cache").config("cacheableEntities");
39
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(model.tableName) : true;
40
+ if (!entityIsCacheable) {
41
+ loggy.info(`Not invalidated. ${model.tableName} is not cacheable.`);
42
+ return;
43
+ }
38
44
  try {
39
45
  const contentType = strapi2.contentType(uid);
40
46
  if (!contentType || !contentType.kind) {
@@ -51,13 +57,31 @@ async function invalidateCache(event, cacheStore, strapi2) {
51
57
  loggy.error(error);
52
58
  }
53
59
  }
54
- async function invalidateGraphqlCache(cacheStore) {
60
+ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
55
61
  try {
56
- const graphqlRegex = new RegExp(`^POST:/graphql(:.*)?$`);
62
+ const { model } = event;
63
+ const contentType = strapi2.contentType(model.uid);
64
+ if (!contentType || !contentType.info) {
65
+ loggy.info(`Content type ${model.uid} not found, purging all GraphQL cache`);
66
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
67
+ await cacheStore.clearByRegexp([graphqlRegex2]);
68
+ return;
69
+ }
70
+ const singularName = contentType.info.singularName ?? "";
71
+ const pluralName = contentType.info.pluralName ?? "";
72
+ const fieldNames = [...new Set([singularName, pluralName].filter(Boolean))];
73
+ if (fieldNames.length === 0) {
74
+ loggy.info(`No field names for ${model.uid}, purging all GraphQL cache`);
75
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
76
+ await cacheStore.clearByRegexp([graphqlRegex2]);
77
+ return;
78
+ }
79
+ const escapedNames = fieldNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
80
+ const graphqlRegex = new RegExp(`^(GET|POST):/graphql:[^:]*\\b(${escapedNames})\\b[^:]*:`);
57
81
  await cacheStore.clearByRegexp([graphqlRegex]);
58
- loggy.info(`Invalidated cache for ${graphqlRegex}`);
82
+ loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
59
83
  } catch (error) {
60
- loggy.error("Cache invalidation error:");
84
+ loggy.error("GraphQL cache invalidation error:");
61
85
  loggy.error(error);
62
86
  }
63
87
  }
@@ -73,8 +97,9 @@ const bootstrap = ({ strapi: strapi2 }) => {
73
97
  loggy.info("Initializing");
74
98
  try {
75
99
  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");
100
+ const autoPurgeCache = !!strapi2.plugin("strapi-cache").config("autoPurgeCache");
101
+ const autoPurgeGraphQL = !!strapi2.plugin("strapi-cache").config("autoPurgeGraphQL");
102
+ const autoPurgeCacheOnStart = !!strapi2.plugin("strapi-cache").config("autoPurgeCacheOnStart");
78
103
  const cacheStore = cacheService.getCacheInstance();
79
104
  if (!cacheStore) {
80
105
  loggy.error("Plugin could not be initialized");
@@ -85,15 +110,25 @@ const bootstrap = ({ strapi: strapi2 }) => {
85
110
  strapi2.db.lifecycles.subscribe({
86
111
  async afterCreate(event) {
87
112
  await invalidateCache(event, cacheStore, strapi2);
88
- await invalidateGraphqlCache(cacheStore);
89
113
  },
90
114
  async afterUpdate(event) {
91
115
  await invalidateCache(event, cacheStore, strapi2);
92
- await invalidateGraphqlCache(cacheStore);
93
116
  },
94
117
  async afterDelete(event) {
95
118
  await invalidateCache(event, cacheStore, strapi2);
96
- await invalidateGraphqlCache(cacheStore);
119
+ }
120
+ });
121
+ }
122
+ if (autoPurgeGraphQL) {
123
+ strapi2.db.lifecycles.subscribe({
124
+ async afterCreate(event) {
125
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
126
+ },
127
+ async afterUpdate(event) {
128
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
129
+ },
130
+ async afterDelete(event) {
131
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
97
132
  }
98
133
  });
99
134
  }
@@ -116,11 +151,17 @@ const generateCacheKey = (context) => {
116
151
  const { method } = context.request;
117
152
  return `${method}:${url}`;
118
153
  };
119
- const generateGraphqlCacheKey = (payload) => {
154
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
120
155
  const hash = crypto.createHash("sha256").update(payload).digest("base64url");
121
- return `POST:/graphql:${hash}`;
156
+ const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
157
+ return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
122
158
  };
123
159
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
160
+ const generateEntityKey = (url, restApiPrefix) => {
161
+ const regex = new RegExp(`${restApiPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([^/?]*)`);
162
+ const match = url.match(regex);
163
+ return match ? match[1] : "";
164
+ };
124
165
  const streamToBuffer = (stream) => {
125
166
  return new Promise((resolve, reject) => {
126
167
  const chunks = [];
@@ -188,6 +229,7 @@ function getCacheHeaderConfig() {
188
229
  }
189
230
  const middleware$1 = async (ctx, next) => {
190
231
  const cacheService = strapi.plugin("strapi-cache").services.service;
232
+ const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
191
233
  const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
192
234
  const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
193
235
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
@@ -198,13 +240,16 @@ const middleware$1 = async (ctx, next) => {
198
240
  const cacheControlHeader = ctx.request.headers["cache-control"];
199
241
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
200
242
  const restApiPrefix = strapi.config.get("api.rest.prefix", "/api");
243
+ const entityKey = generateEntityKey(url, restApiPrefix);
201
244
  const routeIsExcluded = excludeRoutes.some((route) => url.startsWith(route));
202
245
  if (routeIsExcluded) {
203
246
  loggy.info(`Route excluded from cache: ${url}`);
204
247
  await next();
205
248
  return;
206
249
  }
207
- const routeIsCachable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
250
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(entityKey) : void 0;
251
+ const routeIsCacheable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
252
+ const isCacheable = entityIsCacheable ?? routeIsCacheable;
208
253
  const authorizationHeader = ctx.request.headers["authorization"];
209
254
  if (authorizationHeader && !cacheAuthorizedRequests) {
210
255
  loggy.info(`Authorized request bypassing cache: ${key}`);
@@ -242,7 +287,7 @@ const middleware$1 = async (ctx, next) => {
242
287
  return;
243
288
  }
244
289
  await next();
245
- if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && routeIsCachable) {
290
+ if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && isCacheable) {
246
291
  loggy.info(`MISS with key: ${key}`);
247
292
  const headersToStore = getHeadersToStore(
248
293
  ctx,
@@ -274,32 +319,116 @@ const middleware$1 = async (ctx, next) => {
274
319
  }
275
320
  }
276
321
  };
322
+ function parseGraphqlPayload(body, isGet) {
323
+ if (isGet) {
324
+ try {
325
+ const parsed = JSON.parse(body);
326
+ const variables = parsed.variables;
327
+ const variablesParsed = typeof variables === "string" ? variables ? JSON.parse(variables) : null : variables ?? null;
328
+ return {
329
+ query: parsed.query ?? "",
330
+ variables: variablesParsed,
331
+ operationName: parsed.operationName ?? null
332
+ };
333
+ } catch {
334
+ return { query: body, variables: null, operationName: null };
335
+ }
336
+ }
337
+ try {
338
+ const parsed = JSON.parse(body);
339
+ return {
340
+ query: parsed.query ?? "",
341
+ variables: parsed.variables ?? null,
342
+ operationName: parsed.operationName ?? null
343
+ };
344
+ } catch {
345
+ return { query: body, variables: null, operationName: null };
346
+ }
347
+ }
348
+ function getRootFieldsFromQuery(query) {
349
+ const fields = [];
350
+ let depth = 0;
351
+ let i = 0;
352
+ while (i < query.length) {
353
+ if (query[i] === "{") {
354
+ depth++;
355
+ i++;
356
+ if (depth === 1) {
357
+ const rest = query.slice(i);
358
+ const match = rest.match(/^\s*(\w+)\s*([\(\{])/);
359
+ if (match) {
360
+ fields.push(match[1]);
361
+ i += match[0].length - 1;
362
+ }
363
+ }
364
+ } else if (query[i] === "}") {
365
+ depth--;
366
+ i++;
367
+ } else if (depth === 1) {
368
+ const rest = query.slice(i);
369
+ const match = rest.match(/^[\s,]*(\w+)\s*([\(\{])/);
370
+ if (match) {
371
+ fields.push(match[1]);
372
+ i += match[0].length - 1;
373
+ } else {
374
+ i++;
375
+ }
376
+ } else {
377
+ i++;
378
+ }
379
+ }
380
+ return fields;
381
+ }
277
382
  const middleware = async (ctx, next) => {
383
+ const { url, method } = ctx.request;
384
+ if (!url.startsWith("/graphql")) {
385
+ await next();
386
+ return;
387
+ }
278
388
  const cacheService = strapi.plugin("strapi-cache").services.service;
279
389
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
280
390
  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;
391
+ const isGet = method === "GET";
392
+ let body;
393
+ if (isGet) {
394
+ const { query, variables, operationName } = ctx.request.query;
395
+ body = JSON.stringify({
396
+ query: query ?? "",
397
+ variables: variables ?? "",
398
+ operationName: operationName ?? ""
399
+ });
400
+ } else {
401
+ const originalReq = ctx.req;
402
+ const bodyBuffer = await rawBody__default.default(originalReq);
403
+ body = bodyBuffer.toString();
404
+ const clonedReq = new Stream.Readable();
405
+ clonedReq.push(bodyBuffer);
406
+ clonedReq.push(null);
407
+ clonedReq.headers = { ...originalReq.headers };
408
+ clonedReq.method = originalReq.method;
409
+ clonedReq.url = originalReq.url;
410
+ clonedReq.httpVersion = originalReq.httpVersion;
411
+ clonedReq.socket = originalReq.socket;
412
+ clonedReq.connection = originalReq.connection;
413
+ ctx.req = clonedReq;
414
+ ctx.request.req = clonedReq;
415
+ }
416
+ const payload = parseGraphqlPayload(body, isGet);
417
+ const rootFields = getRootFieldsFromQuery(payload.query);
418
+ const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields);
419
+ loggy.info(
420
+ `GraphQL request: ${JSON.stringify({
421
+ operationName: payload.operationName,
422
+ variables: payload.variables,
423
+ rootFields: rootFields.length ? rootFields : void 0
424
+ })}`
425
+ );
296
426
  const isIntrospectionQuery = body.includes("IntrospectionQuery");
297
427
  if (isIntrospectionQuery) {
298
428
  loggy.info("Skipping cache for introspection query");
299
429
  await next();
300
430
  return;
301
431
  }
302
- const key = generateGraphqlCacheKey(body);
303
432
  const cacheEntry = await cacheStore.get(key);
304
433
  const cacheControlHeader = ctx.request.headers["cache-control"];
305
434
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
@@ -340,7 +469,8 @@ const middleware = async (ctx, next) => {
340
469
  return;
341
470
  }
342
471
  await next();
343
- if (ctx.method === "POST" && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql")) {
472
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
473
+ if (shouldCache) {
344
474
  loggy.info(`MISS with key: ${key}`);
345
475
  const headers = ctx.request.headers;
346
476
  const authorizationHeader2 = headers["authorization"];
@@ -406,7 +536,8 @@ const config = {
406
536
  cacheGetTimeoutInMs: 1e3,
407
537
  autoPurgeCache: true,
408
538
  autoPurgeCacheOnStart: true,
409
- disableAdminPopups: false
539
+ disableAdminPopups: false,
540
+ disableAdminButtons: false
410
541
  }),
411
542
  validator: (config2) => {
412
543
  if (typeof config2.debug !== "boolean") {
@@ -427,6 +558,9 @@ const config = {
427
558
  if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
428
559
  throw new Error(`Invalid config: cacheableRoutes must be an string array`);
429
560
  }
561
+ if (config2.cacheableEntities !== void 0 && (!Array.isArray(config2.cacheableEntities) || config2.cacheableEntities.some((item) => typeof item !== "string"))) {
562
+ throw new Error(`Invalid config: cacheableEntities must be a string array`);
563
+ }
430
564
  if (!Array.isArray(config2.excludeRoutes) || config2.excludeRoutes.some((item) => typeof item !== "string")) {
431
565
  throw new Error(`Invalid config: excludeRoutes must be a string array`);
432
566
  }
@@ -478,6 +612,9 @@ const config = {
478
612
  if (typeof config2.disableAdminPopups !== "boolean") {
479
613
  throw new Error(`Invalid config: disableAdminPopups must be a boolean`);
480
614
  }
615
+ if (typeof config2.disableAdminButtons !== "boolean") {
616
+ throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
617
+ }
481
618
  }
482
619
  };
483
620
  const contentTypes = {};
@@ -508,8 +645,10 @@ const controller = ({ strapi: strapi2 }) => ({
508
645
  async config(ctx) {
509
646
  try {
510
647
  const config2 = {
648
+ cacheableEntities: strapi2.plugin("strapi-cache").config("cacheableEntities") ?? [],
511
649
  cacheableRoutes: strapi2.plugin("strapi-cache").config("cacheableRoutes") ?? [],
512
- disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false
650
+ disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false,
651
+ disableAdminButtons: strapi2.plugin("strapi-cache").config("disableAdminButtons") ?? false
513
652
  };
514
653
  ctx.body = config2;
515
654
  } catch (error) {
@@ -765,6 +904,18 @@ class RedisCacheProvider {
765
904
  return null;
766
905
  }
767
906
  }
907
+ /**
908
+ * Deletes all given keys in Redis pipeline.
909
+ * @param keys to delete from cache
910
+ */
911
+ async delAll(keys) {
912
+ const pipeline = this.client.pipeline();
913
+ keys.forEach((key) => {
914
+ const relativeKey = key.slice(this.keyPrefix.length);
915
+ pipeline.del(relativeKey);
916
+ });
917
+ await pipeline.exec();
918
+ }
768
919
  async keys() {
769
920
  if (!this.ready) return null;
770
921
  try {
@@ -800,7 +951,7 @@ class RedisCacheProvider {
800
951
  return;
801
952
  }
802
953
  const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
803
- await Promise.all(toDelete.map((key) => this.del(key)));
954
+ await this.delAll(toDelete);
804
955
  }
805
956
  }
806
957
  const resolveCacheProvider = (strapi2) => {
@@ -31,6 +31,12 @@ async function invalidateCache(event, cacheStore, strapi2) {
31
31
  const { model } = event;
32
32
  const uid = model.uid;
33
33
  const restApiPrefix = strapi2.config.get("api.rest.prefix", "/api");
34
+ const cacheableEntities = strapi2.plugin("strapi-cache").config("cacheableEntities");
35
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(model.tableName) : true;
36
+ if (!entityIsCacheable) {
37
+ loggy.info(`Not invalidated. ${model.tableName} is not cacheable.`);
38
+ return;
39
+ }
34
40
  try {
35
41
  const contentType = strapi2.contentType(uid);
36
42
  if (!contentType || !contentType.kind) {
@@ -47,13 +53,31 @@ async function invalidateCache(event, cacheStore, strapi2) {
47
53
  loggy.error(error);
48
54
  }
49
55
  }
50
- async function invalidateGraphqlCache(cacheStore) {
56
+ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
51
57
  try {
52
- const graphqlRegex = new RegExp(`^POST:/graphql(:.*)?$`);
58
+ const { model } = event;
59
+ const contentType = strapi2.contentType(model.uid);
60
+ if (!contentType || !contentType.info) {
61
+ loggy.info(`Content type ${model.uid} not found, purging all GraphQL cache`);
62
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
63
+ await cacheStore.clearByRegexp([graphqlRegex2]);
64
+ return;
65
+ }
66
+ const singularName = contentType.info.singularName ?? "";
67
+ const pluralName = contentType.info.pluralName ?? "";
68
+ const fieldNames = [...new Set([singularName, pluralName].filter(Boolean))];
69
+ if (fieldNames.length === 0) {
70
+ loggy.info(`No field names for ${model.uid}, purging all GraphQL cache`);
71
+ const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
72
+ await cacheStore.clearByRegexp([graphqlRegex2]);
73
+ return;
74
+ }
75
+ const escapedNames = fieldNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
76
+ const graphqlRegex = new RegExp(`^(GET|POST):/graphql:[^:]*\\b(${escapedNames})\\b[^:]*:`);
53
77
  await cacheStore.clearByRegexp([graphqlRegex]);
54
- loggy.info(`Invalidated cache for ${graphqlRegex}`);
78
+ loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
55
79
  } catch (error) {
56
- loggy.error("Cache invalidation error:");
80
+ loggy.error("GraphQL cache invalidation error:");
57
81
  loggy.error(error);
58
82
  }
59
83
  }
@@ -69,8 +93,9 @@ const bootstrap = ({ strapi: strapi2 }) => {
69
93
  loggy.info("Initializing");
70
94
  try {
71
95
  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");
96
+ const autoPurgeCache = !!strapi2.plugin("strapi-cache").config("autoPurgeCache");
97
+ const autoPurgeGraphQL = !!strapi2.plugin("strapi-cache").config("autoPurgeGraphQL");
98
+ const autoPurgeCacheOnStart = !!strapi2.plugin("strapi-cache").config("autoPurgeCacheOnStart");
74
99
  const cacheStore = cacheService.getCacheInstance();
75
100
  if (!cacheStore) {
76
101
  loggy.error("Plugin could not be initialized");
@@ -81,15 +106,25 @@ const bootstrap = ({ strapi: strapi2 }) => {
81
106
  strapi2.db.lifecycles.subscribe({
82
107
  async afterCreate(event) {
83
108
  await invalidateCache(event, cacheStore, strapi2);
84
- await invalidateGraphqlCache(cacheStore);
85
109
  },
86
110
  async afterUpdate(event) {
87
111
  await invalidateCache(event, cacheStore, strapi2);
88
- await invalidateGraphqlCache(cacheStore);
89
112
  },
90
113
  async afterDelete(event) {
91
114
  await invalidateCache(event, cacheStore, strapi2);
92
- await invalidateGraphqlCache(cacheStore);
115
+ }
116
+ });
117
+ }
118
+ if (autoPurgeGraphQL) {
119
+ strapi2.db.lifecycles.subscribe({
120
+ async afterCreate(event) {
121
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
122
+ },
123
+ async afterUpdate(event) {
124
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
125
+ },
126
+ async afterDelete(event) {
127
+ await invalidateGraphqlCache(event, cacheStore, strapi2);
93
128
  }
94
129
  });
95
130
  }
@@ -112,11 +147,17 @@ const generateCacheKey = (context) => {
112
147
  const { method } = context.request;
113
148
  return `${method}:${url}`;
114
149
  };
115
- const generateGraphqlCacheKey = (payload) => {
150
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
116
151
  const hash = createHash("sha256").update(payload).digest("base64url");
117
- return `POST:/graphql:${hash}`;
152
+ const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
153
+ return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
118
154
  };
119
155
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
156
+ const generateEntityKey = (url, restApiPrefix) => {
157
+ const regex = new RegExp(`${restApiPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([^/?]*)`);
158
+ const match = url.match(regex);
159
+ return match ? match[1] : "";
160
+ };
120
161
  const streamToBuffer = (stream) => {
121
162
  return new Promise((resolve, reject) => {
122
163
  const chunks = [];
@@ -184,6 +225,7 @@ function getCacheHeaderConfig() {
184
225
  }
185
226
  const middleware$1 = async (ctx, next) => {
186
227
  const cacheService = strapi.plugin("strapi-cache").services.service;
228
+ const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
187
229
  const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
188
230
  const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
189
231
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
@@ -194,13 +236,16 @@ const middleware$1 = async (ctx, next) => {
194
236
  const cacheControlHeader = ctx.request.headers["cache-control"];
195
237
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
196
238
  const restApiPrefix = strapi.config.get("api.rest.prefix", "/api");
239
+ const entityKey = generateEntityKey(url, restApiPrefix);
197
240
  const routeIsExcluded = excludeRoutes.some((route) => url.startsWith(route));
198
241
  if (routeIsExcluded) {
199
242
  loggy.info(`Route excluded from cache: ${url}`);
200
243
  await next();
201
244
  return;
202
245
  }
203
- const routeIsCachable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
246
+ const entityIsCacheable = cacheableEntities?.length ? cacheableEntities.includes(entityKey) : void 0;
247
+ const routeIsCacheable = cacheableRoutes.some((route) => url.startsWith(route)) || cacheableRoutes.length === 0 && url.startsWith(restApiPrefix);
248
+ const isCacheable = entityIsCacheable ?? routeIsCacheable;
204
249
  const authorizationHeader = ctx.request.headers["authorization"];
205
250
  if (authorizationHeader && !cacheAuthorizedRequests) {
206
251
  loggy.info(`Authorized request bypassing cache: ${key}`);
@@ -238,7 +283,7 @@ const middleware$1 = async (ctx, next) => {
238
283
  return;
239
284
  }
240
285
  await next();
241
- if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && routeIsCachable) {
286
+ if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && isCacheable) {
242
287
  loggy.info(`MISS with key: ${key}`);
243
288
  const headersToStore = getHeadersToStore(
244
289
  ctx,
@@ -270,32 +315,116 @@ const middleware$1 = async (ctx, next) => {
270
315
  }
271
316
  }
272
317
  };
318
+ function parseGraphqlPayload(body, isGet) {
319
+ if (isGet) {
320
+ try {
321
+ const parsed = JSON.parse(body);
322
+ const variables = parsed.variables;
323
+ const variablesParsed = typeof variables === "string" ? variables ? JSON.parse(variables) : null : variables ?? null;
324
+ return {
325
+ query: parsed.query ?? "",
326
+ variables: variablesParsed,
327
+ operationName: parsed.operationName ?? null
328
+ };
329
+ } catch {
330
+ return { query: body, variables: null, operationName: null };
331
+ }
332
+ }
333
+ try {
334
+ const parsed = JSON.parse(body);
335
+ return {
336
+ query: parsed.query ?? "",
337
+ variables: parsed.variables ?? null,
338
+ operationName: parsed.operationName ?? null
339
+ };
340
+ } catch {
341
+ return { query: body, variables: null, operationName: null };
342
+ }
343
+ }
344
+ function getRootFieldsFromQuery(query) {
345
+ const fields = [];
346
+ let depth = 0;
347
+ let i = 0;
348
+ while (i < query.length) {
349
+ if (query[i] === "{") {
350
+ depth++;
351
+ i++;
352
+ if (depth === 1) {
353
+ const rest = query.slice(i);
354
+ const match = rest.match(/^\s*(\w+)\s*([\(\{])/);
355
+ if (match) {
356
+ fields.push(match[1]);
357
+ i += match[0].length - 1;
358
+ }
359
+ }
360
+ } else if (query[i] === "}") {
361
+ depth--;
362
+ i++;
363
+ } else if (depth === 1) {
364
+ const rest = query.slice(i);
365
+ const match = rest.match(/^[\s,]*(\w+)\s*([\(\{])/);
366
+ if (match) {
367
+ fields.push(match[1]);
368
+ i += match[0].length - 1;
369
+ } else {
370
+ i++;
371
+ }
372
+ } else {
373
+ i++;
374
+ }
375
+ }
376
+ return fields;
377
+ }
273
378
  const middleware = async (ctx, next) => {
379
+ const { url, method } = ctx.request;
380
+ if (!url.startsWith("/graphql")) {
381
+ await next();
382
+ return;
383
+ }
274
384
  const cacheService = strapi.plugin("strapi-cache").services.service;
275
385
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
276
386
  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;
387
+ const isGet = method === "GET";
388
+ let body;
389
+ if (isGet) {
390
+ const { query, variables, operationName } = ctx.request.query;
391
+ body = JSON.stringify({
392
+ query: query ?? "",
393
+ variables: variables ?? "",
394
+ operationName: operationName ?? ""
395
+ });
396
+ } else {
397
+ const originalReq = ctx.req;
398
+ const bodyBuffer = await rawBody(originalReq);
399
+ body = bodyBuffer.toString();
400
+ const clonedReq = new Readable();
401
+ clonedReq.push(bodyBuffer);
402
+ clonedReq.push(null);
403
+ clonedReq.headers = { ...originalReq.headers };
404
+ clonedReq.method = originalReq.method;
405
+ clonedReq.url = originalReq.url;
406
+ clonedReq.httpVersion = originalReq.httpVersion;
407
+ clonedReq.socket = originalReq.socket;
408
+ clonedReq.connection = originalReq.connection;
409
+ ctx.req = clonedReq;
410
+ ctx.request.req = clonedReq;
411
+ }
412
+ const payload = parseGraphqlPayload(body, isGet);
413
+ const rootFields = getRootFieldsFromQuery(payload.query);
414
+ const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields);
415
+ loggy.info(
416
+ `GraphQL request: ${JSON.stringify({
417
+ operationName: payload.operationName,
418
+ variables: payload.variables,
419
+ rootFields: rootFields.length ? rootFields : void 0
420
+ })}`
421
+ );
292
422
  const isIntrospectionQuery = body.includes("IntrospectionQuery");
293
423
  if (isIntrospectionQuery) {
294
424
  loggy.info("Skipping cache for introspection query");
295
425
  await next();
296
426
  return;
297
427
  }
298
- const key = generateGraphqlCacheKey(body);
299
428
  const cacheEntry = await cacheStore.get(key);
300
429
  const cacheControlHeader = ctx.request.headers["cache-control"];
301
430
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
@@ -336,7 +465,8 @@ const middleware = async (ctx, next) => {
336
465
  return;
337
466
  }
338
467
  await next();
339
- if (ctx.method === "POST" && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql")) {
468
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
469
+ if (shouldCache) {
340
470
  loggy.info(`MISS with key: ${key}`);
341
471
  const headers = ctx.request.headers;
342
472
  const authorizationHeader2 = headers["authorization"];
@@ -402,7 +532,8 @@ const config = {
402
532
  cacheGetTimeoutInMs: 1e3,
403
533
  autoPurgeCache: true,
404
534
  autoPurgeCacheOnStart: true,
405
- disableAdminPopups: false
535
+ disableAdminPopups: false,
536
+ disableAdminButtons: false
406
537
  }),
407
538
  validator: (config2) => {
408
539
  if (typeof config2.debug !== "boolean") {
@@ -423,6 +554,9 @@ const config = {
423
554
  if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
424
555
  throw new Error(`Invalid config: cacheableRoutes must be an string array`);
425
556
  }
557
+ if (config2.cacheableEntities !== void 0 && (!Array.isArray(config2.cacheableEntities) || config2.cacheableEntities.some((item) => typeof item !== "string"))) {
558
+ throw new Error(`Invalid config: cacheableEntities must be a string array`);
559
+ }
426
560
  if (!Array.isArray(config2.excludeRoutes) || config2.excludeRoutes.some((item) => typeof item !== "string")) {
427
561
  throw new Error(`Invalid config: excludeRoutes must be a string array`);
428
562
  }
@@ -474,6 +608,9 @@ const config = {
474
608
  if (typeof config2.disableAdminPopups !== "boolean") {
475
609
  throw new Error(`Invalid config: disableAdminPopups must be a boolean`);
476
610
  }
611
+ if (typeof config2.disableAdminButtons !== "boolean") {
612
+ throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
613
+ }
477
614
  }
478
615
  };
479
616
  const contentTypes = {};
@@ -504,8 +641,10 @@ const controller = ({ strapi: strapi2 }) => ({
504
641
  async config(ctx) {
505
642
  try {
506
643
  const config2 = {
644
+ cacheableEntities: strapi2.plugin("strapi-cache").config("cacheableEntities") ?? [],
507
645
  cacheableRoutes: strapi2.plugin("strapi-cache").config("cacheableRoutes") ?? [],
508
- disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false
646
+ disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false,
647
+ disableAdminButtons: strapi2.plugin("strapi-cache").config("disableAdminButtons") ?? false
509
648
  };
510
649
  ctx.body = config2;
511
650
  } catch (error) {
@@ -761,6 +900,18 @@ class RedisCacheProvider {
761
900
  return null;
762
901
  }
763
902
  }
903
+ /**
904
+ * Deletes all given keys in Redis pipeline.
905
+ * @param keys to delete from cache
906
+ */
907
+ async delAll(keys) {
908
+ const pipeline = this.client.pipeline();
909
+ keys.forEach((key) => {
910
+ const relativeKey = key.slice(this.keyPrefix.length);
911
+ pipeline.del(relativeKey);
912
+ });
913
+ await pipeline.exec();
914
+ }
764
915
  async keys() {
765
916
  if (!this.ready) return null;
766
917
  try {
@@ -796,7 +947,7 @@ class RedisCacheProvider {
796
947
  return;
797
948
  }
798
949
  const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
799
- await Promise.all(toDelete.map((key) => this.del(key)));
950
+ await this.delAll(toDelete);
800
951
  }
801
952
  }
802
953
  const resolveCacheProvider = (strapi2) => {
@@ -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.1",
3
3
  "keywords": [
4
4
  "strapi cache",
5
5
  "strapi rest cache",
@@ -34,7 +34,9 @@
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",
@@ -48,14 +50,21 @@
48
50
  "@strapi/sdk-plugin": "^5.3.2",
49
51
  "@strapi/strapi": "^5.0.0",
50
52
  "@strapi/typescript-utils": "^5.0.0",
53
+ "@types/jest": "^30.0.0",
51
54
  "@types/koa": "^2.15.0",
52
55
  "@types/react": "^19.1.0",
53
56
  "@types/react-dom": "^19.1.1",
57
+ "@types/supertest": "^6.0.3",
58
+ "better-sqlite3": "^12.6.2",
59
+ "jest": "^30.2.0",
54
60
  "prettier": "^3.5.3",
55
61
  "react": "^18.3.1",
56
62
  "react-dom": "^18.3.1",
57
63
  "react-router-dom": "^6.30.0",
58
64
  "styled-components": "^6.1.17",
65
+ "supertest": "^7.2.2",
66
+ "ts-jest": "^29.4.6",
67
+ "ts-node": "^10.9.2",
59
68
  "typescript": "^5.8.3",
60
69
  "vitest": "^3.1.1"
61
70
  },
@@ -67,6 +76,11 @@
67
76
  "react-router-dom": "^6.30.0",
68
77
  "styled-components": "^6.1.17"
69
78
  },
79
+ "optionalDependencies": {
80
+ "@img/sharp-linux-x64": "*",
81
+ "@rollup/rollup-linux-x64-gnu": "*",
82
+ "@swc/core-linux-x64-gnu": "*"
83
+ },
70
84
  "strapi": {
71
85
  "kind": "plugin",
72
86
  "name": "strapi-cache",