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 +10 -7
- package/dist/_chunks/{index-BjBoZhpS.js → index-B_MAAg0W.js} +1 -1
- package/dist/_chunks/{index-BwjcxGep.mjs → index-BwuX8jJl.mjs} +9 -1
- package/dist/_chunks/{index-BInrGFOe.mjs → index-CkLhx2ik.mjs} +1 -1
- package/dist/_chunks/{index-Dwrl1jfJ.js → index-D_ssKKxu.js} +9 -1
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/components/PurgeCacheButton/index.d.ts +1 -1
- package/dist/admin/src/hooks/useCacheConfig.d.ts +1 -0
- package/dist/server/index.js +218 -45
- package/dist/server/index.mjs +219 -46
- package/dist/server/src/config/index.d.ts +1 -0
- package/dist/server/src/index.d.ts +1 -0
- package/dist/server/src/services/redis/provider.d.ts +5 -0
- package/dist/server/src/utils/graphql.d.ts +6 -0
- package/dist/server/src/utils/invalidateCache.d.ts +5 -1
- package/dist/server/src/utils/key.d.ts +2 -1
- package/package.json +17 -2
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
|
|
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 '
|
|
55
|
-
redisConfig: env('REDIS_URL', 'redis://localhost:6379'), // Redis config
|
|
56
|
-
redisClusterNodes: [], // If provided any cluster node (this list is not empty), initialize
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
375
|
+
Component: () => Promise.resolve().then(() => require("./index-B_MAAg0W.js")),
|
|
368
376
|
permissions: []
|
|
369
377
|
}
|
|
370
378
|
]
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
|
@@ -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;
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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 ${
|
|
83
|
+
loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
|
|
59
84
|
} catch (error) {
|
|
60
|
-
loggy.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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 '
|
|
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(
|
|
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 (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
867
|
+
loggy.info("Valkey provider initialized");
|
|
716
868
|
} else {
|
|
717
|
-
|
|
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
|
|
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:
|
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
|
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 ${
|
|
79
|
+
loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
|
|
55
80
|
} catch (error) {
|
|
56
|
-
loggy.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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 '
|
|
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(
|
|
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 (
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
863
|
+
loggy.info("Valkey provider initialized");
|
|
712
864
|
} else {
|
|
713
|
-
|
|
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
|
|
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:
|
|
@@ -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>;
|
|
@@ -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(
|
|
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.
|
|
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",
|