strapi-cache 1.8.8 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,7 @@ Full configuration example:
72
72
  size: 1024 * 1024 * 1024, // Maximum size of the cache (1 GB) (only for memory cache)
73
73
  allowStale: false, // Allow stale cache items (only for memory cache)
74
74
  cacheableRoutes: ['/api/products', '/api/categories'], // Caches routes which start with these paths (if empty array, all '/api' routes are cached)
75
+ keyGenerator: (ctx) => `${ctx.request.method}:${ctx.request.url}`, // Optional custom cache key for REST requests; receives koa ctx
75
76
  // 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
76
77
  excludeRoutes: ['/api/products/private'], // Exclude routes which start with these paths from being cached (takes precedence over cacheableRoutes). **Note:** `excludeRoutes` takes precedence over `cacheableRoutes`.
77
78
  provider: 'memory', // Cache provider ('memory', 'redis' or 'valkey')
@@ -109,6 +110,7 @@ Possible configuration keys are listed below; omitted keys keep the plugin defau
109
110
  | `size` | Approximate max total size in bytes (in-memory provider only) | Positive integer (default: `10485760`, i.e. 10 MB) |
110
111
  | `allowStale` | Whether stale entries may be returned (in-memory provider only) | `true` or `false` (default: `false`) |
111
112
  | `cacheableRoutes` | Only URLs starting with one of these paths are cached; if empty, every URL under the REST API prefix matches | Array of path prefix strings (default: `[]` meaning “all API routes”) |
113
+ | `keyGenerator` | Custom function to build REST cache keys; receives Koa `ctx`; when omitted, default key is `${method}:${url}` | Function `(ctx) => string` (default: unset) |
112
114
  | `cacheableEntities` | If non-empty, only these API “entity” segments are cached; **when set, this drives eligibility instead of** `cacheableRoutes` | Array of strings (e.g. collection/table names), or omit / leave empty to use `cacheableRoutes` |
113
115
  | `excludeRoutes` | URLs starting with any of these prefixes are **never** cached; evaluated before `cacheableRoutes` / entities | Array of path prefix strings (default: `[]`) |
114
116
  | `cacheHeaders` | Store and replay response headers with the body | `true` or `false` (default: `true`) |
@@ -139,7 +141,7 @@ All of these routes are protected by the policies `admin::isAuthenticatedAdmin`
139
141
  - **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.
140
142
  - **Automatic Invalidation**: When `autoPurgeCache` is enabled (default), relevant REST cache entries are invalidated on content create, update, or delete. When `autoPurgeGraphQL` is enabled, GraphQL cache is invalidated the same way (it is off unless you set it in config).
141
143
  - **`no-cache` Header Support**: Respects the `no-cache` header, letting you skip the cache by setting `Cache-Control: no-cache` in your request.
142
- - **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.
144
+ - **Default Cached Requests**: By default, caches all GET requests to `/api` (or whatever prefix you defined) and POST requests to `/graphql` or predefined graphql route from graphql plugin config. You can customize which routes or entities to cache using `cacheableRoutes` or `cacheableEntities` config options.
143
145
 
144
146
  ## 🔮 Planned Features
145
147
 
@@ -63,7 +63,7 @@ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
63
63
  const contentType = strapi2.contentType(model.uid);
64
64
  if (!contentType || !contentType.info) {
65
65
  loggy.info(`Content type ${model.uid} not found, purging all GraphQL cache`);
66
- const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
66
+ const graphqlRegex2 = new RegExp(`^(GET|POST):${strapi2.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:.*`);
67
67
  await cacheStore.clearByRegexp([graphqlRegex2]);
68
68
  return;
69
69
  }
@@ -72,12 +72,12 @@ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
72
72
  const fieldNames = [...new Set([singularName, pluralName].filter(Boolean))];
73
73
  if (fieldNames.length === 0) {
74
74
  loggy.info(`No field names for ${model.uid}, purging all GraphQL cache`);
75
- const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
75
+ const graphqlRegex2 = new RegExp(`^(GET|POST):${strapi2.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:.*`);
76
76
  await cacheStore.clearByRegexp([graphqlRegex2]);
77
77
  return;
78
78
  }
79
79
  const escapedNames = fieldNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
80
- const graphqlRegex = new RegExp(`^(GET|POST):/graphql:[^:]*\\b(${escapedNames})\\b[^:]*:`);
80
+ const graphqlRegex = new RegExp(`^(GET|POST):${strapi2.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:[^:]*\\b(${escapedNames})\\b[^:]*:`);
81
81
  await cacheStore.clearByRegexp([graphqlRegex]);
82
82
  loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
83
83
  } catch (error) {
@@ -152,15 +152,21 @@ const bootstrap = ({ strapi: strapi2 }) => {
152
152
  loggy.info("Plugin initialized");
153
153
  strapi2.admin.services.permission.actionProvider.registerMany(actions);
154
154
  };
155
- const generateCacheKey = (context) => {
155
+ const generateCacheKey = (context, keyGenerator) => {
156
+ if (typeof keyGenerator === "function") {
157
+ const customKey = keyGenerator(context);
158
+ if (typeof customKey === "string") {
159
+ return customKey;
160
+ }
161
+ }
156
162
  const { url } = context.request;
157
163
  const { method } = context.request;
158
164
  return `${method}:${url}`;
159
165
  };
160
- const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
166
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = [], strapi2) => {
161
167
  const hash = crypto.createHash("sha256").update(payload).digest("base64url");
162
168
  const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
163
- return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
169
+ return `${method}:${strapi2?.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:${rootFieldsSegment}:${hash}`;
164
170
  };
165
171
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
172
  const generateEntityKey = (url, restApiPrefix) => {
@@ -238,10 +244,11 @@ const middleware$1 = async (ctx, next) => {
238
244
  const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
239
245
  const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
240
246
  const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
247
+ const keyGenerator = strapi.plugin("strapi-cache").config("keyGenerator");
241
248
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
242
249
  const cacheStore = cacheService.getCacheInstance();
243
250
  const { url } = ctx.request;
244
- const key = generateCacheKey(ctx);
251
+ const key = generateCacheKey(ctx, keyGenerator);
245
252
  const cacheEntry = await cacheStore.get(key);
246
253
  const cacheControlHeader = ctx.request.headers["cache-control"];
247
254
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
@@ -390,7 +397,7 @@ function getRootFieldsFromQuery(query) {
390
397
  }
391
398
  const middleware = async (ctx, next) => {
392
399
  const { url, method } = ctx.request;
393
- if (!url.startsWith("/graphql")) {
400
+ if (!url.startsWith(strapi.plugin("graphql")?.config("endpoint", "/graphql"))) {
394
401
  await next();
395
402
  return;
396
403
  }
@@ -424,7 +431,7 @@ const middleware = async (ctx, next) => {
424
431
  }
425
432
  const payload = parseGraphqlPayload(body, isGet);
426
433
  const rootFields = getRootFieldsFromQuery(payload.query);
427
- const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields);
434
+ const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields, strapi);
428
435
  loggy.info(
429
436
  `GraphQL request: ${JSON.stringify({
430
437
  operationName: payload.operationName,
@@ -478,7 +485,7 @@ const middleware = async (ctx, next) => {
478
485
  return;
479
486
  }
480
487
  await next();
481
- const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
488
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith(strapi.plugin("graphql")?.config("endpoint", "/graphql"));
482
489
  if (shouldCache) {
483
490
  loggy.info(`MISS with key: ${key}`);
484
491
  const headers = ctx.request.headers;
@@ -533,6 +540,7 @@ const config = {
533
540
  size: 1024 * 1024 * 10,
534
541
  allowStale: false,
535
542
  cacheableRoutes: [],
543
+ keyGenerator: void 0,
536
544
  provider: "memory",
537
545
  excludeRoutes: [],
538
546
  redisConfig: env("REDIS_URL"),
@@ -568,6 +576,9 @@ const config = {
568
576
  if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
569
577
  throw new Error(`Invalid config: cacheableRoutes must be an string array`);
570
578
  }
579
+ if (config2.keyGenerator !== void 0 && typeof config2.keyGenerator !== "function") {
580
+ throw new Error(`Invalid config: keyGenerator must be a function`);
581
+ }
571
582
  if (config2.cacheableEntities !== void 0 && (!Array.isArray(config2.cacheableEntities) || config2.cacheableEntities.some((item) => typeof item !== "string"))) {
572
583
  throw new Error(`Invalid config: cacheableEntities must be a string array`);
573
584
  }
@@ -59,7 +59,7 @@ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
59
59
  const contentType = strapi2.contentType(model.uid);
60
60
  if (!contentType || !contentType.info) {
61
61
  loggy.info(`Content type ${model.uid} not found, purging all GraphQL cache`);
62
- const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
62
+ const graphqlRegex2 = new RegExp(`^(GET|POST):${strapi2.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:.*`);
63
63
  await cacheStore.clearByRegexp([graphqlRegex2]);
64
64
  return;
65
65
  }
@@ -68,12 +68,12 @@ async function invalidateGraphqlCache(event, cacheStore, strapi2) {
68
68
  const fieldNames = [...new Set([singularName, pluralName].filter(Boolean))];
69
69
  if (fieldNames.length === 0) {
70
70
  loggy.info(`No field names for ${model.uid}, purging all GraphQL cache`);
71
- const graphqlRegex2 = new RegExp(`^(GET|POST):/graphql:.*`);
71
+ const graphqlRegex2 = new RegExp(`^(GET|POST):${strapi2.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:.*`);
72
72
  await cacheStore.clearByRegexp([graphqlRegex2]);
73
73
  return;
74
74
  }
75
75
  const escapedNames = fieldNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
76
- const graphqlRegex = new RegExp(`^(GET|POST):/graphql:[^:]*\\b(${escapedNames})\\b[^:]*:`);
76
+ const graphqlRegex = new RegExp(`^(GET|POST):${strapi2.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:[^:]*\\b(${escapedNames})\\b[^:]*:`);
77
77
  await cacheStore.clearByRegexp([graphqlRegex]);
78
78
  loggy.info(`Invalidated GraphQL cache for ${model.uid} (${fieldNames.join(", ")})`);
79
79
  } catch (error) {
@@ -148,15 +148,21 @@ const bootstrap = ({ strapi: strapi2 }) => {
148
148
  loggy.info("Plugin initialized");
149
149
  strapi2.admin.services.permission.actionProvider.registerMany(actions);
150
150
  };
151
- const generateCacheKey = (context) => {
151
+ const generateCacheKey = (context, keyGenerator) => {
152
+ if (typeof keyGenerator === "function") {
153
+ const customKey = keyGenerator(context);
154
+ if (typeof customKey === "string") {
155
+ return customKey;
156
+ }
157
+ }
152
158
  const { url } = context.request;
153
159
  const { method } = context.request;
154
160
  return `${method}:${url}`;
155
161
  };
156
- const generateGraphqlCacheKey = (payload, method = "POST", rootFields = []) => {
162
+ const generateGraphqlCacheKey = (payload, method = "POST", rootFields = [], strapi2) => {
157
163
  const hash = createHash("sha256").update(payload).digest("base64url");
158
164
  const rootFieldsSegment = rootFields.length > 0 ? [...rootFields].sort((a, b) => a.localeCompare(b)).join(",") : "_";
159
- return `${method}:/graphql:${rootFieldsSegment}:${hash}`;
165
+ return `${method}:${strapi2?.plugin("graphql")?.config("endpoint", "/graphql") ?? "/graphql"}:${rootFieldsSegment}:${hash}`;
160
166
  };
161
167
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
162
168
  const generateEntityKey = (url, restApiPrefix) => {
@@ -234,10 +240,11 @@ const middleware$1 = async (ctx, next) => {
234
240
  const cacheableEntities = strapi.plugin("strapi-cache").config("cacheableEntities");
235
241
  const cacheableRoutes = strapi.plugin("strapi-cache").config("cacheableRoutes");
236
242
  const excludeRoutes = strapi.plugin("strapi-cache").config("excludeRoutes");
243
+ const keyGenerator = strapi.plugin("strapi-cache").config("keyGenerator");
237
244
  const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } = getCacheHeaderConfig();
238
245
  const cacheStore = cacheService.getCacheInstance();
239
246
  const { url } = ctx.request;
240
- const key = generateCacheKey(ctx);
247
+ const key = generateCacheKey(ctx, keyGenerator);
241
248
  const cacheEntry = await cacheStore.get(key);
242
249
  const cacheControlHeader = ctx.request.headers["cache-control"];
243
250
  const noCache = cacheControlHeader && cacheControlHeader.includes("no-cache");
@@ -386,7 +393,7 @@ function getRootFieldsFromQuery(query) {
386
393
  }
387
394
  const middleware = async (ctx, next) => {
388
395
  const { url, method } = ctx.request;
389
- if (!url.startsWith("/graphql")) {
396
+ if (!url.startsWith(strapi.plugin("graphql")?.config("endpoint", "/graphql"))) {
390
397
  await next();
391
398
  return;
392
399
  }
@@ -420,7 +427,7 @@ const middleware = async (ctx, next) => {
420
427
  }
421
428
  const payload = parseGraphqlPayload(body, isGet);
422
429
  const rootFields = getRootFieldsFromQuery(payload.query);
423
- const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields);
430
+ const key = generateGraphqlCacheKey(body, isGet ? "GET" : "POST", rootFields, strapi);
424
431
  loggy.info(
425
432
  `GraphQL request: ${JSON.stringify({
426
433
  operationName: payload.operationName,
@@ -474,7 +481,7 @@ const middleware = async (ctx, next) => {
474
481
  return;
475
482
  }
476
483
  await next();
477
- const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith("/graphql");
484
+ const shouldCache = (ctx.method === "POST" || ctx.method === "GET") && ctx.status >= 200 && ctx.status < 300 && url.startsWith(strapi.plugin("graphql")?.config("endpoint", "/graphql"));
478
485
  if (shouldCache) {
479
486
  loggy.info(`MISS with key: ${key}`);
480
487
  const headers = ctx.request.headers;
@@ -529,6 +536,7 @@ const config = {
529
536
  size: 1024 * 1024 * 10,
530
537
  allowStale: false,
531
538
  cacheableRoutes: [],
539
+ keyGenerator: void 0,
532
540
  provider: "memory",
533
541
  excludeRoutes: [],
534
542
  redisConfig: env("REDIS_URL"),
@@ -564,6 +572,9 @@ const config = {
564
572
  if (!Array.isArray(config2.cacheableRoutes) || config2.cacheableRoutes.some((item) => typeof item !== "string")) {
565
573
  throw new Error(`Invalid config: cacheableRoutes must be an string array`);
566
574
  }
575
+ if (config2.keyGenerator !== void 0 && typeof config2.keyGenerator !== "function") {
576
+ throw new Error(`Invalid config: keyGenerator must be a function`);
577
+ }
567
578
  if (config2.cacheableEntities !== void 0 && (!Array.isArray(config2.cacheableEntities) || config2.cacheableEntities.some((item) => typeof item !== "string"))) {
568
579
  throw new Error(`Invalid config: cacheableEntities must be a string array`);
569
580
  }
@@ -8,6 +8,7 @@ declare const _default: {
8
8
  size: number;
9
9
  allowStale: boolean;
10
10
  cacheableRoutes: any[];
11
+ keyGenerator: any;
11
12
  provider: string;
12
13
  excludeRoutes: any[];
13
14
  redisConfig: any;
@@ -17,6 +17,7 @@ declare const _default: {
17
17
  size: number;
18
18
  allowStale: boolean;
19
19
  cacheableRoutes: any[];
20
+ keyGenerator: any;
20
21
  provider: string;
21
22
  excludeRoutes: any[];
22
23
  redisConfig: any;
@@ -1,5 +1,7 @@
1
+ import { Core } from '@strapi/strapi';
1
2
  import { Context } from 'koa';
2
- export declare const generateCacheKey: (context: Context) => string;
3
- export declare const generateGraphqlCacheKey: (payload: string, method?: 'GET' | 'POST', rootFields?: string[]) => string;
3
+ export type CacheKeyGenerator = (context: Context) => string;
4
+ export declare const generateCacheKey: (context: Context, keyGenerator?: CacheKeyGenerator) => string;
5
+ export declare const generateGraphqlCacheKey: (payload: string, method?: 'GET' | 'POST', rootFields?: string[], strapi?: Core.Strapi) => string;
4
6
  export declare const escapeRegExp: (s: string) => string;
5
7
  export declare const generateEntityKey: (url: string, restApiPrefix: string) => string;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.8",
2
+ "version": "1.10.0",
3
3
  "keywords": [
4
4
  "strapi cache",
5
5
  "strapi rest cache",
@@ -35,7 +35,7 @@
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
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",
38
+ "test:integration": "npm run build && rm -rf playground/.cache playground/node_modules playground/.yalc && mkdir -p playground/.yalc/strapi-cache && cp -r dist package.json LICENSE README.md playground/.yalc/strapi-cache/ && node -e \"const fs=require('fs');const p='playground/.yalc/strapi-cache/package.json';const pkg=JSON.parse(fs.readFileSync(p,'utf8'));delete pkg.devDependencies;delete pkg.optionalDependencies;delete pkg.scripts;fs.writeFileSync(p,JSON.stringify(pkg,null,2));\" && cd playground && npm ci --ignore-scripts && npm rebuild better-sqlite3 @swc/core sharp --foreground-scripts && npm run build && cd .. && NODE_ENV=test jest --config jest.integration.config.js --runInBand --forceExit --detectOpenHandles",
39
39
  "test:all": "npm test -- --run && npm run test:integration"
40
40
  },
41
41
  "dependencies": {