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 +4 -1
- 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 +184 -33
- package/dist/server/index.mjs +184 -33
- 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 +16 -2
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
|
|
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
|
@@ -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
|
|
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 ${
|
|
82
|
+
loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
|
|
59
83
|
} catch (error) {
|
|
60
|
-
loggy.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
954
|
+
await this.delAll(toDelete);
|
|
804
955
|
}
|
|
805
956
|
}
|
|
806
957
|
const resolveCacheProvider = (strapi2) => {
|
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
|
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 ${
|
|
78
|
+
loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
|
|
55
79
|
} catch (error) {
|
|
56
|
-
loggy.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
950
|
+
await this.delAll(toDelete);
|
|
800
951
|
}
|
|
801
952
|
}
|
|
802
953
|
const resolveCacheProvider = (strapi2) => {
|
|
@@ -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.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",
|