strapi-cache 1.6.2 → 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.
Files changed (46) hide show
  1. package/README.md +7 -2
  2. package/dist/_chunks/de-C_gFyhHL.mjs +18 -0
  3. package/dist/_chunks/de-l6MDDAXu.js +18 -0
  4. package/dist/_chunks/en-D9w03LyX.js +18 -0
  5. package/dist/_chunks/en-bK2OKwtN.mjs +18 -0
  6. package/dist/_chunks/index-B_MAAg0W.js +52 -0
  7. package/dist/_chunks/index-BwuX8jJl.mjs +396 -0
  8. package/dist/_chunks/index-CkLhx2ik.mjs +52 -0
  9. package/dist/_chunks/index-D_ssKKxu.js +395 -0
  10. package/dist/admin/index.js +3 -0
  11. package/dist/admin/index.mjs +4 -0
  12. package/dist/admin/src/components/PurgeCacheButton/index.d.ts +1 -1
  13. package/dist/admin/src/components/PurgeModal/index.d.ts +2 -1
  14. package/dist/admin/src/hooks/useCacheConfig.d.ts +1 -0
  15. package/dist/admin/src/hooks/useCacheOperations.d.ts +1 -0
  16. package/dist/server/index.js +999 -0
  17. package/dist/server/index.mjs +997 -0
  18. package/dist/server/src/bootstrap.d.ts +5 -0
  19. package/dist/server/src/config/index.d.ts +28 -0
  20. package/dist/server/src/content-types/index.d.ts +2 -0
  21. package/dist/server/src/controllers/controller.d.ts +10 -0
  22. package/dist/server/src/controllers/index.d.ts +11 -0
  23. package/dist/server/src/index.d.ts +76 -0
  24. package/dist/server/src/middlewares/cache.d.ts +3 -0
  25. package/dist/server/src/middlewares/graphql.d.ts +2 -0
  26. package/dist/server/src/middlewares/index.d.ts +6 -0
  27. package/dist/server/src/permissions.d.ts +6 -0
  28. package/dist/server/src/policies/index.d.ts +2 -0
  29. package/dist/server/src/register.d.ts +5 -0
  30. package/dist/server/src/routes/index.d.ts +19 -0
  31. package/dist/server/src/routes/purge.d.ts +14 -0
  32. package/dist/server/src/services/index.d.ts +8 -0
  33. package/dist/server/src/services/memory/provider.d.ts +17 -0
  34. package/dist/server/src/services/memory/service.d.ts +6 -0
  35. package/dist/server/src/services/redis/provider.d.ts +23 -0
  36. package/dist/server/src/services/redis/service.d.ts +6 -0
  37. package/dist/server/src/services/resolver.d.ts +3 -0
  38. package/dist/server/src/types/cache.types.d.ts +18 -0
  39. package/dist/server/src/utils/body.d.ts +7 -0
  40. package/dist/server/src/utils/graphql.d.ts +6 -0
  41. package/dist/server/src/utils/header.d.ts +10 -0
  42. package/dist/server/src/utils/invalidateCache.d.ts +8 -0
  43. package/dist/server/src/utils/key.d.ts +5 -0
  44. package/dist/server/src/utils/log.d.ts +5 -0
  45. package/dist/server/src/utils/withTimeout.d.ts +1 -0
  46. package/package.json +16 -2
@@ -0,0 +1,997 @@
1
+ import { createHash } from "crypto";
2
+ import Stream, { Readable } from "stream";
3
+ import { createInflate, createBrotliDecompress, createGunzip } from "zlib";
4
+ import rawBody from "raw-body";
5
+ import { LRUCache } from "lru-cache";
6
+ import { Redis } from "ioredis";
7
+ const loggy = {
8
+ info: (msg) => {
9
+ const shouldDebug = strapi.plugin("strapi-cache").config("debug") ?? false;
10
+ if (!shouldDebug) {
11
+ return;
12
+ }
13
+ strapi.log.info(`[STRAPI CACHE] ${msg}`);
14
+ },
15
+ error: (msg) => {
16
+ const shouldDebug = strapi.plugin("strapi-cache").config("debug") ?? false;
17
+ if (!shouldDebug) {
18
+ return;
19
+ }
20
+ strapi.log.error(`[STRAPI CACHE] ${msg}`);
21
+ },
22
+ warn: (msg) => {
23
+ const shouldDebug = strapi.plugin("strapi-cache").config("debug") ?? false;
24
+ if (!shouldDebug) {
25
+ return;
26
+ }
27
+ strapi.log.warn(`[STRAPI CACHE] ${msg}`);
28
+ }
29
+ };
30
+ async function invalidateCache(event, cacheStore, strapi2) {
31
+ const { model } = event;
32
+ const uid = model.uid;
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
+ }
40
+ try {
41
+ const contentType = strapi2.contentType(uid);
42
+ if (!contentType || !contentType.kind) {
43
+ loggy.info(`Content type ${uid} not found`);
44
+ return;
45
+ }
46
+ const pluralName = contentType.kind === "singleType" ? contentType.info.singularName : contentType.info.pluralName;
47
+ const apiPath = `${restApiPrefix}/${pluralName}`;
48
+ const regex = new RegExp(`^.*:${apiPath}(/.*)?(\\?.*)?$`);
49
+ await cacheStore.clearByRegexp([regex]);
50
+ loggy.info(`Invalidated cache for ${apiPath}`);
51
+ } catch (error) {
52
+ loggy.error("Cache invalidation error:");
53
+ loggy.error(error);
54
+ }
55
+ }
56
+ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
57
+ try {
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[^:]*:`);
77
+ await cacheStore.clearByRegexp([graphqlRegex]);
78
+ loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
79
+ } catch (error) {
80
+ loggy.error("GraphQL cache invalidation error:");
81
+ loggy.error(error);
82
+ }
83
+ }
84
+ const actions = [
85
+ {
86
+ section: "plugins",
87
+ displayName: "Purge Cache",
88
+ uid: "purge-cache",
89
+ pluginName: "strapi-cache"
90
+ }
91
+ ];
92
+ const bootstrap = ({ strapi: strapi2 }) => {
93
+ loggy.info("Initializing");
94
+ try {
95
+ const cacheService = strapi2.plugin("strapi-cache").services.service;
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");
99
+ const cacheStore = cacheService.getCacheInstance();
100
+ if (!cacheStore) {
101
+ loggy.error("Plugin could not be initialized");
102
+ return;
103
+ }
104
+ cacheStore.init();
105
+ if (autoPurgeCache) {
106
+ strapi2.db.lifecycles.subscribe({
107
+ async afterCreate(event) {
108
+ await invalidateCache(event, cacheStore, strapi2);
109
+ },
110
+ async afterUpdate(event) {
111
+ await invalidateCache(event, cacheStore, strapi2);
112
+ },
113
+ async afterDelete(event) {
114
+ await invalidateCache(event, cacheStore, strapi2);
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);
128
+ }
129
+ });
130
+ }
131
+ if (autoPurgeCacheOnStart) {
132
+ cacheStore.reset().then(() => {
133
+ loggy.info("Cache purged successfully");
134
+ }).catch((error) => {
135
+ loggy.error(`Error purging cache on start: ${error.message}`);
136
+ });
137
+ }
138
+ } catch (error) {
139
+ loggy.error("Plugin could not be initialized");
140
+ return;
141
+ }
142
+ loggy.info("Plugin initialized");
143
+ strapi2.admin.services.permission.actionProvider.registerMany(actions);
144
+ };
145
+ const generateCacheKey = (context) => {
146
+ const { url } = context.request;
147
+ const { method } = context.request;
148
+ return `${method}:${url}`;
149
+ };
150
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
151
+ const hash = createHash("sha256").update(payload).digest("base64url");
152
+ const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
153
+ return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
154
+ };
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
+ };
161
+ const streamToBuffer = (stream) => {
162
+ return new Promise((resolve, reject) => {
163
+ const chunks = [];
164
+ stream.on("data", (chunk) => chunks.push(chunk));
165
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
166
+ stream.on("error", reject);
167
+ });
168
+ };
169
+ const decompressBuffer = async (buffer, encoding) => {
170
+ return new Promise((resolve, reject) => {
171
+ let decompressStream;
172
+ switch (encoding) {
173
+ case "gzip":
174
+ decompressStream = createGunzip();
175
+ break;
176
+ case "br":
177
+ decompressStream = createBrotliDecompress();
178
+ break;
179
+ case "deflate":
180
+ decompressStream = createInflate();
181
+ break;
182
+ default:
183
+ return resolve(buffer);
184
+ }
185
+ const chunks = [];
186
+ decompressStream.on("data", (chunk) => chunks.push(chunk));
187
+ decompressStream.on("end", () => resolve(Buffer.concat(chunks)));
188
+ decompressStream.on("error", reject);
189
+ decompressStream.end(buffer);
190
+ });
191
+ };
192
+ const decodeBufferToText = (buffer) => {
193
+ const decoder = new TextDecoder("utf-8");
194
+ return decoder.decode(buffer);
195
+ };
196
+ function getHeadersToStore(ctx, cacheHeaders, cacheHeadersAllowList = [], cacheHeadersDenyList = []) {
197
+ let headersToStore = null;
198
+ if (cacheHeaders) {
199
+ let headers = ctx.response.headers;
200
+ if (cacheHeadersAllowList.length) {
201
+ headers = Object.fromEntries(
202
+ Object.entries(headers).filter(([key]) => cacheHeadersAllowList.includes(key.toLowerCase()))
203
+ );
204
+ }
205
+ if (cacheHeadersDenyList.length) {
206
+ headers = Object.fromEntries(
207
+ Object.entries(headers).filter(([key]) => !cacheHeadersDenyList.includes(key.toLowerCase()))
208
+ );
209
+ }
210
+ headersToStore = headers;
211
+ }
212
+ return headersToStore;
213
+ }
214
+ function getCacheHeaderConfig() {
215
+ const cacheHeaders = strapi.plugin("strapi-cache").config("cacheHeaders");
216
+ const cacheHeadersDenyList = strapi.plugin("strapi-cache").config("cacheHeadersDenyList");
217
+ const cacheHeadersAllowList = strapi.plugin("strapi-cache").config("cacheHeadersAllowList");
218
+ const cacheAuthorizedRequests = strapi.plugin("strapi-cache").config("cacheAuthorizedRequests");
219
+ return {
220
+ cacheHeaders,
221
+ cacheHeadersDenyList,
222
+ cacheHeadersAllowList,
223
+ cacheAuthorizedRequests
224
+ };
225
+ }
226
+ const middleware$1 = async (ctx, next) => {
227
+ const cacheService = strapi.plugin("strapi-cache").services.service;
228
+ const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
229
+ const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
230
+ const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
231
+ const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
232
+ const cacheStore = cacheService.getCacheInstance();
233
+ const { url } = ctx.request;
234
+ const key = generateCacheKey(ctx);
235
+ const cacheEntry = await cacheStore.get(key);
236
+ const cacheControlHeader = ctx.request.headers["cache-control"];
237
+ const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
238
+ const restApiPrefix = strapi.config.get("api.rest.prefix", "/api");
239
+ const entityKey = generateEntityKey(url, restApiPrefix);
240
+ const routeIsExcluded = excludeRoutes.some((route) => url.startsWith(route));
241
+ if (routeIsExcluded) {
242
+ loggy.info(`Route excluded from cache: ${url}`);
243
+ await next();
244
+ return;
245
+ }
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;
249
+ const authorizationHeader = ctx.request.headers["authorization"];
250
+ if (authorizationHeader && !cacheAuthorizedRequests) {
251
+ loggy.info(`Authorized request bypassing cache: ${key}`);
252
+ await next();
253
+ return;
254
+ }
255
+ const middlewaresConfig = strapi.config.get("middlewares");
256
+ const corsMiddleware = middlewaresConfig.find((mw) => mw.name === "strapi::cors");
257
+ const corsConfig = corsMiddleware?.config;
258
+ const origin = ctx?.request?.headers?.origin;
259
+ let allowedOrigins = corsConfig?.origin ?? "*";
260
+ if (typeof allowedOrigins === "string") {
261
+ allowedOrigins = [allowedOrigins];
262
+ }
263
+ if (cacheEntry && !noCache) {
264
+ loggy.info(`HIT with key: ${key}`);
265
+ ctx.status = 200;
266
+ ctx.body = cacheEntry.body;
267
+ if (cacheHeaders) {
268
+ ctx.set(cacheEntry.headers);
269
+ }
270
+ if (corsMiddleware) {
271
+ loggy.info("CORS middleware is set, checking allowed origins");
272
+ if (allowedOrigins.includes(origin)) {
273
+ loggy.info(`Setting Access-Control-Allow-Origin to ${origin}`);
274
+ ctx.set("Access-Control-Allow-Origin", origin);
275
+ } else if (typeof origin === "undefined" || allowedOrigins.includes("*")) {
276
+ loggy.info("No origin header or * in allowed origins, setting to *");
277
+ ctx.set("Access-Control-Allow-Origin", "*");
278
+ }
279
+ } else {
280
+ loggy.info("No CORS middleware set, setting to request origin or *");
281
+ ctx.set("Access-Control-Allow-Origin", ctx.request.headers.origin || "*");
282
+ }
283
+ return;
284
+ }
285
+ await next();
286
+ if (ctx.method === "GET" && ctx.status >= 200 && ctx.status < 300 && isCacheable) {
287
+ loggy.info(`MISS with key: ${key}`);
288
+ const headersToStore = getHeadersToStore(
289
+ ctx,
290
+ cacheHeaders,
291
+ cacheHeadersAllowList,
292
+ cacheHeadersDenyList
293
+ );
294
+ let setCache = true;
295
+ if (corsMiddleware) {
296
+ if (allowedOrigins.includes(origin)) ;
297
+ else if (typeof origin === "undefined" || allowedOrigins.includes("*")) ;
298
+ else {
299
+ setCache = false;
300
+ }
301
+ }
302
+ if (ctx.body instanceof Stream) {
303
+ const buf = await streamToBuffer(ctx.body);
304
+ const contentEncoding = ctx.response.headers["content-encoding"];
305
+ const decompressed = await decompressBuffer(buf, contentEncoding);
306
+ const responseText = decodeBufferToText(decompressed);
307
+ if (setCache) {
308
+ await cacheStore.set(key, { body: responseText, headers: headersToStore });
309
+ }
310
+ ctx.body = buf;
311
+ } else {
312
+ if (setCache) {
313
+ await cacheStore.set(key, { body: ctx.body, headers: headersToStore });
314
+ }
315
+ }
316
+ }
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
+ }
378
+ const middleware = async (ctx, next) => {
379
+ const { url, method } = ctx.request;
380
+ if (!url.startsWith("/graphql")) {
381
+ await next();
382
+ return;
383
+ }
384
+ const cacheService = strapi.plugin("strapi-cache").services.service;
385
+ const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
386
+ const cacheStore = cacheService.getCacheInstance();
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
+ );
422
+ const isIntrospectionQuery = body.includes("IntrospectionQuery");
423
+ if (isIntrospectionQuery) {
424
+ loggy.info("Skipping cache for introspection query");
425
+ await next();
426
+ return;
427
+ }
428
+ const cacheEntry = await cacheStore.get(key);
429
+ const cacheControlHeader = ctx.request.headers["cache-control"];
430
+ const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
431
+ const authorizationHeader = ctx.request.headers["authorization"];
432
+ if (authorizationHeader && !cacheAuthorizedRequests) {
433
+ loggy.info(`Authorized request bypassing cache: ${key}`);
434
+ await next();
435
+ return;
436
+ }
437
+ const middlewaresConfig = strapi.config.get("middlewares");
438
+ const corsMiddleware = middlewaresConfig.find((mw) => mw.name === "strapi::cors");
439
+ const corsConfig = corsMiddleware?.config;
440
+ const origin = ctx?.request?.headers?.origin;
441
+ let allowedOrigins = corsConfig?.origin ?? "*";
442
+ if (typeof allowedOrigins === "string") {
443
+ allowedOrigins = [allowedOrigins];
444
+ }
445
+ if (cacheEntry && !noCache) {
446
+ loggy.info(`HIT with key: ${key}`);
447
+ ctx.status = 200;
448
+ ctx.body = cacheEntry.body;
449
+ if (cacheHeaders) {
450
+ ctx.set(cacheEntry.headers);
451
+ }
452
+ if (corsMiddleware) {
453
+ loggy.info("CORS middleware is set, checking allowed origins");
454
+ if (allowedOrigins.includes(origin)) {
455
+ loggy.info(`Setting Access-Control-Allow-Origin to ${origin}`);
456
+ ctx.set("Access-Control-Allow-Origin", origin);
457
+ } else if (typeof origin === "undefined" || allowedOrigins.includes("*")) {
458
+ loggy.info("No origin header or * in allowed origins, setting to *");
459
+ ctx.set("Access-Control-Allow-Origin", "*");
460
+ }
461
+ } else {
462
+ loggy.info("No CORS middleware set, setting to request origin or *");
463
+ ctx.set("Access-Control-Allow-Origin", ctx.request.headers.origin || "*");
464
+ }
465
+ return;
466
+ }
467
+ await next();
468
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
469
+ if (shouldCache) {
470
+ loggy.info(`MISS with key: ${key}`);
471
+ const headers = ctx.request.headers;
472
+ const authorizationHeader2 = headers["authorization"];
473
+ if (authorizationHeader2 && !cacheAuthorizedRequests) {
474
+ loggy.info(`Authorized request not caching: ${key}`);
475
+ return;
476
+ }
477
+ const headersToStore = getHeadersToStore(
478
+ ctx,
479
+ cacheHeaders,
480
+ cacheHeadersAllowList,
481
+ cacheHeadersDenyList
482
+ );
483
+ let setCache = true;
484
+ if (corsMiddleware) {
485
+ if (allowedOrigins.includes(origin)) ;
486
+ else if (typeof origin === "undefined" || allowedOrigins.includes("*")) ;
487
+ else {
488
+ setCache = false;
489
+ }
490
+ }
491
+ if (ctx.body instanceof Stream) {
492
+ const buf = await streamToBuffer(ctx.body);
493
+ const contentEncoding = ctx.response.headers["content-encoding"];
494
+ const decompressed = await decompressBuffer(buf, contentEncoding);
495
+ const responseText = decodeBufferToText(decompressed);
496
+ if (setCache) {
497
+ await cacheStore.set(key, { body: responseText, headers: headersToStore });
498
+ }
499
+ ctx.body = buf;
500
+ } else {
501
+ if (setCache) {
502
+ await cacheStore.set(key, { body: ctx.body, headers: headersToStore });
503
+ }
504
+ }
505
+ }
506
+ };
507
+ const middlewares = {
508
+ graphql: middleware,
509
+ cache: middleware$1
510
+ };
511
+ const register = ({ strapi: strapi2 }) => {
512
+ strapi2.server.use(middlewares.cache);
513
+ strapi2.server.use(middlewares.graphql);
514
+ };
515
+ const config = {
516
+ default: ({ env }) => ({
517
+ debug: false,
518
+ max: 1e3,
519
+ ttl: 1e3 * 60 * 60,
520
+ size: 1024 * 1024 * 10,
521
+ allowStale: false,
522
+ cacheableRoutes: [],
523
+ provider: "memory",
524
+ excludeRoutes: [],
525
+ redisConfig: env("REDIS_URL"),
526
+ redisClusterNodes: [],
527
+ redisClusterOptions: {},
528
+ cacheHeaders: true,
529
+ cacheHeadersDenyList: [],
530
+ cacheHeadersAllowList: [],
531
+ cacheAuthorizedRequests: false,
532
+ cacheGetTimeoutInMs: 1e3,
533
+ autoPurgeCache: true,
534
+ autoPurgeCacheOnStart: true,
535
+ disableAdminPopups: false,
536
+ disableAdminButtons: false
537
+ }),
538
+ validator: (config2) => {
539
+ if (typeof config2.debug !== "boolean") {
540
+ throw new Error(`Invalid config: debug must be a boolean`);
541
+ }
542
+ if (typeof config2.max !== "number") {
543
+ throw new Error(`Invalid config: max must be a number`);
544
+ }
545
+ if (typeof config2.ttl !== "number") {
546
+ throw new Error(`Invalid config: ttl must be a number`);
547
+ }
548
+ if (typeof config2.size !== "number") {
549
+ throw new Error(`Invalid config: size must be a number`);
550
+ }
551
+ if (typeof config2.allowStale !== "boolean") {
552
+ throw new Error(`Invalid config: allowStale must be a boolean`);
553
+ }
554
+ if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
555
+ throw new Error(`Invalid config: cacheableRoutes must be an string array`);
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
+ }
560
+ if (!Array.isArray(config2.excludeRoutes) || config2.excludeRoutes.some((item) => typeof item !== "string")) {
561
+ throw new Error(`Invalid config: excludeRoutes must be a string array`);
562
+ }
563
+ if (typeof config2.provider !== "string") {
564
+ throw new Error(`Invalid config: provider must be a string`);
565
+ }
566
+ if (config2.provider !== "memory" && config2.provider !== "redis") {
567
+ throw new Error(`Invalid config: provider must be 'memory' or 'redis'`);
568
+ }
569
+ if (config2.provider === "redis") {
570
+ if (!config2.redisConfig) {
571
+ throw new Error(`Invalid config: redisConfig must be set when using redis provider`);
572
+ }
573
+ if (typeof config2.redisConfig !== "string" && typeof config2.redisConfig !== "object") {
574
+ throw new Error(
575
+ `Invalid config: redisConfig must be a string or object when using redis provider`
576
+ );
577
+ }
578
+ if (!Array.isArray(config2.redisClusterNodes) || config2.redisClusterNodes.some((item) => !("host" in item && "port" in item))) {
579
+ throw new Error(
580
+ `Invalid config: redisClusterNodes must be as a list of objects with keys 'host' and 'port'`
581
+ );
582
+ }
583
+ if (typeof config2.redisClusterOptions !== "object") {
584
+ throw new Error(`Invalid config: redisClusterOptions must be an object`);
585
+ }
586
+ }
587
+ if (typeof config2.cacheHeaders !== "boolean") {
588
+ throw new Error(`Invalid config: cacheHeaders must be a boolean`);
589
+ }
590
+ if (!Array.isArray(config2.cacheHeadersDenyList) || config2.cacheHeadersDenyList.some((item) => typeof item !== "string")) {
591
+ throw new Error(`Invalid config: cacheHeadersDenyList must be an string array`);
592
+ }
593
+ if (!Array.isArray(config2.cacheHeadersAllowList) || config2.cacheHeadersAllowList.some((item) => typeof item !== "string")) {
594
+ throw new Error(`Invalid config: cacheHeadersAllowList must be an string array`);
595
+ }
596
+ if (typeof config2.cacheAuthorizedRequests !== "boolean") {
597
+ throw new Error(`Invalid config: cacheAuthorizedRequests must be a boolean`);
598
+ }
599
+ if (typeof config2.cacheGetTimeoutInMs !== "number") {
600
+ throw new Error(`Invalid config: cacheGetTimeoutInMs must be a number`);
601
+ }
602
+ if (typeof config2.autoPurgeCache !== "boolean") {
603
+ throw new Error(`Invalid config: autoPurgeCache must be a boolean`);
604
+ }
605
+ if (typeof config2.autoPurgeCacheOnStart !== "boolean") {
606
+ throw new Error(`Invalid config: autoPurgeCacheOnStart must be a boolean`);
607
+ }
608
+ if (typeof config2.disableAdminPopups !== "boolean") {
609
+ throw new Error(`Invalid config: disableAdminPopups must be a boolean`);
610
+ }
611
+ if (typeof config2.disableAdminButtons !== "boolean") {
612
+ throw new Error(`Invalid config: disableAdminButtons must be a boolean`);
613
+ }
614
+ }
615
+ };
616
+ const contentTypes = {};
617
+ const controller = ({ strapi: strapi2 }) => ({
618
+ async purgeCache(ctx) {
619
+ const service2 = strapi2.plugin("strapi-cache").service("service");
620
+ await service2.getCacheInstance().reset();
621
+ ctx.body = {
622
+ message: "Cache purged successfully"
623
+ };
624
+ },
625
+ async purgeCacheByKey(ctx) {
626
+ const { key } = ctx.request.body;
627
+ if (!key || typeof key !== "string" || key.trim() === "") {
628
+ ctx.status = 400;
629
+ ctx.body = {
630
+ error: "Key is required and must be a non-empty string"
631
+ };
632
+ return;
633
+ }
634
+ const service2 = strapi2.plugin("strapi-cache").service("service");
635
+ const regex = new RegExp(escapeRegExp(key));
636
+ await service2.getCacheInstance().clearByRegexp([regex]);
637
+ ctx.body = {
638
+ message: `Cache purged successfully for key: ${key}`
639
+ };
640
+ },
641
+ async config(ctx) {
642
+ try {
643
+ const config2 = {
644
+ cacheableEntities: strapi2.plugin("strapi-cache").config("cacheableEntities") ?? [],
645
+ cacheableRoutes: strapi2.plugin("strapi-cache").config("cacheableRoutes") ?? [],
646
+ disableAdminPopups: strapi2.plugin("strapi-cache").config("disableAdminPopups") ?? false,
647
+ disableAdminButtons: strapi2.plugin("strapi-cache").config("disableAdminButtons") ?? false
648
+ };
649
+ ctx.body = config2;
650
+ } catch (error) {
651
+ console.error("Error constructing config:", error);
652
+ ctx.status = 500;
653
+ ctx.body = { error: "Configuration not available" };
654
+ }
655
+ }
656
+ });
657
+ const controllers = {
658
+ controller
659
+ };
660
+ const policies = {};
661
+ const purgeRoute = [
662
+ {
663
+ method: "POST",
664
+ path: "/purge-cache",
665
+ handler: "controller.purgeCache",
666
+ config: {
667
+ policies: [
668
+ "admin::isAuthenticatedAdmin",
669
+ {
670
+ name: "plugin::content-manager.hasPermissions",
671
+ config: {
672
+ actions: ["plugin::strapi-cache.purge-cache"]
673
+ }
674
+ }
675
+ ]
676
+ }
677
+ },
678
+ {
679
+ method: "POST",
680
+ path: "/purge-cache/key",
681
+ handler: "controller.purgeCacheByKey",
682
+ config: {
683
+ policies: [
684
+ "admin::isAuthenticatedAdmin",
685
+ {
686
+ name: "plugin::content-manager.hasPermissions",
687
+ config: {
688
+ actions: ["plugin::strapi-cache.purge-cache"]
689
+ }
690
+ }
691
+ ]
692
+ }
693
+ },
694
+ {
695
+ method: "GET",
696
+ path: "/config",
697
+ handler: "controller.config",
698
+ config: {
699
+ policies: [
700
+ "admin::isAuthenticatedAdmin",
701
+ {
702
+ name: "plugin::content-manager.hasPermissions",
703
+ config: {
704
+ actions: ["plugin::strapi-cache.purge-cache"]
705
+ }
706
+ }
707
+ ]
708
+ }
709
+ }
710
+ ];
711
+ const routes = {
712
+ "purge-route": {
713
+ type: "admin",
714
+ routes: purgeRoute
715
+ }
716
+ };
717
+ const withTimeout = (callback, ms) => {
718
+ let timeout = null;
719
+ return Promise.race([
720
+ callback().then((result) => {
721
+ if (timeout) {
722
+ clearTimeout(timeout);
723
+ }
724
+ return result;
725
+ }),
726
+ new Promise((_, reject) => {
727
+ timeout = setTimeout(() => {
728
+ reject(new Error("timeout"));
729
+ }, ms);
730
+ })
731
+ ]);
732
+ };
733
+ class InMemoryCacheProvider {
734
+ constructor(strapi2) {
735
+ this.strapi = strapi2;
736
+ this.initialized = false;
737
+ }
738
+ init() {
739
+ if (this.initialized) {
740
+ loggy.error("Provider already initialized");
741
+ return;
742
+ }
743
+ this.initialized = true;
744
+ const max = Number(this.strapi.plugin("strapi-cache").config("max"));
745
+ const ttl = Number(this.strapi.plugin("strapi-cache").config("ttl"));
746
+ const size = Number(this.strapi.plugin("strapi-cache").config("size"));
747
+ const allowStale = Boolean(this.strapi.plugin("strapi-cache").config("allowStale"));
748
+ this.provider = new LRUCache({
749
+ max,
750
+ ttl,
751
+ size,
752
+ allowStale
753
+ });
754
+ this.cacheGetTimeoutInMs = Number(
755
+ this.strapi.plugin("strapi-cache").config("cacheGetTimeoutInMs")
756
+ );
757
+ loggy.info("Provider initialized");
758
+ }
759
+ get ready() {
760
+ if (!this.initialized) {
761
+ loggy.info("Provider not initialized");
762
+ return false;
763
+ }
764
+ return true;
765
+ }
766
+ async get(key) {
767
+ if (!this.ready) return null;
768
+ return withTimeout(
769
+ () => new Promise((resolve) => {
770
+ resolve(this.provider.get(key));
771
+ }),
772
+ this.cacheGetTimeoutInMs
773
+ ).catch((error) => {
774
+ loggy.error(`Error during get: ${error?.message || error}`);
775
+ return null;
776
+ });
777
+ }
778
+ async set(key, val) {
779
+ if (!this.ready) return null;
780
+ try {
781
+ return this.provider.set(key, val);
782
+ } catch (error) {
783
+ loggy.error(`Error during set: ${error}`);
784
+ return null;
785
+ }
786
+ }
787
+ async del(key) {
788
+ if (!this.ready) return null;
789
+ try {
790
+ loggy.info(`PURGING KEY: ${key}`);
791
+ return this.provider.delete(key);
792
+ } catch (error) {
793
+ loggy.error(`Error during delete: ${error}`);
794
+ return null;
795
+ }
796
+ }
797
+ async keys() {
798
+ if (!this.ready) return null;
799
+ try {
800
+ return Array.from(this.provider.keys());
801
+ } catch (error) {
802
+ loggy.error(`Error fetching keys: ${error}`);
803
+ return null;
804
+ }
805
+ }
806
+ async reset() {
807
+ if (!this.ready) return null;
808
+ try {
809
+ const allKeys = await this.keys();
810
+ if (!allKeys) return null;
811
+ loggy.info(`PURGING ALL KEYS: ${allKeys.length}`);
812
+ await Promise.all(allKeys.map((key) => this.del(key)));
813
+ return true;
814
+ } catch (error) {
815
+ loggy.error(`Error during reset: ${error}`);
816
+ return null;
817
+ }
818
+ }
819
+ async clearByRegexp(regExps) {
820
+ const keys = await this.keys();
821
+ if (!keys) {
822
+ return;
823
+ }
824
+ const matches = keys.filter((key) => regExps.some((re) => re.test(key)));
825
+ await Promise.all(matches.map((key) => this.del(key)));
826
+ }
827
+ }
828
+ class RedisCacheProvider {
829
+ constructor(strapi2) {
830
+ this.strapi = strapi2;
831
+ this.initialized = false;
832
+ }
833
+ init() {
834
+ if (this.initialized) {
835
+ loggy.error("Redis provider already initialized");
836
+ return;
837
+ }
838
+ try {
839
+ const redisConfig = this.strapi.plugin("strapi-cache").config("redisConfig") || "redis://localhost:6379";
840
+ const redisClusterNodes = this.strapi.plugin("strapi-cache").config("redisClusterNodes");
841
+ this.cacheGetTimeoutInMs = Number(
842
+ this.strapi.plugin("strapi-cache").config("cacheGetTimeoutInMs")
843
+ );
844
+ this.keyPrefix = this.strapi.plugin("strapi-cache").config("redisConfig")?.["keyPrefix"] ?? "";
845
+ if (redisClusterNodes.length) {
846
+ const redisClusterOptions = this.strapi.plugin("strapi-cache").config("redisClusterOptions");
847
+ if (!redisClusterOptions["redisOptions"]) {
848
+ redisClusterOptions.redisOptions = redisConfig;
849
+ }
850
+ this.client = new Redis.Cluster(redisClusterNodes, redisClusterOptions);
851
+ } else {
852
+ this.client = new Redis(redisConfig);
853
+ }
854
+ this.initialized = true;
855
+ loggy.info("Redis provider initialized");
856
+ } catch (error) {
857
+ loggy.error(error);
858
+ }
859
+ }
860
+ get ready() {
861
+ if (!this.initialized) {
862
+ loggy.info("Redis provider not initialized");
863
+ return false;
864
+ }
865
+ return true;
866
+ }
867
+ async get(key) {
868
+ if (!this.ready) return null;
869
+ return withTimeout(() => this.client.get(key), this.cacheGetTimeoutInMs).then((data) => data ? JSON.parse(data) : null).catch((error) => {
870
+ loggy.error(`Redis get error: ${error?.message || error}`);
871
+ return null;
872
+ });
873
+ }
874
+ async set(key, val) {
875
+ if (!this.ready) return null;
876
+ try {
877
+ const ttlInMs = Number(this.strapi.plugin("strapi-cache").config("ttl"));
878
+ const ttlInS = Number((ttlInMs / 1e3).toFixed());
879
+ const serialized = JSON.stringify(val);
880
+ if (ttlInS > 0) {
881
+ await this.client.set(key, serialized, "EX", ttlInS);
882
+ } else {
883
+ await this.client.set(key, serialized);
884
+ }
885
+ return val;
886
+ } catch (error) {
887
+ loggy.error(`Redis set error: ${error}`);
888
+ return null;
889
+ }
890
+ }
891
+ async del(key) {
892
+ if (!this.ready) return null;
893
+ try {
894
+ const relativeKey = key.slice(this.keyPrefix.length);
895
+ loggy.info(`Redis PURGING KEY: ${relativeKey}`);
896
+ await this.client.del(relativeKey);
897
+ return true;
898
+ } catch (error) {
899
+ loggy.error(`Redis del error: ${error}`);
900
+ return null;
901
+ }
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
+ }
915
+ async keys() {
916
+ if (!this.ready) return null;
917
+ try {
918
+ const keys = await this.client.keys(`${this.keyPrefix}*`);
919
+ return keys;
920
+ } catch (error) {
921
+ loggy.error(`Redis keys error: ${error}`);
922
+ return null;
923
+ }
924
+ }
925
+ async reset() {
926
+ if (!this.ready) return null;
927
+ try {
928
+ if (this.keyPrefix) {
929
+ loggy.info(`Redis FLUSHING NAMESPACE: ${this.keyPrefix}`);
930
+ const keys = await this.keys();
931
+ if (!keys) return null;
932
+ const toDelete = keys.filter((key) => key.startsWith(this.keyPrefix));
933
+ await Promise.all(toDelete.map((key) => this.del(key)));
934
+ return true;
935
+ }
936
+ loggy.info(`Redis FLUSHING ALL KEYS`);
937
+ await this.client.flushdb();
938
+ return true;
939
+ } catch (error) {
940
+ loggy.error(`Redis reset error: ${error}`);
941
+ return null;
942
+ }
943
+ }
944
+ async clearByRegexp(regExps) {
945
+ const keys = await this.keys();
946
+ if (!keys) {
947
+ return;
948
+ }
949
+ const toDelete = keys.filter((key) => regExps.some((re) => re.test(key)));
950
+ await this.delAll(toDelete);
951
+ }
952
+ }
953
+ const resolveCacheProvider = (strapi2) => {
954
+ const providerType = strapi2.plugin("strapi-cache").config("provider") || "memory";
955
+ loggy.info(`Selected cache provider: ${providerType}`);
956
+ let instance;
957
+ switch (providerType) {
958
+ case "redis":
959
+ instance = new RedisCacheProvider(strapi2);
960
+ break;
961
+ default:
962
+ instance = new InMemoryCacheProvider(strapi2);
963
+ break;
964
+ }
965
+ return instance;
966
+ };
967
+ const service = ({ strapi: strapi2 }) => {
968
+ let instance = null;
969
+ return {
970
+ getCacheInstance() {
971
+ if (!instance) {
972
+ loggy.info("Initializing cache service from provider config...");
973
+ instance = resolveCacheProvider(strapi2);
974
+ }
975
+ return instance;
976
+ }
977
+ };
978
+ };
979
+ const services = {
980
+ service
981
+ };
982
+ const index = {
983
+ register,
984
+ bootstrap,
985
+ destroy() {
986
+ },
987
+ config,
988
+ controllers,
989
+ routes,
990
+ services,
991
+ contentTypes,
992
+ policies,
993
+ middlewares
994
+ };
995
+ export {
996
+ index as default
997
+ };