@trieb.work/nextjs-turbo-redis-cache 1.14.1 → 1.15.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/.github/workflows/pages.yml +38 -0
- package/CHANGELOG.md +12 -0
- package/README.md +150 -3
- package/dist/index.d.mts +77 -2
- package/dist/index.d.ts +77 -2
- package/dist/index.js +31 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +28 -9
- package/dist/index.mjs.map +1 -1
- package/docs/index.html +139 -0
- package/docs/robots.txt +4 -0
- package/docs/sitemap.xml +8 -0
- package/docs/styles.css +141 -0
- package/package.json +3 -3
- package/src/RedisStringsHandler.ts +48 -11
- package/src/index.ts +4 -0
- package/src/serializer.test.ts +178 -0
- package/src/serializer.ts +59 -0
- package/test/cache-components/cache-components.integration.spec.ts +39 -0
- package/test/integration/next-app-16-2-3-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
- package/test/integration/nextjs-cache-handler.integration.test.ts +2 -1
package/docs/index.html
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>nextjs-turbo-redis-cache | High-Performance Redis Cache Handler for Next.js</title>
|
|
7
|
+
<meta
|
|
8
|
+
name="description"
|
|
9
|
+
content="High-performance Redis cache handler for Next.js 15/16 App Router. Get request deduplication, batch tag invalidation, in-memory caching, and production-ready scalability."
|
|
10
|
+
/>
|
|
11
|
+
<meta
|
|
12
|
+
name="keywords"
|
|
13
|
+
content="Next.js cache handler, Redis cache, App Router cache, Next.js performance, cache components, batch tag invalidation"
|
|
14
|
+
/>
|
|
15
|
+
<meta name="robots" content="index, follow" />
|
|
16
|
+
<meta name="author" content="TRWK" />
|
|
17
|
+
<link rel="canonical" href="https://trieb-work.github.io/nextjs-turbo-redis-cache/" />
|
|
18
|
+
|
|
19
|
+
<meta property="og:type" content="website" />
|
|
20
|
+
<meta property="og:title" content="nextjs-turbo-redis-cache | High-Performance Redis Cache Handler for Next.js" />
|
|
21
|
+
<meta
|
|
22
|
+
property="og:description"
|
|
23
|
+
content="The production-ready Redis cache handler for Next.js 15/16 with request deduplication, in-memory caching, and efficient tag invalidation."
|
|
24
|
+
/>
|
|
25
|
+
<meta property="og:url" content="https://trieb-work.github.io/nextjs-turbo-redis-cache/" />
|
|
26
|
+
<meta
|
|
27
|
+
property="og:image"
|
|
28
|
+
content="https://github.com/user-attachments/assets/4103191e-4f4d-4139-a519-0b5bfab3e8b4"
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
32
|
+
<meta name="twitter:title" content="nextjs-turbo-redis-cache" />
|
|
33
|
+
<meta
|
|
34
|
+
name="twitter:description"
|
|
35
|
+
content="Production-ready Redis cache handler for Next.js 15/16 App Router workloads."
|
|
36
|
+
/>
|
|
37
|
+
<meta
|
|
38
|
+
name="twitter:image"
|
|
39
|
+
content="https://github.com/user-attachments/assets/4103191e-4f4d-4139-a519-0b5bfab3e8b4"
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
43
|
+
|
|
44
|
+
<script type="application/ld+json">
|
|
45
|
+
{
|
|
46
|
+
"@context": "https://schema.org",
|
|
47
|
+
"@type": "SoftwareSourceCode",
|
|
48
|
+
"name": "nextjs-turbo-redis-cache",
|
|
49
|
+
"description": "High-performance Redis cache handler for Next.js 15/16 App Router with request deduplication, in-memory caching, and batch tag invalidation.",
|
|
50
|
+
"codeRepository": "https://github.com/trieb-work/nextjs-turbo-redis-cache",
|
|
51
|
+
"programmingLanguage": "TypeScript",
|
|
52
|
+
"runtimePlatform": "Node.js",
|
|
53
|
+
"license": "https://opensource.org/license/mit",
|
|
54
|
+
"author": {
|
|
55
|
+
"@type": "Organization",
|
|
56
|
+
"name": "TRWK",
|
|
57
|
+
"url": "https://trwk.de"
|
|
58
|
+
},
|
|
59
|
+
"keywords": [
|
|
60
|
+
"Next.js",
|
|
61
|
+
"Redis",
|
|
62
|
+
"Cache Handler",
|
|
63
|
+
"App Router",
|
|
64
|
+
"Performance"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<header class="hero">
|
|
71
|
+
<p class="eyebrow">Open-source · MIT Licensed</p>
|
|
72
|
+
<h1>Redis Cache Handler Built for High-Traffic Next.js Apps</h1>
|
|
73
|
+
<p class="lead">
|
|
74
|
+
nextjs-turbo-redis-cache is a production-ready cache handler for Next.js 15/16 App Router, designed for
|
|
75
|
+
speed, consistency tradeoff awareness, and scale.
|
|
76
|
+
</p>
|
|
77
|
+
<div class="actions">
|
|
78
|
+
<a class="button primary" href="https://www.npmjs.com/package/@trieb.work/nextjs-turbo-redis-cache">Install from npm</a>
|
|
79
|
+
<a class="button" href="https://github.com/trieb-work/nextjs-turbo-redis-cache">View on GitHub</a>
|
|
80
|
+
</div>
|
|
81
|
+
</header>
|
|
82
|
+
|
|
83
|
+
<main>
|
|
84
|
+
<section aria-labelledby="features-title">
|
|
85
|
+
<h2 id="features-title">Why teams use nextjs-turbo-redis-cache</h2>
|
|
86
|
+
<ul class="feature-list">
|
|
87
|
+
<li>
|
|
88
|
+
<h3>Batch tag invalidation</h3>
|
|
89
|
+
<p>Groups and optimizes delete operations to reduce Redis load during revalidation spikes.</p>
|
|
90
|
+
</li>
|
|
91
|
+
<li>
|
|
92
|
+
<h3>Request deduplication</h3>
|
|
93
|
+
<p>Prevents duplicate Redis reads in hot paths to lower latency and improve throughput.</p>
|
|
94
|
+
</li>
|
|
95
|
+
<li>
|
|
96
|
+
<h3>In-memory acceleration</h3>
|
|
97
|
+
<p>Local memory caching reduces repetitive lookups and keeps frequent reads fast.</p>
|
|
98
|
+
</li>
|
|
99
|
+
<li>
|
|
100
|
+
<h3>Cache Components support</h3>
|
|
101
|
+
<p>Supports Next.js 16+ Cache Components flows including use cache, cacheTag, and cacheLife.</p>
|
|
102
|
+
</li>
|
|
103
|
+
</ul>
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section aria-labelledby="quickstart-title">
|
|
107
|
+
<h2 id="quickstart-title">Quick start</h2>
|
|
108
|
+
<p>Install the package and wire it as your cache handler in a Next.js App Router project.</p>
|
|
109
|
+
<pre><code>pnpm add @trieb.work/nextjs-turbo-redis-cache</code></pre>
|
|
110
|
+
<p>Then follow the setup guide in the README for configuration, Redis environment variables, and advanced options.</p>
|
|
111
|
+
</section>
|
|
112
|
+
|
|
113
|
+
<section aria-labelledby="links-title" class="links">
|
|
114
|
+
<h2 id="links-title">Resources</h2>
|
|
115
|
+
<ul>
|
|
116
|
+
<li><a href="https://github.com/trieb-work/nextjs-turbo-redis-cache">GitHub Repository</a></li>
|
|
117
|
+
<li><a href="https://www.npmjs.com/package/@trieb.work/nextjs-turbo-redis-cache">npm Package</a></li>
|
|
118
|
+
<li><a href="https://trwk.de/case-studies/nextjs-turbo-redis-cache">TRWK Case Study</a></li>
|
|
119
|
+
</ul>
|
|
120
|
+
</section>
|
|
121
|
+
|
|
122
|
+
<section aria-labelledby="alternatives-title" class="links">
|
|
123
|
+
<h2 id="alternatives-title">Other cache handler projects</h2>
|
|
124
|
+
<p>Compare this project with other open-source handlers used in the Next.js ecosystem:</p>
|
|
125
|
+
<ul>
|
|
126
|
+
<li><a href="https://github.com/fortedigital/nextjs-cache-handler">fortedigital/nextjs-cache-handler</a></li>
|
|
127
|
+
<li><a href="https://github.com/mrjasonroy/cache-components-cache-handler">mrjasonroy/cache-components-cache-handler</a></li>
|
|
128
|
+
<li><a href="https://github.com/leejpsd/nextjs-cache-handler">leejpsd/nextjs-cache-handler</a></li>
|
|
129
|
+
<li><a href="https://github.com/caching-tools/next-shared-cache">caching-tools/next-shared-cache (@neshca/cache-handler)</a></li>
|
|
130
|
+
<li><a href="https://github.com/vercel/next.js/tree/canary/examples/cache-handler-redis">vercel/next.js cache-handler-redis example</a></li>
|
|
131
|
+
</ul>
|
|
132
|
+
</section>
|
|
133
|
+
</main>
|
|
134
|
+
|
|
135
|
+
<footer>
|
|
136
|
+
<p>Maintained by <a href="https://trwk.de">TRWK</a>.</p>
|
|
137
|
+
</footer>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
package/docs/robots.txt
ADDED
package/docs/sitemap.xml
ADDED
package/docs/styles.css
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0a1628;
|
|
3
|
+
--bg-alt: #0f2139;
|
|
4
|
+
--card: #112a49;
|
|
5
|
+
--text: #e8f1ff;
|
|
6
|
+
--muted: #b8c8de;
|
|
7
|
+
--accent: #56c2ff;
|
|
8
|
+
--accent-strong: #1fa6f3;
|
|
9
|
+
--border: #2a476b;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
* {
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
margin: 0;
|
|
18
|
+
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
19
|
+
background: radial-gradient(circle at top, var(--bg-alt), var(--bg));
|
|
20
|
+
color: var(--text);
|
|
21
|
+
line-height: 1.6;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hero,
|
|
25
|
+
main,
|
|
26
|
+
footer {
|
|
27
|
+
width: min(1080px, 92vw);
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.hero {
|
|
32
|
+
padding: 4.5rem 0 2.2rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.eyebrow {
|
|
36
|
+
color: var(--accent);
|
|
37
|
+
font-weight: 700;
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
letter-spacing: 0.06em;
|
|
40
|
+
font-size: 0.82rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
h1 {
|
|
44
|
+
font-size: clamp(2rem, 4vw, 3.2rem);
|
|
45
|
+
line-height: 1.15;
|
|
46
|
+
margin: 0.5rem 0 1rem;
|
|
47
|
+
max-width: 18ch;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.lead {
|
|
51
|
+
color: var(--muted);
|
|
52
|
+
font-size: 1.1rem;
|
|
53
|
+
max-width: 64ch;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.actions {
|
|
57
|
+
display: flex;
|
|
58
|
+
gap: 0.8rem;
|
|
59
|
+
flex-wrap: wrap;
|
|
60
|
+
margin-top: 1.5rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.button {
|
|
64
|
+
display: inline-block;
|
|
65
|
+
text-decoration: none;
|
|
66
|
+
color: var(--text);
|
|
67
|
+
border: 1px solid var(--border);
|
|
68
|
+
padding: 0.75rem 1rem;
|
|
69
|
+
border-radius: 0.7rem;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.button.primary {
|
|
74
|
+
background: var(--accent-strong);
|
|
75
|
+
border-color: var(--accent-strong);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main {
|
|
79
|
+
padding: 1.5rem 0 3rem;
|
|
80
|
+
display: grid;
|
|
81
|
+
gap: 1.2rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
section {
|
|
85
|
+
background: color-mix(in srgb, var(--card) 82%, black);
|
|
86
|
+
border: 1px solid var(--border);
|
|
87
|
+
border-radius: 1rem;
|
|
88
|
+
padding: 1.2rem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
h2 {
|
|
92
|
+
margin-top: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.feature-list {
|
|
96
|
+
margin: 0;
|
|
97
|
+
padding-left: 1rem;
|
|
98
|
+
display: grid;
|
|
99
|
+
gap: 0.75rem;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.feature-list li {
|
|
103
|
+
list-style: square;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.feature-list h3 {
|
|
107
|
+
margin-bottom: 0.2rem;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.feature-list p,
|
|
111
|
+
.links li,
|
|
112
|
+
footer p {
|
|
113
|
+
color: var(--muted);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pre {
|
|
117
|
+
overflow-x: auto;
|
|
118
|
+
background: #081323;
|
|
119
|
+
border: 1px solid var(--border);
|
|
120
|
+
border-radius: 0.7rem;
|
|
121
|
+
padding: 0.8rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
a {
|
|
125
|
+
color: var(--accent);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
footer {
|
|
129
|
+
padding: 0 0 2rem;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@media (max-width: 700px) {
|
|
133
|
+
.hero {
|
|
134
|
+
padding-top: 2.8rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.button {
|
|
138
|
+
width: 100%;
|
|
139
|
+
text-align: center;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trieb.work/nextjs-turbo-redis-cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"homepage": "https://trwk.de/case-studies/nextjs-turbo-redis-cache",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"tags-cache",
|
|
57
57
|
"nextjs-turbo-redis-cache"
|
|
58
58
|
],
|
|
59
|
-
"author": "
|
|
59
|
+
"author": "TRWK <info@trwk.de>",
|
|
60
60
|
"license": "ISC",
|
|
61
|
-
"description": "
|
|
61
|
+
"description": "Designed for speed, scalability, and optimized performance, nextjs-turbo-redis-cache is your custom cache handler for demanding production environments.",
|
|
62
62
|
"publishConfig": {
|
|
63
63
|
"access": "public",
|
|
64
64
|
"provenance": true
|
|
@@ -2,7 +2,7 @@ import { commandOptions, createClient, RedisClientOptions } from 'redis';
|
|
|
2
2
|
import { SyncedMap } from './SyncedMap';
|
|
3
3
|
import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
|
|
4
4
|
import { debug } from './utils/debug';
|
|
5
|
-
import {
|
|
5
|
+
import { CacheValueSerializer, jsonCacheValueSerializer } from './serializer';
|
|
6
6
|
|
|
7
7
|
export type CommandOptions = ReturnType<typeof commandOptions>;
|
|
8
8
|
export type Client = ReturnType<typeof createClient>;
|
|
@@ -113,6 +113,27 @@ export type CreateRedisStringsHandlerOptions = {
|
|
|
113
113
|
* @example { username: 'user', password: 'pass' }
|
|
114
114
|
*/
|
|
115
115
|
clientOptions?: Omit<RedisClientOptions, 'url' | 'database' | 'socket'>;
|
|
116
|
+
/** Pluggable wire-format codec for Redis string values. Lets you plug in
|
|
117
|
+
* compression (gzip/brotli), encryption, or any other custom encoding without
|
|
118
|
+
* forking this package or losing the existing dedup / batch / keyspace features.
|
|
119
|
+
*
|
|
120
|
+
* Both `serialize` and `deserialize` may return a `Promise`, enabling
|
|
121
|
+
* non-blocking async codecs (e.g. `zlib.brotliCompress`) that don't block the
|
|
122
|
+
* Node.js event loop. Synchronous implementations continue to work unchanged.
|
|
123
|
+
*
|
|
124
|
+
* Only the main cache-entry storage path is routed through the serializer.
|
|
125
|
+
* The shared-tags map and the revalidated-tags map are not. The in-memory
|
|
126
|
+
* deduplication cache stores the wire-format string verbatim - its contents
|
|
127
|
+
* change with the serializer, but the cache itself is not re-encoded.
|
|
128
|
+
*
|
|
129
|
+
* Operational note: changing the serializer (or any of its parameters such as
|
|
130
|
+
* a compression level or encryption key) makes existing Redis keys unreadable.
|
|
131
|
+
* Either flush the affected keys or bump `keyPrefix` before deploying.
|
|
132
|
+
*
|
|
133
|
+
* @default jsonCacheValueSerializer (JSON.stringify with bufferAndMapReplacer
|
|
134
|
+
* so native Buffer and Map values inside a CacheEntry round-trip transparently)
|
|
135
|
+
*/
|
|
136
|
+
valueSerializer?: CacheValueSerializer;
|
|
116
137
|
};
|
|
117
138
|
|
|
118
139
|
// Identifier prefix used by Next.js to mark automatically generated cache tags
|
|
@@ -144,6 +165,7 @@ export default class RedisStringsHandler {
|
|
|
144
165
|
private defaultStaleAge: number;
|
|
145
166
|
private estimateExpireAge: (staleAge: number) => number;
|
|
146
167
|
private killContainerOnErrorThreshold: number;
|
|
168
|
+
private valueSerializer: CacheValueSerializer;
|
|
147
169
|
|
|
148
170
|
constructor({
|
|
149
171
|
redisUrl = process.env.REDIS_URL
|
|
@@ -172,6 +194,7 @@ export default class RedisStringsHandler {
|
|
|
172
194
|
: 0,
|
|
173
195
|
socketOptions,
|
|
174
196
|
clientOptions,
|
|
197
|
+
valueSerializer = jsonCacheValueSerializer,
|
|
175
198
|
}: CreateRedisStringsHandlerOptions) {
|
|
176
199
|
try {
|
|
177
200
|
this.keyPrefix = keyPrefix;
|
|
@@ -181,6 +204,7 @@ export default class RedisStringsHandler {
|
|
|
181
204
|
this.estimateExpireAge = estimateExpireAge;
|
|
182
205
|
this.killContainerOnErrorThreshold = killContainerOnErrorThreshold;
|
|
183
206
|
this.getTimeoutMs = getTimeoutMs;
|
|
207
|
+
this.valueSerializer = valueSerializer;
|
|
184
208
|
|
|
185
209
|
try {
|
|
186
210
|
// Create Redis client with properly typed configuration
|
|
@@ -418,10 +442,24 @@ export default class RedisStringsHandler {
|
|
|
418
442
|
return null;
|
|
419
443
|
}
|
|
420
444
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
445
|
+
let cacheEntry: CacheEntry | null;
|
|
446
|
+
try {
|
|
447
|
+
cacheEntry =
|
|
448
|
+
await this.valueSerializer.deserialize(serializedCacheEntry);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
// A decode failure (e.g. legacy/corrupted entry after swapping codecs,
|
|
451
|
+
// bumping a compression level or rotating an encryption key) is treated
|
|
452
|
+
// as a cache miss rather than a hard error - this matches the
|
|
453
|
+
// null-from-deserialize semantics in the CacheValueSerializer contract
|
|
454
|
+
// and prevents a single unreadable entry from incrementing
|
|
455
|
+
// killContainerOnErrorCount on every read.
|
|
456
|
+
console.warn(
|
|
457
|
+
'RedisStringsHandler.get() valueSerializer.deserialize failed, treating as cache miss',
|
|
458
|
+
this.keyPrefix + key,
|
|
459
|
+
err,
|
|
460
|
+
);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
425
463
|
|
|
426
464
|
debug(
|
|
427
465
|
'green',
|
|
@@ -520,8 +558,9 @@ export default class RedisStringsHandler {
|
|
|
520
558
|
} catch (error) {
|
|
521
559
|
// This catch block is necessary to handle any errors that may occur during:
|
|
522
560
|
// 1. Redis operations (get, unlink)
|
|
523
|
-
// 2.
|
|
524
|
-
//
|
|
561
|
+
// 2. Tag validation and cleanup
|
|
562
|
+
// (Deserialization errors are handled locally above and treated as cache misses,
|
|
563
|
+
// so they do not reach this branch and do not count toward the kill threshold.)
|
|
525
564
|
// If any error occurs, we return null to indicate no valid cache entry was found,
|
|
526
565
|
// allowing the application to regenerate the content rather than crash
|
|
527
566
|
console.error(
|
|
@@ -621,10 +660,8 @@ export default class RedisStringsHandler {
|
|
|
621
660
|
tags: ctx?.tags || [],
|
|
622
661
|
value: data,
|
|
623
662
|
};
|
|
624
|
-
const serializedCacheEntry =
|
|
625
|
-
cacheEntry
|
|
626
|
-
bufferAndMapReplacer,
|
|
627
|
-
);
|
|
663
|
+
const serializedCacheEntry =
|
|
664
|
+
await this.valueSerializer.serialize(cacheEntry);
|
|
628
665
|
|
|
629
666
|
// pre seed data into deduplicated get client. This will reduce redis load by not requesting
|
|
630
667
|
// the same value from redis which was just set.
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,10 @@ import RedisStringsHandler from './RedisStringsHandler';
|
|
|
5
5
|
export { RedisStringsHandler };
|
|
6
6
|
export type { CreateRedisStringsHandlerOptions } from './RedisStringsHandler';
|
|
7
7
|
|
|
8
|
+
export { jsonCacheValueSerializer } from './serializer';
|
|
9
|
+
export type { CacheValueSerializer } from './serializer';
|
|
10
|
+
export { bufferAndMapReplacer, bufferAndMapReviver } from './utils/json';
|
|
11
|
+
|
|
8
12
|
import {
|
|
9
13
|
redisCacheHandler,
|
|
10
14
|
getRedisCacheComponentsHandler,
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { CacheValueSerializer, jsonCacheValueSerializer } from './serializer';
|
|
4
|
+
import type { CacheEntry } from './RedisStringsHandler';
|
|
5
|
+
|
|
6
|
+
function makeEntry(value: unknown): CacheEntry {
|
|
7
|
+
return {
|
|
8
|
+
value,
|
|
9
|
+
lastModified: 1700000000000,
|
|
10
|
+
tags: ['tag-a', 'tag-b'],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('jsonCacheValueSerializer (default)', () => {
|
|
15
|
+
it('round-trips a CacheEntry with a top-level Buffer value (Buffer + Map encoding preserved)', async () => {
|
|
16
|
+
const original = makeEntry(Buffer.from('hello world', 'utf8'));
|
|
17
|
+
|
|
18
|
+
const serialized = await jsonCacheValueSerializer.serialize(original);
|
|
19
|
+
expect(typeof serialized).toBe('string');
|
|
20
|
+
|
|
21
|
+
const restored = await jsonCacheValueSerializer.deserialize(serialized);
|
|
22
|
+
expect(restored).not.toBeNull();
|
|
23
|
+
expect(Buffer.isBuffer(restored!.value)).toBe(true);
|
|
24
|
+
expect((restored!.value as Buffer).toString('utf8')).toBe('hello world');
|
|
25
|
+
expect(restored!.lastModified).toBe(original.lastModified);
|
|
26
|
+
expect(restored!.tags).toEqual(original.tags);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('round-trips a Buffer nested inside a CacheEntry value object', async () => {
|
|
30
|
+
const original = makeEntry({
|
|
31
|
+
kind: 'APP_ROUTE',
|
|
32
|
+
body: Buffer.from([0x01, 0x02, 0x03, 0x04]),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const serialized = await jsonCacheValueSerializer.serialize(original);
|
|
36
|
+
const restored = await jsonCacheValueSerializer.deserialize(serialized);
|
|
37
|
+
|
|
38
|
+
const nested = (restored!.value as { body: Buffer }).body;
|
|
39
|
+
expect(Buffer.isBuffer(nested)).toBe(true);
|
|
40
|
+
expect(Array.from(nested)).toEqual([0x01, 0x02, 0x03, 0x04]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('round-trips a Map value back to a native Map instance', async () => {
|
|
44
|
+
const map = new Map<string, string>([
|
|
45
|
+
['k1', 'v1'],
|
|
46
|
+
['k2', 'v2'],
|
|
47
|
+
]);
|
|
48
|
+
const original = makeEntry(map);
|
|
49
|
+
|
|
50
|
+
const serialized = await jsonCacheValueSerializer.serialize(original);
|
|
51
|
+
const restored = await jsonCacheValueSerializer.deserialize(serialized);
|
|
52
|
+
|
|
53
|
+
expect(restored!.value).toBeInstanceOf(Map);
|
|
54
|
+
const restoredMap = restored!.value as Map<string, string>;
|
|
55
|
+
expect(restoredMap.size).toBe(2);
|
|
56
|
+
expect(restoredMap.get('k1')).toBe('v1');
|
|
57
|
+
expect(restoredMap.get('k2')).toBe('v2');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('is referentially stable so consumers can detect default usage', async () => {
|
|
61
|
+
// Importing twice should yield the same singleton; this guards against
|
|
62
|
+
// accidental "factory" refactors that would break reference equality.
|
|
63
|
+
const reimport = (await import('./serializer')).jsonCacheValueSerializer;
|
|
64
|
+
expect(reimport).toBe(jsonCacheValueSerializer);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('CacheValueSerializer custom implementations', () => {
|
|
69
|
+
it('invokes a synchronous custom serializer exactly once per call', async () => {
|
|
70
|
+
const serialize = vi.fn(
|
|
71
|
+
(value: CacheEntry) => `sync:${JSON.stringify(value)}`,
|
|
72
|
+
);
|
|
73
|
+
const deserialize = vi.fn(
|
|
74
|
+
(stored: string) =>
|
|
75
|
+
JSON.parse(stored.replace(/^sync:/, '')) as CacheEntry,
|
|
76
|
+
);
|
|
77
|
+
const custom: CacheValueSerializer = { serialize, deserialize };
|
|
78
|
+
|
|
79
|
+
const entry = makeEntry({ kind: 'FETCH', payload: 42 });
|
|
80
|
+
|
|
81
|
+
const out = await custom.serialize(entry);
|
|
82
|
+
expect(serialize).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(out.startsWith('sync:')).toBe(true);
|
|
84
|
+
|
|
85
|
+
const restored = await custom.deserialize(out);
|
|
86
|
+
expect(deserialize).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(restored).toEqual(entry);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('awaits an asynchronous custom serializer (Promise-returning serialize/deserialize)', async () => {
|
|
91
|
+
const custom: CacheValueSerializer = {
|
|
92
|
+
async serialize(value) {
|
|
93
|
+
// Simulate async work (e.g. zlib.brotliCompress promisified).
|
|
94
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
95
|
+
return `async:${JSON.stringify(value)}`;
|
|
96
|
+
},
|
|
97
|
+
async deserialize(stored) {
|
|
98
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
99
|
+
return JSON.parse(stored.replace(/^async:/, '')) as CacheEntry;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const entry = makeEntry({ kind: 'FETCH', payload: 'async-value' });
|
|
104
|
+
|
|
105
|
+
const out = await custom.serialize(entry);
|
|
106
|
+
expect(typeof out).toBe('string');
|
|
107
|
+
expect(out.startsWith('async:')).toBe(true);
|
|
108
|
+
|
|
109
|
+
const restored = await custom.deserialize(out);
|
|
110
|
+
expect(restored).toEqual(entry);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('treats a deserializer returning null as a cache miss (sync and async)', async () => {
|
|
114
|
+
const syncMiss: CacheValueSerializer = {
|
|
115
|
+
serialize: () => 'ignored',
|
|
116
|
+
deserialize: () => null,
|
|
117
|
+
};
|
|
118
|
+
const asyncMiss: CacheValueSerializer = {
|
|
119
|
+
serialize: async () => 'ignored',
|
|
120
|
+
deserialize: async () => null,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
expect(await syncMiss.deserialize('whatever')).toBeNull();
|
|
124
|
+
expect(await asyncMiss.deserialize('whatever')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('propagates synchronous serializer throws and asynchronous rejections to the caller', async () => {
|
|
128
|
+
const syncThrowing: CacheValueSerializer = {
|
|
129
|
+
serialize() {
|
|
130
|
+
throw new Error('sync serialize boom');
|
|
131
|
+
},
|
|
132
|
+
deserialize() {
|
|
133
|
+
throw new Error('sync deserialize boom');
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const asyncRejecting: CacheValueSerializer = {
|
|
137
|
+
async serialize() {
|
|
138
|
+
throw new Error('async serialize boom');
|
|
139
|
+
},
|
|
140
|
+
async deserialize() {
|
|
141
|
+
throw new Error('async deserialize boom');
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const entry = makeEntry({ kind: 'FETCH', payload: 'x' });
|
|
146
|
+
|
|
147
|
+
expect(() => syncThrowing.serialize(entry)).toThrow('sync serialize boom');
|
|
148
|
+
expect(() => syncThrowing.deserialize('x')).toThrow(
|
|
149
|
+
'sync deserialize boom',
|
|
150
|
+
);
|
|
151
|
+
await expect(asyncRejecting.serialize(entry)).rejects.toThrow(
|
|
152
|
+
'async serialize boom',
|
|
153
|
+
);
|
|
154
|
+
await expect(asyncRejecting.deserialize('x')).rejects.toThrow(
|
|
155
|
+
'async deserialize boom',
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('passes the exact stored string to deserialize (no pre-parsing by the caller)', async () => {
|
|
160
|
+
const serialize = (value: CacheEntry) =>
|
|
161
|
+
`wrapped(${JSON.stringify(value)})`;
|
|
162
|
+
const deserialize = vi.fn((stored: string) => {
|
|
163
|
+
// If the caller mutated the wire format, this would explode.
|
|
164
|
+
const m = /^wrapped\((.+)\)$/.exec(stored);
|
|
165
|
+
if (!m) return null;
|
|
166
|
+
return JSON.parse(m[1]) as CacheEntry;
|
|
167
|
+
});
|
|
168
|
+
const custom: CacheValueSerializer = { serialize, deserialize };
|
|
169
|
+
|
|
170
|
+
const entry = makeEntry({ kind: 'APP_PAGE', html: '<p>hi</p>' });
|
|
171
|
+
|
|
172
|
+
const wire = await custom.serialize(entry);
|
|
173
|
+
const restored = await custom.deserialize(wire);
|
|
174
|
+
|
|
175
|
+
expect(deserialize).toHaveBeenCalledWith(wire);
|
|
176
|
+
expect(restored).toEqual(entry);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable wire-format codec for Redis string values used by `RedisStringsHandler`.
|
|
3
|
+
*
|
|
4
|
+
* The serializer is the single point at which an in-memory `CacheEntry` becomes the
|
|
5
|
+
* string written to Redis (and back). Plugging in a custom serializer lets you add
|
|
6
|
+
* compression (gzip/brotli), encryption, or any other custom encoding without forking
|
|
7
|
+
* this package or losing the existing dedup / batch / keyspace features.
|
|
8
|
+
*
|
|
9
|
+
* Both `serialize` and `deserialize` may return either a value directly or a `Promise`,
|
|
10
|
+
* which enables non-blocking async codecs such as stream-based compression
|
|
11
|
+
* (`zlib.brotliCompress`) or encryption (`crypto.subtle`). Synchronous implementations
|
|
12
|
+
* continue to work unchanged - awaiting a plain value is a no-op.
|
|
13
|
+
*
|
|
14
|
+
* The default export {@link jsonCacheValueSerializer} is `JSON.stringify` /
|
|
15
|
+
* `JSON.parse` paired with {@link bufferAndMapReplacer} / {@link bufferAndMapReviver},
|
|
16
|
+
* so native `Buffer` and `Map` values inside a `CacheEntry` round-trip transparently
|
|
17
|
+
* (this is required for Next.js RSC payloads).
|
|
18
|
+
*
|
|
19
|
+
* Operational note: changing the serializer (or any of its parameters such as a
|
|
20
|
+
* compression level or encryption key) makes existing Redis keys unreadable, because
|
|
21
|
+
* the deserializer will fail or return `null` for entries written by the previous
|
|
22
|
+
* format. Either flush the affected keys, bump `keyPrefix`, or migrate values
|
|
23
|
+
* out-of-band before deploying a new serializer.
|
|
24
|
+
*/
|
|
25
|
+
import type { CacheEntry } from './RedisStringsHandler';
|
|
26
|
+
import { bufferAndMapReplacer, bufferAndMapReviver } from './utils/json';
|
|
27
|
+
|
|
28
|
+
export type CacheValueSerializer = {
|
|
29
|
+
/**
|
|
30
|
+
* Encode an in-memory `CacheEntry` into the string written to Redis.
|
|
31
|
+
* May return a `Promise` for async codecs (e.g. compression, encryption).
|
|
32
|
+
*/
|
|
33
|
+
serialize(value: CacheEntry): string | Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Decode a string read from Redis back into a `CacheEntry`.
|
|
36
|
+
* Returning `null` (or a `Promise<null>`) signals "treat as cache miss" -
|
|
37
|
+
* the handler will return `null` from `get()` without surfacing an error.
|
|
38
|
+
* May return a `Promise` for async codecs.
|
|
39
|
+
*/
|
|
40
|
+
deserialize(stored: string): CacheEntry | null | Promise<CacheEntry | null>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default serializer used by `RedisStringsHandler` when no `valueSerializer` is
|
|
45
|
+
* configured. Wraps `JSON.stringify` / `JSON.parse` with this package's
|
|
46
|
+
* {@link bufferAndMapReplacer} / {@link bufferAndMapReviver} so native `Buffer`
|
|
47
|
+
* and `Map` values inside a `CacheEntry` survive the round-trip.
|
|
48
|
+
*
|
|
49
|
+
* Exported as a singleton so consumers can compare against the default by
|
|
50
|
+
* reference (e.g. to detect that no custom serializer was configured).
|
|
51
|
+
*/
|
|
52
|
+
export const jsonCacheValueSerializer: CacheValueSerializer = {
|
|
53
|
+
serialize(value) {
|
|
54
|
+
return JSON.stringify(value, bufferAndMapReplacer);
|
|
55
|
+
},
|
|
56
|
+
deserialize(stored) {
|
|
57
|
+
return JSON.parse(stored, bufferAndMapReviver) as CacheEntry | null;
|
|
58
|
+
},
|
|
59
|
+
};
|