@heinhtet37/express-route-cache 0.1.0 → 0.2.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 +230 -66
- package/dist/index.d.ts +93 -9
- package/dist/index.js +557 -25
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,31 +1,138 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @heinhtet37/express-route-cache
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Express route caching helpers for low-level control and practical resource-level DX.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The original API still works:
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
- `createRouteCache`
|
|
8
|
+
- `createRedisStore`
|
|
9
|
+
- `createMemoryStore`
|
|
10
|
+
- `check`
|
|
11
|
+
- `capture`
|
|
12
|
+
- `invalidate`
|
|
13
|
+
|
|
14
|
+
This version adds:
|
|
15
|
+
|
|
16
|
+
- `resource()` bundles for list/detail/variant routes
|
|
17
|
+
- optional cache key `namespace`
|
|
18
|
+
- optional tag-based invalidation
|
|
19
|
+
- deterministic resource keys
|
|
20
|
+
- lightweight TTL parsing like `"5m"` and `"1h"`
|
|
21
|
+
- lightweight lifecycle events
|
|
10
22
|
|
|
11
23
|
## Install
|
|
12
24
|
|
|
13
25
|
```bash
|
|
14
|
-
npm install @
|
|
26
|
+
npm install @heinhtet37/express-route-cache
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createRedisStore, createRouteCache } from "@heinhtet37/express-route-cache";
|
|
33
|
+
import redis from "./redis";
|
|
34
|
+
|
|
35
|
+
const cache = createRouteCache({
|
|
36
|
+
store: createRedisStore({
|
|
37
|
+
client: redis,
|
|
38
|
+
defaultTtlSeconds: "5m",
|
|
39
|
+
}),
|
|
40
|
+
namespace: "mm-smart-pos",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const rolesCache = cache.resource({
|
|
44
|
+
basePath: "/api/v1/roles",
|
|
45
|
+
idParam: "id",
|
|
46
|
+
ttlSeconds: "5m",
|
|
47
|
+
variants: ["names"],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.get("/", ...rolesCache.list(), async (_req, res) => {
|
|
51
|
+
res.json(await roleService.getAll());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
router.get("/names", ...rolesCache.variant("names"), async (_req, res) => {
|
|
55
|
+
res.json(await roleService.getAllNames());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.get("/:id", ...rolesCache.detail(), async (req, res) => {
|
|
59
|
+
res.json(await roleService.getById(req.params.id));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
router.post("/", rolesCache.invalidateOnWrite(), async (req, res) => {
|
|
63
|
+
res.json(await roleService.create(req.body));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
router.patch("/:id", rolesCache.invalidateOnWrite(), async (req, res) => {
|
|
67
|
+
res.json(await roleService.update(req.params.id, req.body));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.delete("/:id", rolesCache.invalidateOnWrite(), async (req, res) => {
|
|
71
|
+
res.json(await roleService.remove(req.params.id));
|
|
72
|
+
});
|
|
15
73
|
```
|
|
16
74
|
|
|
17
|
-
##
|
|
75
|
+
## Resource API
|
|
76
|
+
|
|
77
|
+
`resource()` is the high-level DX API for common REST-style routes.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const usersCache = cache.resource({
|
|
81
|
+
basePath: "/api/users",
|
|
82
|
+
idParam: "id",
|
|
83
|
+
ttlSeconds: 300,
|
|
84
|
+
variants: ["names", "summary"],
|
|
85
|
+
});
|
|
86
|
+
```
|
|
18
87
|
|
|
19
|
-
|
|
20
|
-
- `createRedisStore()` for Redis-backed caching
|
|
21
|
-
- `createMemoryStore()` for local development and tests
|
|
88
|
+
### Read Bundles
|
|
22
89
|
|
|
23
|
-
|
|
90
|
+
`list()` returns `[check, capture]` for the collection route.
|
|
24
91
|
|
|
25
92
|
```ts
|
|
26
|
-
|
|
27
|
-
|
|
93
|
+
router.get("/", ...usersCache.list(), handler);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`detail()` returns `[check, capture]` for `/:id`.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
router.get("/:id", ...usersCache.detail(), handler);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`variant(name)` returns `[check, capture]` for custom read routes like `/names`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
router.get("/names", ...usersCache.variant("names"), handler);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Write Invalidation
|
|
109
|
+
|
|
110
|
+
`invalidateOnWrite()` invalidates the resource collection, query variants, configured variants, and the matching detail route when the write route has an `id` param.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
router.post("/", usersCache.invalidateOnWrite(), handler);
|
|
114
|
+
router.patch("/:id", usersCache.invalidateOnWrite(), handler);
|
|
115
|
+
router.delete("/:id", usersCache.invalidateOnWrite(), handler);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You can extend invalidation with extra patterns or tags:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
router.patch(
|
|
122
|
+
"/:id",
|
|
123
|
+
usersCache.invalidateOnWrite({
|
|
124
|
+
additionalPatterns: ["/api/dashboard*", "/api/summary*"],
|
|
125
|
+
additionalTags: ["dashboard"],
|
|
126
|
+
}),
|
|
127
|
+
handler
|
|
128
|
+
);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Low-Level API
|
|
28
132
|
|
|
133
|
+
The original low-level API remains available for custom routes.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
29
136
|
const cache = createRouteCache({
|
|
30
137
|
store: createRedisStore({
|
|
31
138
|
client: redis,
|
|
@@ -33,13 +140,15 @@ const cache = createRouteCache({
|
|
|
33
140
|
}),
|
|
34
141
|
});
|
|
35
142
|
|
|
36
|
-
|
|
143
|
+
const checkUsersCache = cache.check({
|
|
37
144
|
varyHeaders: ["accept-language"],
|
|
38
145
|
});
|
|
39
146
|
|
|
40
|
-
|
|
147
|
+
const captureUsersCache = cache.capture({
|
|
148
|
+
ttlSeconds: "5m",
|
|
149
|
+
});
|
|
41
150
|
|
|
42
|
-
|
|
151
|
+
const invalidateUsersCache = cache.invalidate({
|
|
43
152
|
patterns: (req) => [
|
|
44
153
|
"/api/users",
|
|
45
154
|
"/api/users?*",
|
|
@@ -48,83 +157,138 @@ export const invalidateUsersCache = cache.invalidate({
|
|
|
48
157
|
});
|
|
49
158
|
```
|
|
50
159
|
|
|
51
|
-
|
|
160
|
+
## Namespace Support
|
|
161
|
+
|
|
162
|
+
`namespace` prefixes internal cache keys and tag identifiers without forcing consumers to rewrite their patterns.
|
|
52
163
|
|
|
53
164
|
```ts
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
async (req, res) => {
|
|
59
|
-
res.json(await userService.getAll());
|
|
60
|
-
}
|
|
61
|
-
);
|
|
165
|
+
const cache = createRouteCache({
|
|
166
|
+
store,
|
|
167
|
+
namespace: "mm-smart-pos",
|
|
168
|
+
});
|
|
62
169
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
170
|
+
await cache.set("/roles", { ok: true }, 300);
|
|
171
|
+
await cache.deleteByPatterns(["/roles"]);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
With a namespace configured, `"/roles"` becomes an internal key like `"mm-smart-pos::/roles"`, but callers can still use the simple public key.
|
|
175
|
+
|
|
176
|
+
## Tag Support
|
|
177
|
+
|
|
178
|
+
Tag invalidation is optional.
|
|
179
|
+
|
|
180
|
+
- `createMemoryStore()` supports tags out of the box.
|
|
181
|
+
- `createRedisStore()` supports tags when the Redis client provides `sadd`, `srem`, `smembers`, and `expire`.
|
|
182
|
+
- Pattern invalidation continues to work either way.
|
|
183
|
+
|
|
184
|
+
Low-level tag example:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const checkUsersCache = cache.check({ key: "/api/users" });
|
|
188
|
+
|
|
189
|
+
const captureUsersCache = cache.capture({
|
|
190
|
+
tags: ["users", "admin-dashboard"],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const invalidateUsersByTag = cache.invalidate({
|
|
194
|
+
tags: ["users"],
|
|
195
|
+
});
|
|
70
196
|
```
|
|
71
197
|
|
|
72
|
-
|
|
198
|
+
Resource bundles automatically attach resource-level tags, and `invalidateOnWrite()` uses both patterns and tags when available.
|
|
73
199
|
|
|
74
|
-
|
|
200
|
+
## TTL Values
|
|
75
201
|
|
|
76
|
-
|
|
202
|
+
TTL values can be numbers or simple strings:
|
|
77
203
|
|
|
78
|
-
- `
|
|
79
|
-
- `
|
|
80
|
-
- `
|
|
81
|
-
- `
|
|
82
|
-
- `
|
|
83
|
-
- `deleteByPatterns(patterns)`
|
|
204
|
+
- `300`
|
|
205
|
+
- `"300"`
|
|
206
|
+
- `"5m"`
|
|
207
|
+
- `"1h"`
|
|
208
|
+
- `"1d"`
|
|
84
209
|
|
|
85
|
-
|
|
210
|
+
This works with:
|
|
86
211
|
|
|
87
|
-
|
|
212
|
+
- `defaultTtlSeconds`
|
|
213
|
+
- `capture({ ttlSeconds })`
|
|
214
|
+
- `resource({ ttlSeconds })`
|
|
215
|
+
- `set(key, value, ttl)`
|
|
88
216
|
|
|
89
|
-
|
|
90
|
-
- `varyHeaders`: append selected request headers into the cache key
|
|
91
|
-
- `shouldBypass`: skip lookup for selected requests
|
|
92
|
-
- `hitHeader`: response header name for cache hit or miss, defaults to `x-cache`
|
|
217
|
+
## Events
|
|
93
218
|
|
|
94
|
-
|
|
219
|
+
You can subscribe to lightweight lifecycle events:
|
|
95
220
|
|
|
96
|
-
|
|
221
|
+
- `hit`
|
|
222
|
+
- `miss`
|
|
223
|
+
- `set`
|
|
224
|
+
- `invalidate`
|
|
225
|
+
- `error`
|
|
97
226
|
|
|
98
|
-
|
|
99
|
-
|
|
227
|
+
```ts
|
|
228
|
+
const cache = createRouteCache({
|
|
229
|
+
store,
|
|
230
|
+
events: {
|
|
231
|
+
hit: ({ key }) => console.log("cache hit", key),
|
|
232
|
+
miss: ({ key }) => console.log("cache miss", key),
|
|
233
|
+
set: ({ key, ttlSeconds }) => console.log("cache set", key, ttlSeconds),
|
|
234
|
+
invalidate: ({ patterns, tags }) => console.log("cache invalidate", patterns, tags),
|
|
235
|
+
error: ({ operation, error }) => console.error("cache error", operation, error),
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
```
|
|
100
239
|
|
|
101
|
-
|
|
240
|
+
## Redis Store
|
|
102
241
|
|
|
103
|
-
|
|
242
|
+
```ts
|
|
243
|
+
import Redis from "ioredis";
|
|
244
|
+
import { createRedisStore } from "@heinhtet37/express-route-cache";
|
|
104
245
|
|
|
105
|
-
|
|
106
|
-
- `shouldInvalidate`: predicate to control invalidation
|
|
107
|
-
- `onInvalidate`: async hook for audit logging or side effects
|
|
246
|
+
const client = new Redis();
|
|
108
247
|
|
|
109
|
-
|
|
248
|
+
const store = createRedisStore({
|
|
249
|
+
client,
|
|
250
|
+
defaultTtlSeconds: "5m",
|
|
251
|
+
});
|
|
252
|
+
```
|
|
110
253
|
|
|
111
|
-
|
|
254
|
+
For pattern invalidation, the Redis client must provide:
|
|
112
255
|
|
|
113
256
|
- `get`
|
|
114
257
|
- `del`
|
|
115
258
|
- one of `scan` or `keys`
|
|
116
259
|
- one of `setex` or `set(..., "EX", ttl)`
|
|
117
260
|
|
|
118
|
-
|
|
261
|
+
For tag invalidation, the Redis client should also provide:
|
|
262
|
+
|
|
263
|
+
- `sadd`
|
|
264
|
+
- `srem`
|
|
265
|
+
- `smembers`
|
|
266
|
+
- `expire`
|
|
267
|
+
|
|
268
|
+
## Memory Store
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { createMemoryStore } from "@heinhtet37/express-route-cache";
|
|
272
|
+
|
|
273
|
+
const store = createMemoryStore({
|
|
274
|
+
defaultTtlSeconds: "1m",
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The memory store is useful for local development and tests. It also supports tag invalidation.
|
|
279
|
+
|
|
280
|
+
## Notes And Tradeoffs
|
|
119
281
|
|
|
120
|
-
|
|
282
|
+
- The old API is still supported and unchanged for normal usage.
|
|
283
|
+
- The new `resource()` API is intentionally opinionated around list/detail/variant patterns.
|
|
284
|
+
- Resource invalidation automatically covers configured variants only. If you use extra variant names, either include them in `variants` or add `additionalPatterns`.
|
|
285
|
+
- Tag invalidation is additive. It does not replace pattern invalidation.
|
|
286
|
+
- Redis tag invalidation uses extra metadata keys to track tag membership. This keeps the public API simple at the cost of slightly more write work per cached entry.
|
|
121
287
|
|
|
122
|
-
##
|
|
288
|
+
## Tests
|
|
123
289
|
|
|
124
290
|
```bash
|
|
125
|
-
|
|
126
|
-
npm install
|
|
127
|
-
npm publish
|
|
291
|
+
npm test
|
|
128
292
|
```
|
|
129
293
|
|
|
130
|
-
|
|
294
|
+
That runs a package build and then the Node test suite in `tests/`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Request, RequestHandler, Response } from "express";
|
|
2
|
+
export type TtlValue = number | string;
|
|
2
3
|
export interface CacheStore {
|
|
3
4
|
get<T>(key: string): Promise<T | null>;
|
|
4
5
|
set<T>(key: string, value: T, options?: {
|
|
@@ -6,11 +7,48 @@ export interface CacheStore {
|
|
|
6
7
|
}): Promise<void>;
|
|
7
8
|
deleteByPatterns(patterns: string[]): Promise<number | void>;
|
|
8
9
|
}
|
|
10
|
+
export interface TaggableCacheStore extends CacheStore {
|
|
11
|
+
addTags?(key: string, tags: string[], options?: {
|
|
12
|
+
ttlSeconds?: number;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
deleteByTags?(tags: string[]): Promise<number | void>;
|
|
15
|
+
}
|
|
9
16
|
export interface CacheLogger {
|
|
10
17
|
error?(message: string, meta?: unknown): void;
|
|
11
18
|
warn?(message: string, meta?: unknown): void;
|
|
12
19
|
info?(message: string, meta?: unknown): void;
|
|
13
20
|
}
|
|
21
|
+
export interface CacheEvents {
|
|
22
|
+
hit?(event: {
|
|
23
|
+
key: string;
|
|
24
|
+
req: Request;
|
|
25
|
+
}): void;
|
|
26
|
+
miss?(event: {
|
|
27
|
+
key: string;
|
|
28
|
+
req: Request;
|
|
29
|
+
}): void;
|
|
30
|
+
set?(event: {
|
|
31
|
+
key: string;
|
|
32
|
+
ttlSeconds?: number;
|
|
33
|
+
req: Request;
|
|
34
|
+
body: unknown;
|
|
35
|
+
tags?: string[];
|
|
36
|
+
}): void;
|
|
37
|
+
invalidate?(event: {
|
|
38
|
+
req?: Request;
|
|
39
|
+
patterns?: string[];
|
|
40
|
+
tags?: string[];
|
|
41
|
+
deletedCount?: number;
|
|
42
|
+
}): void;
|
|
43
|
+
error?(event: {
|
|
44
|
+
req?: Request;
|
|
45
|
+
key?: string;
|
|
46
|
+
patterns?: string[];
|
|
47
|
+
tags?: string[];
|
|
48
|
+
operation: "get" | "set" | "invalidate" | "hook" | "tag";
|
|
49
|
+
error: unknown;
|
|
50
|
+
}): void;
|
|
51
|
+
}
|
|
14
52
|
type ValueResolver<T> = T | ((req: Request, res: Response, body?: unknown) => T);
|
|
15
53
|
type MaybePromise<T> = T | Promise<T>;
|
|
16
54
|
export interface CacheCheckOptions {
|
|
@@ -20,26 +58,68 @@ export interface CacheCheckOptions {
|
|
|
20
58
|
hitHeader?: string | false;
|
|
21
59
|
}
|
|
22
60
|
export interface CacheCaptureOptions {
|
|
23
|
-
ttlSeconds?: ValueResolver<
|
|
61
|
+
ttlSeconds?: ValueResolver<TtlValue | undefined>;
|
|
24
62
|
shouldCache?: (req: Request, res: Response, body: unknown) => boolean;
|
|
63
|
+
tags?: ValueResolver<string[] | undefined>;
|
|
25
64
|
}
|
|
26
65
|
export interface CacheInvalidateOptions {
|
|
27
|
-
patterns
|
|
66
|
+
patterns?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
|
|
67
|
+
tags?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
|
|
68
|
+
shouldInvalidate?: (req: Request, res: Response, body: unknown) => boolean;
|
|
69
|
+
onInvalidate?: (req: Request, res: Response, body: unknown, details: {
|
|
70
|
+
patterns: string[];
|
|
71
|
+
tags: string[];
|
|
72
|
+
}) => MaybePromise<void>;
|
|
73
|
+
}
|
|
74
|
+
export interface ResourceReadOptions {
|
|
75
|
+
ttlSeconds?: TtlValue;
|
|
76
|
+
varyHeaders?: string[];
|
|
77
|
+
shouldBypass?: (req: Request) => boolean;
|
|
78
|
+
hitHeader?: string | false;
|
|
79
|
+
shouldCache?: (req: Request, res: Response, body: unknown) => boolean;
|
|
80
|
+
tags?: ValueResolver<string[] | undefined>;
|
|
81
|
+
}
|
|
82
|
+
export interface ResourceInvalidateOptions {
|
|
83
|
+
additionalPatterns?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
|
|
84
|
+
additionalTags?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
|
|
28
85
|
shouldInvalidate?: (req: Request, res: Response, body: unknown) => boolean;
|
|
29
|
-
onInvalidate?: (req: Request, res: Response, body: unknown,
|
|
86
|
+
onInvalidate?: (req: Request, res: Response, body: unknown, details: {
|
|
87
|
+
patterns: string[];
|
|
88
|
+
tags: string[];
|
|
89
|
+
}) => MaybePromise<void>;
|
|
90
|
+
}
|
|
91
|
+
export interface RouteCacheResourceOptions {
|
|
92
|
+
basePath: string;
|
|
93
|
+
idParam?: string;
|
|
94
|
+
ttlSeconds?: TtlValue;
|
|
95
|
+
variants?: string[];
|
|
96
|
+
varyHeaders?: string[];
|
|
97
|
+
hitHeader?: string | false;
|
|
98
|
+
shouldBypass?: (req: Request) => boolean;
|
|
99
|
+
shouldCache?: (req: Request, res: Response, body: unknown) => boolean;
|
|
100
|
+
}
|
|
101
|
+
export interface RouteCacheResource {
|
|
102
|
+
list(options?: ResourceReadOptions): [RequestHandler, RequestHandler];
|
|
103
|
+
detail(options?: ResourceReadOptions): [RequestHandler, RequestHandler];
|
|
104
|
+
variant(name: string, options?: ResourceReadOptions): [RequestHandler, RequestHandler];
|
|
105
|
+
invalidateOnWrite(options?: ResourceInvalidateOptions): RequestHandler;
|
|
30
106
|
}
|
|
31
107
|
export interface CreateRouteCacheOptions {
|
|
32
108
|
store: CacheStore;
|
|
33
109
|
logger?: CacheLogger;
|
|
34
|
-
defaultTtlSeconds?:
|
|
110
|
+
defaultTtlSeconds?: TtlValue;
|
|
111
|
+
namespace?: string;
|
|
112
|
+
events?: CacheEvents;
|
|
35
113
|
}
|
|
36
114
|
export declare function createRouteCache(options: CreateRouteCacheOptions): {
|
|
37
115
|
check(checkOptions?: CacheCheckOptions): RequestHandler;
|
|
38
116
|
capture(captureOptions?: CacheCaptureOptions): RequestHandler;
|
|
39
117
|
invalidate(invalidateOptions: CacheInvalidateOptions): RequestHandler;
|
|
118
|
+
resource(resourceOptions: RouteCacheResourceOptions): RouteCacheResource;
|
|
40
119
|
get<T>(key: string): Promise<T | null>;
|
|
41
|
-
set<T>(key: string, value: T, ttlSeconds?:
|
|
120
|
+
set<T>(key: string, value: T, ttlSeconds?: TtlValue | undefined): Promise<void>;
|
|
42
121
|
deleteByPatterns(patterns: string[]): Promise<void>;
|
|
122
|
+
deleteByTags(tags: string[]): Promise<void>;
|
|
43
123
|
};
|
|
44
124
|
export interface RedisLikeClient {
|
|
45
125
|
get(key: string): Promise<string | null>;
|
|
@@ -48,18 +128,22 @@ export interface RedisLikeClient {
|
|
|
48
128
|
keys?(pattern: string): Promise<string[]>;
|
|
49
129
|
scan?(cursor: string, tokenOne: "MATCH", pattern: string, tokenTwo: "COUNT", count: number): Promise<[string, string[]]>;
|
|
50
130
|
del(...keys: string[]): Promise<number>;
|
|
131
|
+
sadd?(key: string, ...members: string[]): Promise<number>;
|
|
132
|
+
srem?(key: string, ...members: string[]): Promise<number>;
|
|
133
|
+
smembers?(key: string): Promise<string[]>;
|
|
134
|
+
expire?(key: string, seconds: number): Promise<number>;
|
|
51
135
|
}
|
|
52
136
|
export interface CreateRedisStoreOptions {
|
|
53
137
|
client: RedisLikeClient;
|
|
54
|
-
defaultTtlSeconds?:
|
|
138
|
+
defaultTtlSeconds?: TtlValue;
|
|
55
139
|
logger?: CacheLogger;
|
|
56
140
|
scanCount?: number;
|
|
57
141
|
deserialize?: <T>(value: string) => T;
|
|
58
142
|
serialize?: (value: unknown) => string;
|
|
59
143
|
}
|
|
60
|
-
export declare function createRedisStore(options: CreateRedisStoreOptions):
|
|
144
|
+
export declare function createRedisStore(options: CreateRedisStoreOptions): TaggableCacheStore;
|
|
61
145
|
export interface CreateMemoryStoreOptions {
|
|
62
|
-
defaultTtlSeconds?:
|
|
146
|
+
defaultTtlSeconds?: TtlValue;
|
|
63
147
|
}
|
|
64
|
-
export declare function createMemoryStore(options?: CreateMemoryStoreOptions):
|
|
148
|
+
export declare function createMemoryStore(options?: CreateMemoryStoreOptions): TaggableCacheStore;
|
|
65
149
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ exports.createRouteCache = createRouteCache;
|
|
|
4
4
|
exports.createRedisStore = createRedisStore;
|
|
5
5
|
exports.createMemoryStore = createMemoryStore;
|
|
6
6
|
const ROUTE_CACHE_CONTEXT_KEY = "__routeCache";
|
|
7
|
+
const REDIS_TAG_PREFIX = "__route_cache_tag__:";
|
|
8
|
+
const REDIS_KEY_TAG_PREFIX = "__route_cache_key_tags__:";
|
|
7
9
|
function getCacheContext(res) {
|
|
8
10
|
return res.locals?.[ROUTE_CACHE_CONTEXT_KEY];
|
|
9
11
|
}
|
|
@@ -22,6 +24,55 @@ function escapeForRegex(input) {
|
|
|
22
24
|
function wildcardToRegExp(pattern) {
|
|
23
25
|
return new RegExp("^" + pattern.split("*").map(escapeForRegex).join(".*") + "$");
|
|
24
26
|
}
|
|
27
|
+
function normalizePath(input) {
|
|
28
|
+
if (!input) {
|
|
29
|
+
return "/";
|
|
30
|
+
}
|
|
31
|
+
if (input === "/") {
|
|
32
|
+
return "/";
|
|
33
|
+
}
|
|
34
|
+
return input.endsWith("/") ? input.slice(0, -1) : input;
|
|
35
|
+
}
|
|
36
|
+
function stableSerialize(value) {
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
|
|
39
|
+
}
|
|
40
|
+
if (value && typeof value === "object") {
|
|
41
|
+
const entries = Object.entries(value)
|
|
42
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
43
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
44
|
+
return `{${entries
|
|
45
|
+
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`)
|
|
46
|
+
.join(",")}}`;
|
|
47
|
+
}
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
}
|
|
50
|
+
function buildDeterministicQuerySuffix(query) {
|
|
51
|
+
if (!query ||
|
|
52
|
+
typeof query !== "object" ||
|
|
53
|
+
Array.isArray(query) ||
|
|
54
|
+
Object.keys(query).length === 0) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
return `?${stableSerialize(query)}`;
|
|
58
|
+
}
|
|
59
|
+
function buildHeaderSuffix(req, varyHeaders = []) {
|
|
60
|
+
if (varyHeaders.length === 0) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
const serializedHeaders = varyHeaders
|
|
64
|
+
.map((headerName) => headerName.toLowerCase())
|
|
65
|
+
.sort((left, right) => left.localeCompare(right))
|
|
66
|
+
.map((headerName) => [
|
|
67
|
+
headerName,
|
|
68
|
+
normalizeHeaderValue(req.headers[headerName]),
|
|
69
|
+
])
|
|
70
|
+
.filter(([, value]) => value !== "");
|
|
71
|
+
if (serializedHeaders.length === 0) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
return `#headers=${stableSerialize(Object.fromEntries(serializedHeaders))}`;
|
|
75
|
+
}
|
|
25
76
|
function buildCacheKey(req, varyHeaders = []) {
|
|
26
77
|
const suffix = varyHeaders
|
|
27
78
|
.map((headerName) => {
|
|
@@ -37,32 +88,260 @@ function buildCacheKey(req, varyHeaders = []) {
|
|
|
37
88
|
const separator = req.originalUrl.includes("?") ? "&" : "?";
|
|
38
89
|
return `${req.originalUrl}${separator}${suffix}`;
|
|
39
90
|
}
|
|
91
|
+
function buildResourceKey(kind, req, options) {
|
|
92
|
+
const { basePath, detailId, variantName, varyHeaders = [] } = options;
|
|
93
|
+
let routePath = normalizePath(basePath);
|
|
94
|
+
let routeLabel = `resource:${kind}`;
|
|
95
|
+
if (kind === "detail") {
|
|
96
|
+
routePath = `${routePath}/${detailId ?? ""}`;
|
|
97
|
+
}
|
|
98
|
+
if (kind === "variant") {
|
|
99
|
+
routePath = `${routePath}/${variantName ?? ""}`;
|
|
100
|
+
routeLabel = `${routeLabel}:${variantName ?? "unknown"}`;
|
|
101
|
+
}
|
|
102
|
+
return `${routeLabel}:${routePath}${buildDeterministicQuerySuffix(req.query)}${buildHeaderSuffix(req, varyHeaders)}`;
|
|
103
|
+
}
|
|
104
|
+
function parseTtl(ttlSeconds) {
|
|
105
|
+
if (ttlSeconds === undefined) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
if (typeof ttlSeconds === "number") {
|
|
109
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds < 0) {
|
|
110
|
+
throw new Error("TTL must be a finite non-negative number.");
|
|
111
|
+
}
|
|
112
|
+
return ttlSeconds;
|
|
113
|
+
}
|
|
114
|
+
const normalized = ttlSeconds.trim().toLowerCase();
|
|
115
|
+
if (/^\d+$/.test(normalized)) {
|
|
116
|
+
return Number(normalized);
|
|
117
|
+
}
|
|
118
|
+
const matched = normalized.match(/^(\d+)(s|m|h|d)$/);
|
|
119
|
+
if (!matched) {
|
|
120
|
+
throw new Error(`Invalid TTL value "${ttlSeconds}". Use numbers or strings like "5m" and "1h".`);
|
|
121
|
+
}
|
|
122
|
+
const amount = Number(matched[1]);
|
|
123
|
+
const unit = matched[2];
|
|
124
|
+
switch (unit) {
|
|
125
|
+
case "s":
|
|
126
|
+
return amount;
|
|
127
|
+
case "m":
|
|
128
|
+
return amount * 60;
|
|
129
|
+
case "h":
|
|
130
|
+
return amount * 60 * 60;
|
|
131
|
+
case "d":
|
|
132
|
+
return amount * 60 * 60 * 24;
|
|
133
|
+
default:
|
|
134
|
+
return amount;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
40
137
|
function isSuccessfulStatus(statusCode) {
|
|
41
138
|
return statusCode >= 200 && statusCode < 300;
|
|
42
139
|
}
|
|
140
|
+
function getRouteParamValue(value) {
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
return value[0] ?? "";
|
|
143
|
+
}
|
|
144
|
+
return value ?? "";
|
|
145
|
+
}
|
|
146
|
+
function normalizeNamespace(namespace) {
|
|
147
|
+
return namespace?.trim() ? namespace.trim() : undefined;
|
|
148
|
+
}
|
|
149
|
+
function hasNamespacePrefix(value, namespace) {
|
|
150
|
+
return Boolean(namespace) && value.startsWith(`${namespace}::`);
|
|
151
|
+
}
|
|
152
|
+
function applyNamespace(value, namespace) {
|
|
153
|
+
if (!namespace || hasNamespacePrefix(value, namespace)) {
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
return `${namespace}::${value}`;
|
|
157
|
+
}
|
|
158
|
+
function normalizePatterns(patterns, namespace) {
|
|
159
|
+
return patterns.map((pattern) => applyNamespace(pattern, namespace));
|
|
160
|
+
}
|
|
161
|
+
function normalizeTags(tags, namespace) {
|
|
162
|
+
return tags.map((tag) => applyNamespace(`tag:${tag}`, namespace));
|
|
163
|
+
}
|
|
164
|
+
function uniqueValues(values) {
|
|
165
|
+
return [...new Set(values.filter((value) => value.length > 0))];
|
|
166
|
+
}
|
|
167
|
+
function safeEmitError(events, logger, event) {
|
|
168
|
+
try {
|
|
169
|
+
events?.error?.(event);
|
|
170
|
+
}
|
|
171
|
+
catch (hookError) {
|
|
172
|
+
logger?.error?.("Cache event hook failed", hookError);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function safeEmit(emitter, logger, eventName, payload, events) {
|
|
176
|
+
if (!emitter) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
emitter(payload);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger?.error?.(`Cache ${eventName} hook failed`, error);
|
|
184
|
+
safeEmitError(events, logger, {
|
|
185
|
+
operation: "hook",
|
|
186
|
+
error,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
43
190
|
async function deletePatterns(store, patterns, logger) {
|
|
44
191
|
if (patterns.length === 0) {
|
|
45
|
-
return;
|
|
192
|
+
return 0;
|
|
46
193
|
}
|
|
47
194
|
try {
|
|
48
|
-
await store.deleteByPatterns(patterns);
|
|
195
|
+
const deletedCount = await store.deleteByPatterns(patterns);
|
|
196
|
+
return deletedCount ?? 0;
|
|
49
197
|
}
|
|
50
198
|
catch (error) {
|
|
51
199
|
logger?.error?.("Failed to invalidate cache patterns", error);
|
|
200
|
+
throw error;
|
|
52
201
|
}
|
|
53
202
|
}
|
|
54
|
-
function
|
|
55
|
-
const
|
|
203
|
+
async function deleteTags(store, tags, logger) {
|
|
204
|
+
const tagStore = store;
|
|
205
|
+
if (tags.length === 0) {
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
if (!tagStore.deleteByTags) {
|
|
209
|
+
logger?.warn?.("Cache store does not support tag invalidation. Falling back to pattern invalidation only.");
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const deletedCount = await tagStore.deleteByTags(tags);
|
|
214
|
+
return deletedCount ?? 0;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
logger?.error?.("Failed to invalidate cache tags", error);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function deleteCacheEntries(store, options, logger) {
|
|
222
|
+
const patterns = uniqueValues(options.patterns ?? []);
|
|
223
|
+
const tags = uniqueValues(options.tags ?? []);
|
|
224
|
+
const patternDeletes = await deletePatterns(store, patterns, logger);
|
|
225
|
+
const tagDeletes = await deleteTags(store, tags, logger);
|
|
56
226
|
return {
|
|
227
|
+
deletedCount: patternDeletes + tagDeletes,
|
|
228
|
+
patterns,
|
|
229
|
+
tags,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function buildResourceBaseTag(basePath) {
|
|
233
|
+
return `resource:${normalizePath(basePath)}`;
|
|
234
|
+
}
|
|
235
|
+
function resolveResourceTags(basePath, kind, input) {
|
|
236
|
+
const resourceBaseTag = buildResourceBaseTag(basePath);
|
|
237
|
+
if (kind === "list") {
|
|
238
|
+
return [resourceBaseTag, `${resourceBaseTag}:list`];
|
|
239
|
+
}
|
|
240
|
+
if (kind === "detail") {
|
|
241
|
+
return [
|
|
242
|
+
resourceBaseTag,
|
|
243
|
+
`${resourceBaseTag}:detail:${input.detailId ?? ""}`,
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
return [
|
|
247
|
+
resourceBaseTag,
|
|
248
|
+
`${resourceBaseTag}:variant:${input.variantName ?? "unknown"}`,
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
function resolveMaybeValue(value, req, res, body) {
|
|
252
|
+
return typeof value === "function"
|
|
253
|
+
? value(req, res, body)
|
|
254
|
+
: value;
|
|
255
|
+
}
|
|
256
|
+
function buildResourceInvalidationPatterns(options, req) {
|
|
257
|
+
const normalizedBasePath = normalizePath(options.basePath);
|
|
258
|
+
const patterns = [
|
|
259
|
+
`resource:list:${normalizedBasePath}`,
|
|
260
|
+
`resource:list:${normalizedBasePath}?*`,
|
|
261
|
+
`resource:list:${normalizedBasePath}#*`,
|
|
262
|
+
`resource:list:${normalizedBasePath}?*#*`,
|
|
263
|
+
];
|
|
264
|
+
for (const variantName of options.variants) {
|
|
265
|
+
const variantPath = `${normalizedBasePath}/${variantName}`;
|
|
266
|
+
patterns.push(`resource:variant:${variantName}:${variantPath}`, `resource:variant:${variantName}:${variantPath}?*`, `resource:variant:${variantName}:${variantPath}#*`, `resource:variant:${variantName}:${variantPath}?*#*`);
|
|
267
|
+
}
|
|
268
|
+
const detailId = getRouteParamValue(req.params?.[options.idParam]);
|
|
269
|
+
if (detailId) {
|
|
270
|
+
const detailPath = `${normalizedBasePath}/${detailId}`;
|
|
271
|
+
patterns.push(`resource:detail:${detailPath}`, `resource:detail:${detailPath}?*`, `resource:detail:${detailPath}#*`, `resource:detail:${detailPath}?*#*`);
|
|
272
|
+
}
|
|
273
|
+
return uniqueValues(patterns);
|
|
274
|
+
}
|
|
275
|
+
function buildResourceInvalidationTags(options, req) {
|
|
276
|
+
const resourceBaseTag = buildResourceBaseTag(options.basePath);
|
|
277
|
+
const tags = [resourceBaseTag, `${resourceBaseTag}:list`];
|
|
278
|
+
for (const variantName of options.variants) {
|
|
279
|
+
tags.push(`${resourceBaseTag}:variant:${variantName}`);
|
|
280
|
+
}
|
|
281
|
+
const detailId = getRouteParamValue(req.params?.[options.idParam]);
|
|
282
|
+
if (detailId) {
|
|
283
|
+
tags.push(`${resourceBaseTag}:detail:${detailId}`);
|
|
284
|
+
}
|
|
285
|
+
return uniqueValues(tags);
|
|
286
|
+
}
|
|
287
|
+
function createRouteCache(options) {
|
|
288
|
+
const { defaultTtlSeconds, events, logger, namespace: inputNamespace, store, } = options;
|
|
289
|
+
const namespace = normalizeNamespace(inputNamespace);
|
|
290
|
+
function buildNamespacedKey(value) {
|
|
291
|
+
return applyNamespace(value, namespace);
|
|
292
|
+
}
|
|
293
|
+
function buildNamespacedPatterns(patterns) {
|
|
294
|
+
return normalizePatterns(patterns, namespace);
|
|
295
|
+
}
|
|
296
|
+
function buildNamespacedTags(tags) {
|
|
297
|
+
return normalizeTags(tags, namespace);
|
|
298
|
+
}
|
|
299
|
+
function resolveTtl(ttlValue, fallback) {
|
|
300
|
+
return parseTtl(ttlValue ?? fallback);
|
|
301
|
+
}
|
|
302
|
+
function resolveInvalidatePayload(invalidateOptions, req, res, body) {
|
|
303
|
+
const resolvedPatterns = uniqueValues(invalidateOptions.patterns
|
|
304
|
+
? resolveMaybeValue(invalidateOptions.patterns, req, res, body)
|
|
305
|
+
: []);
|
|
306
|
+
const resolvedTags = uniqueValues(invalidateOptions.tags
|
|
307
|
+
? resolveMaybeValue(invalidateOptions.tags, req, res, body)
|
|
308
|
+
: []);
|
|
309
|
+
return {
|
|
310
|
+
patterns: buildNamespacedPatterns(resolvedPatterns),
|
|
311
|
+
tags: buildNamespacedTags(resolvedTags),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function createReadBundle(keyBuilder, defaultReadOptions, defaultTagsBuilder) {
|
|
315
|
+
const readOptions = defaultReadOptions ?? {};
|
|
316
|
+
return [
|
|
317
|
+
api.check({
|
|
318
|
+
key: (req) => buildNamespacedKey(keyBuilder(req, readOptions.varyHeaders ?? [])),
|
|
319
|
+
shouldBypass: readOptions.shouldBypass,
|
|
320
|
+
hitHeader: readOptions.hitHeader ?? "x-cache",
|
|
321
|
+
}),
|
|
322
|
+
api.capture({
|
|
323
|
+
ttlSeconds: readOptions.ttlSeconds ?? defaultTtlSeconds,
|
|
324
|
+
shouldCache: readOptions.shouldCache,
|
|
325
|
+
tags: (req, res, body) => uniqueValues([
|
|
326
|
+
...defaultTagsBuilder(req),
|
|
327
|
+
...(readOptions.tags
|
|
328
|
+
? resolveMaybeValue(readOptions.tags, req, res, body) ?? []
|
|
329
|
+
: []),
|
|
330
|
+
]),
|
|
331
|
+
}),
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
const api = {
|
|
57
335
|
check(checkOptions = {}) {
|
|
58
336
|
const { hitHeader = "x-cache", key, shouldBypass, varyHeaders = [], } = checkOptions;
|
|
59
337
|
return async (req, res, next) => {
|
|
60
338
|
if (shouldBypass?.(req)) {
|
|
61
339
|
return next();
|
|
62
340
|
}
|
|
63
|
-
const
|
|
341
|
+
const rawKey = typeof key === "function"
|
|
64
342
|
? key(req)
|
|
65
343
|
: key ?? buildCacheKey(req, varyHeaders);
|
|
344
|
+
const cacheKey = buildNamespacedKey(rawKey);
|
|
66
345
|
setCacheContext(res, { key: cacheKey });
|
|
67
346
|
try {
|
|
68
347
|
const cachedBody = await store.get(cacheKey);
|
|
@@ -70,22 +349,30 @@ function createRouteCache(options) {
|
|
|
70
349
|
if (hitHeader) {
|
|
71
350
|
res.setHeader(hitHeader, "HIT");
|
|
72
351
|
}
|
|
352
|
+
safeEmit(events?.hit, logger, "hit", { key: cacheKey, req }, events);
|
|
73
353
|
res.json(cachedBody);
|
|
74
354
|
return;
|
|
75
355
|
}
|
|
76
356
|
if (hitHeader) {
|
|
77
357
|
res.setHeader(hitHeader, "MISS");
|
|
78
358
|
}
|
|
359
|
+
safeEmit(events?.miss, logger, "miss", { key: cacheKey, req }, events);
|
|
79
360
|
next();
|
|
80
361
|
}
|
|
81
362
|
catch (error) {
|
|
82
363
|
logger?.error?.("Failed to read from cache store", error);
|
|
364
|
+
safeEmitError(events, logger, {
|
|
365
|
+
req,
|
|
366
|
+
key: cacheKey,
|
|
367
|
+
operation: "get",
|
|
368
|
+
error,
|
|
369
|
+
});
|
|
83
370
|
next();
|
|
84
371
|
}
|
|
85
372
|
};
|
|
86
373
|
},
|
|
87
374
|
capture(captureOptions = {}) {
|
|
88
|
-
const { shouldCache, ttlSeconds } = captureOptions;
|
|
375
|
+
const { shouldCache, tags, ttlSeconds } = captureOptions;
|
|
89
376
|
return (req, res, next) => {
|
|
90
377
|
const originalJson = res.json.bind(res);
|
|
91
378
|
res.json = ((body) => {
|
|
@@ -93,14 +380,45 @@ function createRouteCache(options) {
|
|
|
93
380
|
if (context &&
|
|
94
381
|
isSuccessfulStatus(res.statusCode) &&
|
|
95
382
|
(shouldCache?.(req, res, body) ?? true)) {
|
|
96
|
-
const resolvedTtl = typeof ttlSeconds === "function"
|
|
383
|
+
const resolvedTtl = resolveTtl(typeof ttlSeconds === "function"
|
|
97
384
|
? ttlSeconds(req, res, body)
|
|
98
|
-
: ttlSeconds
|
|
99
|
-
|
|
385
|
+
: ttlSeconds, defaultTtlSeconds);
|
|
386
|
+
const resolvedTags = uniqueValues(tags
|
|
387
|
+
? resolveMaybeValue(tags, req, res, body) ?? []
|
|
388
|
+
: []);
|
|
389
|
+
const normalizedTags = buildNamespacedTags(resolvedTags);
|
|
390
|
+
void store
|
|
100
391
|
.set(context.key, body, {
|
|
101
392
|
ttlSeconds: resolvedTtl,
|
|
102
393
|
})
|
|
103
|
-
.
|
|
394
|
+
.then(async () => {
|
|
395
|
+
const tagStore = store;
|
|
396
|
+
if (normalizedTags.length > 0 && tagStore.addTags) {
|
|
397
|
+
await tagStore.addTags(context.key, normalizedTags, {
|
|
398
|
+
ttlSeconds: resolvedTtl,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
else if (normalizedTags.length > 0) {
|
|
402
|
+
logger?.warn?.("Cache store does not support tag indexing. Tags were ignored.");
|
|
403
|
+
}
|
|
404
|
+
safeEmit(events?.set, logger, "set", {
|
|
405
|
+
key: context.key,
|
|
406
|
+
ttlSeconds: resolvedTtl,
|
|
407
|
+
req,
|
|
408
|
+
body,
|
|
409
|
+
tags: normalizedTags,
|
|
410
|
+
}, events);
|
|
411
|
+
})
|
|
412
|
+
.catch((error) => {
|
|
413
|
+
logger?.error?.("Failed to write response into cache store", error);
|
|
414
|
+
safeEmitError(events, logger, {
|
|
415
|
+
req,
|
|
416
|
+
key: context.key,
|
|
417
|
+
tags: normalizedTags,
|
|
418
|
+
operation: normalizedTags.length > 0 ? "tag" : "set",
|
|
419
|
+
error,
|
|
420
|
+
});
|
|
421
|
+
});
|
|
104
422
|
}
|
|
105
423
|
return originalJson(body);
|
|
106
424
|
});
|
|
@@ -108,34 +426,137 @@ function createRouteCache(options) {
|
|
|
108
426
|
};
|
|
109
427
|
},
|
|
110
428
|
invalidate(invalidateOptions) {
|
|
111
|
-
const { onInvalidate,
|
|
429
|
+
const { onInvalidate, shouldInvalidate } = invalidateOptions;
|
|
112
430
|
return (req, res, next) => {
|
|
113
431
|
const originalJson = res.json.bind(res);
|
|
114
432
|
res.json = ((body) => {
|
|
115
|
-
const
|
|
116
|
-
? patterns(req, res, body)
|
|
117
|
-
: patterns;
|
|
433
|
+
const resolvedInvalidation = resolveInvalidatePayload(invalidateOptions, req, res, body);
|
|
118
434
|
if (isSuccessfulStatus(res.statusCode) &&
|
|
119
435
|
(shouldInvalidate?.(req, res, body) ?? true)) {
|
|
120
|
-
Promise.resolve(onInvalidate?.(req, res, body,
|
|
121
|
-
.
|
|
122
|
-
.
|
|
436
|
+
Promise.resolve(onInvalidate?.(req, res, body, {
|
|
437
|
+
patterns: resolvedInvalidation.patterns,
|
|
438
|
+
tags: resolvedInvalidation.tags,
|
|
439
|
+
}))
|
|
440
|
+
.then(() => deleteCacheEntries(store, resolvedInvalidation, logger))
|
|
441
|
+
.then((result) => {
|
|
442
|
+
safeEmit(events?.invalidate, logger, "invalidate", {
|
|
443
|
+
req,
|
|
444
|
+
patterns: result.patterns,
|
|
445
|
+
tags: result.tags,
|
|
446
|
+
deletedCount: result.deletedCount,
|
|
447
|
+
}, events);
|
|
448
|
+
})
|
|
449
|
+
.catch((error) => {
|
|
450
|
+
logger?.error?.("Failed while running cache invalidation hook", error);
|
|
451
|
+
safeEmitError(events, logger, {
|
|
452
|
+
req,
|
|
453
|
+
patterns: resolvedInvalidation.patterns,
|
|
454
|
+
tags: resolvedInvalidation.tags,
|
|
455
|
+
operation: "invalidate",
|
|
456
|
+
error,
|
|
457
|
+
});
|
|
458
|
+
});
|
|
123
459
|
}
|
|
124
460
|
return originalJson(body);
|
|
125
461
|
});
|
|
126
462
|
next();
|
|
127
463
|
};
|
|
128
464
|
},
|
|
465
|
+
resource(resourceOptions) {
|
|
466
|
+
const { basePath, hitHeader = "x-cache", idParam = "id", shouldBypass, shouldCache, ttlSeconds, variants = [], varyHeaders = [], } = resourceOptions;
|
|
467
|
+
function mergeReadOptions(readOptions) {
|
|
468
|
+
return {
|
|
469
|
+
ttlSeconds: readOptions?.ttlSeconds ?? ttlSeconds,
|
|
470
|
+
varyHeaders: readOptions?.varyHeaders ?? varyHeaders,
|
|
471
|
+
shouldBypass: readOptions?.shouldBypass ?? shouldBypass,
|
|
472
|
+
hitHeader: readOptions?.hitHeader ?? hitHeader,
|
|
473
|
+
shouldCache: readOptions?.shouldCache ?? shouldCache,
|
|
474
|
+
tags: readOptions?.tags,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
list(readOptions) {
|
|
479
|
+
const mergedOptions = mergeReadOptions(readOptions);
|
|
480
|
+
return createReadBundle((req, varyHeaderList) => buildResourceKey("list", req, {
|
|
481
|
+
basePath,
|
|
482
|
+
varyHeaders: varyHeaderList,
|
|
483
|
+
}), mergedOptions, () => resolveResourceTags(basePath, "list", {}));
|
|
484
|
+
},
|
|
485
|
+
detail(readOptions) {
|
|
486
|
+
const mergedOptions = mergeReadOptions(readOptions);
|
|
487
|
+
return createReadBundle((req, varyHeaderList) => buildResourceKey("detail", req, {
|
|
488
|
+
basePath,
|
|
489
|
+
detailId: getRouteParamValue(req.params?.[idParam]),
|
|
490
|
+
varyHeaders: varyHeaderList,
|
|
491
|
+
}), mergedOptions, (req) => resolveResourceTags(basePath, "detail", {
|
|
492
|
+
detailId: getRouteParamValue(req.params?.[idParam]),
|
|
493
|
+
}));
|
|
494
|
+
},
|
|
495
|
+
variant(name, readOptions) {
|
|
496
|
+
const mergedOptions = mergeReadOptions(readOptions);
|
|
497
|
+
return createReadBundle((req, varyHeaderList) => buildResourceKey("variant", req, {
|
|
498
|
+
basePath,
|
|
499
|
+
variantName: name,
|
|
500
|
+
varyHeaders: varyHeaderList,
|
|
501
|
+
}), mergedOptions, () => resolveResourceTags(basePath, "variant", {
|
|
502
|
+
variantName: name,
|
|
503
|
+
}));
|
|
504
|
+
},
|
|
505
|
+
invalidateOnWrite(invalidateOptions = {}) {
|
|
506
|
+
return api.invalidate({
|
|
507
|
+
patterns: (req, res, body) => [
|
|
508
|
+
...buildResourceInvalidationPatterns({
|
|
509
|
+
basePath,
|
|
510
|
+
idParam,
|
|
511
|
+
variants,
|
|
512
|
+
}, req),
|
|
513
|
+
...(invalidateOptions.additionalPatterns
|
|
514
|
+
? resolveMaybeValue(invalidateOptions.additionalPatterns, req, res, body)
|
|
515
|
+
: []),
|
|
516
|
+
],
|
|
517
|
+
tags: (req, res, body) => [
|
|
518
|
+
...buildResourceInvalidationTags({
|
|
519
|
+
basePath,
|
|
520
|
+
idParam,
|
|
521
|
+
variants,
|
|
522
|
+
}, req),
|
|
523
|
+
...(invalidateOptions.additionalTags
|
|
524
|
+
? resolveMaybeValue(invalidateOptions.additionalTags, req, res, body)
|
|
525
|
+
: []),
|
|
526
|
+
],
|
|
527
|
+
shouldInvalidate: invalidateOptions.shouldInvalidate,
|
|
528
|
+
onInvalidate: invalidateOptions.onInvalidate,
|
|
529
|
+
});
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
},
|
|
129
533
|
async get(key) {
|
|
130
|
-
return store.get(key);
|
|
534
|
+
return store.get(buildNamespacedKey(key));
|
|
131
535
|
},
|
|
132
536
|
async set(key, value, ttlSeconds = defaultTtlSeconds) {
|
|
133
|
-
|
|
537
|
+
const resolvedTtl = resolveTtl(ttlSeconds, defaultTtlSeconds);
|
|
538
|
+
await store.set(buildNamespacedKey(key), value, {
|
|
539
|
+
ttlSeconds: resolvedTtl,
|
|
540
|
+
});
|
|
134
541
|
},
|
|
135
542
|
async deleteByPatterns(patterns) {
|
|
136
|
-
await
|
|
543
|
+
const result = await deleteCacheEntries(store, { patterns: buildNamespacedPatterns(patterns) }, logger);
|
|
544
|
+
safeEmit(events?.invalidate, logger, "invalidate", {
|
|
545
|
+
patterns: result.patterns,
|
|
546
|
+
tags: result.tags,
|
|
547
|
+
deletedCount: result.deletedCount,
|
|
548
|
+
}, events);
|
|
549
|
+
},
|
|
550
|
+
async deleteByTags(tags) {
|
|
551
|
+
const result = await deleteCacheEntries(store, { tags: buildNamespacedTags(tags) }, logger);
|
|
552
|
+
safeEmit(events?.invalidate, logger, "invalidate", {
|
|
553
|
+
patterns: result.patterns,
|
|
554
|
+
tags: result.tags,
|
|
555
|
+
deletedCount: result.deletedCount,
|
|
556
|
+
}, events);
|
|
137
557
|
},
|
|
138
558
|
};
|
|
559
|
+
return api;
|
|
139
560
|
}
|
|
140
561
|
async function collectKeysForPattern(client, pattern, scanCount) {
|
|
141
562
|
if (client.scan) {
|
|
@@ -153,15 +574,40 @@ async function collectKeysForPattern(client, pattern, scanCount) {
|
|
|
153
574
|
}
|
|
154
575
|
throw new Error("Redis client must provide either scan() or keys() for pattern invalidation.");
|
|
155
576
|
}
|
|
577
|
+
function createRedisTagSetKey(tag) {
|
|
578
|
+
return `${REDIS_TAG_PREFIX}${tag}`;
|
|
579
|
+
}
|
|
580
|
+
function createRedisKeyTagSetKey(key) {
|
|
581
|
+
return `${REDIS_KEY_TAG_PREFIX}${key}`;
|
|
582
|
+
}
|
|
583
|
+
function hasRedisTagSupport(client) {
|
|
584
|
+
return Boolean(client.sadd && client.srem && client.smembers && client.expire);
|
|
585
|
+
}
|
|
156
586
|
function createRedisStore(options) {
|
|
157
587
|
const { client, defaultTtlSeconds = 60, deserialize = JSON.parse, logger, scanCount = 200, serialize = JSON.stringify, } = options;
|
|
588
|
+
const resolvedDefaultTtl = parseTtl(defaultTtlSeconds) ?? 60;
|
|
589
|
+
async function cleanupRedisTagRefs(keys) {
|
|
590
|
+
if (!hasRedisTagSupport(client) || keys.length === 0) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
for (const key of keys) {
|
|
594
|
+
const keyTagSetKey = createRedisKeyTagSetKey(key);
|
|
595
|
+
const tags = await client.smembers(keyTagSetKey);
|
|
596
|
+
if (tags.length > 0) {
|
|
597
|
+
for (const tag of tags) {
|
|
598
|
+
await client.srem(createRedisTagSetKey(tag), key);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
await client.del(keyTagSetKey);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
158
604
|
return {
|
|
159
605
|
async get(key) {
|
|
160
606
|
const rawValue = await client.get(key);
|
|
161
607
|
return rawValue ? deserialize(rawValue) : null;
|
|
162
608
|
},
|
|
163
609
|
async set(key, value, setOptions) {
|
|
164
|
-
const ttlSeconds = setOptions?.ttlSeconds ??
|
|
610
|
+
const ttlSeconds = setOptions?.ttlSeconds ?? resolvedDefaultTtl;
|
|
165
611
|
const payload = serialize(value);
|
|
166
612
|
if (client.setex) {
|
|
167
613
|
await client.setex(key, ttlSeconds, payload);
|
|
@@ -179,6 +625,7 @@ function createRedisStore(options) {
|
|
|
179
625
|
if (uniqueKeys.length === 0) {
|
|
180
626
|
return 0;
|
|
181
627
|
}
|
|
628
|
+
await cleanupRedisTagRefs(uniqueKeys);
|
|
182
629
|
const deletedCount = await client.del(...uniqueKeys);
|
|
183
630
|
logger?.info?.("Deleted cache keys", {
|
|
184
631
|
deletedCount,
|
|
@@ -186,11 +633,69 @@ function createRedisStore(options) {
|
|
|
186
633
|
});
|
|
187
634
|
return deletedCount;
|
|
188
635
|
},
|
|
636
|
+
async addTags(key, tags, addTagOptions) {
|
|
637
|
+
if (!hasRedisTagSupport(client) || tags.length === 0) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const ttlSeconds = addTagOptions?.ttlSeconds ?? resolvedDefaultTtl;
|
|
641
|
+
const keyTagSetKey = createRedisKeyTagSetKey(key);
|
|
642
|
+
await client.sadd(keyTagSetKey, ...tags);
|
|
643
|
+
await client.expire(keyTagSetKey, ttlSeconds);
|
|
644
|
+
for (const tag of tags) {
|
|
645
|
+
const tagSetKey = createRedisTagSetKey(tag);
|
|
646
|
+
await client.sadd(tagSetKey, key);
|
|
647
|
+
await client.expire(tagSetKey, ttlSeconds);
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
async deleteByTags(tags) {
|
|
651
|
+
if (!hasRedisTagSupport(client) || tags.length === 0) {
|
|
652
|
+
return 0;
|
|
653
|
+
}
|
|
654
|
+
const keys = new Set();
|
|
655
|
+
for (const tag of tags) {
|
|
656
|
+
const matchedKeys = await client.smembers(createRedisTagSetKey(tag));
|
|
657
|
+
matchedKeys.forEach((key) => keys.add(key));
|
|
658
|
+
}
|
|
659
|
+
const uniqueKeys = [...keys];
|
|
660
|
+
if (uniqueKeys.length === 0) {
|
|
661
|
+
return 0;
|
|
662
|
+
}
|
|
663
|
+
await cleanupRedisTagRefs(uniqueKeys);
|
|
664
|
+
const deletedCount = await client.del(...uniqueKeys);
|
|
665
|
+
for (const tag of tags) {
|
|
666
|
+
await client.del(createRedisTagSetKey(tag));
|
|
667
|
+
}
|
|
668
|
+
return deletedCount;
|
|
669
|
+
},
|
|
189
670
|
};
|
|
190
671
|
}
|
|
191
672
|
function createMemoryStore(options = {}) {
|
|
192
673
|
const { defaultTtlSeconds = 60 } = options;
|
|
674
|
+
const resolvedDefaultTtl = parseTtl(defaultTtlSeconds) ?? 60;
|
|
193
675
|
const entries = new Map();
|
|
676
|
+
const tagIndex = new Map();
|
|
677
|
+
const keyTags = new Map();
|
|
678
|
+
function removeKeyFromTagIndexes(key) {
|
|
679
|
+
const tags = keyTags.get(key);
|
|
680
|
+
if (!tags) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
for (const tag of tags) {
|
|
684
|
+
const keys = tagIndex.get(tag);
|
|
685
|
+
if (!keys) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
keys.delete(key);
|
|
689
|
+
if (keys.size === 0) {
|
|
690
|
+
tagIndex.delete(tag);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
keyTags.delete(key);
|
|
694
|
+
}
|
|
695
|
+
function deleteKey(key) {
|
|
696
|
+
entries.delete(key);
|
|
697
|
+
removeKeyFromTagIndexes(key);
|
|
698
|
+
}
|
|
194
699
|
return {
|
|
195
700
|
async get(key) {
|
|
196
701
|
const entry = entries.get(key);
|
|
@@ -198,13 +703,13 @@ function createMemoryStore(options = {}) {
|
|
|
198
703
|
return null;
|
|
199
704
|
}
|
|
200
705
|
if (Date.now() > entry.expiresAt) {
|
|
201
|
-
|
|
706
|
+
deleteKey(key);
|
|
202
707
|
return null;
|
|
203
708
|
}
|
|
204
709
|
return entry.value;
|
|
205
710
|
},
|
|
206
711
|
async set(key, value, setOptions) {
|
|
207
|
-
const ttlSeconds = setOptions?.ttlSeconds ??
|
|
712
|
+
const ttlSeconds = setOptions?.ttlSeconds ?? resolvedDefaultTtl;
|
|
208
713
|
entries.set(key, {
|
|
209
714
|
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
210
715
|
value,
|
|
@@ -213,13 +718,40 @@ function createMemoryStore(options = {}) {
|
|
|
213
718
|
async deleteByPatterns(patterns) {
|
|
214
719
|
const matchers = patterns.map(wildcardToRegExp);
|
|
215
720
|
let deletedCount = 0;
|
|
216
|
-
for (const key of entries.keys()) {
|
|
721
|
+
for (const key of [...entries.keys()]) {
|
|
217
722
|
if (matchers.some((matcher) => matcher.test(key))) {
|
|
218
|
-
|
|
723
|
+
deleteKey(key);
|
|
219
724
|
deletedCount += 1;
|
|
220
725
|
}
|
|
221
726
|
}
|
|
222
727
|
return deletedCount;
|
|
223
728
|
},
|
|
729
|
+
async addTags(key, tags) {
|
|
730
|
+
if (!entries.has(key) || tags.length === 0) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const nextTags = keyTags.get(key) ?? new Set();
|
|
734
|
+
for (const tag of tags) {
|
|
735
|
+
nextTags.add(tag);
|
|
736
|
+
const keysForTag = tagIndex.get(tag) ?? new Set();
|
|
737
|
+
keysForTag.add(key);
|
|
738
|
+
tagIndex.set(tag, keysForTag);
|
|
739
|
+
}
|
|
740
|
+
keyTags.set(key, nextTags);
|
|
741
|
+
},
|
|
742
|
+
async deleteByTags(tags) {
|
|
743
|
+
const keys = new Set();
|
|
744
|
+
for (const tag of tags) {
|
|
745
|
+
const taggedKeys = tagIndex.get(tag);
|
|
746
|
+
if (!taggedKeys) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
taggedKeys.forEach((key) => keys.add(key));
|
|
750
|
+
}
|
|
751
|
+
for (const key of keys) {
|
|
752
|
+
deleteKey(key);
|
|
753
|
+
}
|
|
754
|
+
return keys.size;
|
|
755
|
+
},
|
|
224
756
|
};
|
|
225
757
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heinhtet37/express-route-cache",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Reusable Express route caching middleware for Node.js and TypeScript services.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc -p tsconfig.json",
|
|
21
21
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
22
|
+
"test": "npm run build && node --test tests/**/*.test.js",
|
|
22
23
|
"prepublishOnly": "npm run clean && npm run build"
|
|
23
24
|
},
|
|
24
25
|
"publishConfig": {
|