@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.
@@ -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>
@@ -0,0 +1,4 @@
1
+ User-agent: *
2
+ Allow: /
3
+
4
+ Sitemap: https://trieb-work.github.io/nextjs-turbo-redis-cache/sitemap.xml
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://trieb-work.github.io/nextjs-turbo-redis-cache/</loc>
5
+ <changefreq>weekly</changefreq>
6
+ <priority>1.0</priority>
7
+ </url>
8
+ </urlset>
@@ -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.14.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": "Designed for speed, scalability, and optimized performance, nextjs-turbo-redis-cache is your go-to solution for Next.js caching in demanding production environments.",
59
+ "author": "TRWK <info@trwk.de>",
60
60
  "license": "ISC",
61
- "description": "Next.js redis cache handler",
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 { bufferAndMapReviver, bufferAndMapReplacer } from './utils/json';
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
- const cacheEntry: CacheEntry | null = JSON.parse(
422
- serializedCacheEntry,
423
- bufferAndMapReviver,
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. JSON parsing of cache entries
524
- // 3. Tag validation and cleanup
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 = JSON.stringify(
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
+ };