@trieb.work/nextjs-turbo-redis-cache 1.14.0 → 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.
@@ -76,7 +76,7 @@ jobs:
76
76
  run: cd test/integration/${{ matrix.next-test-app }} && pnpm build
77
77
 
78
78
  - name: Run tests
79
- run: pnpm test
79
+ run: pnpm test:ci
80
80
  env:
81
81
  SKIP_BUILD: true
82
82
  NEXT_TEST_APP: ${{ matrix.next-test-app }}
@@ -121,3 +121,50 @@ jobs:
121
121
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # NPM token for publishing
122
122
  run: |
123
123
  npx semantic-release --dry-run
124
+
125
+ build-id-prefix:
126
+ runs-on: ubuntu-latest
127
+ permissions:
128
+ contents: read
129
+ steps:
130
+ - name: Checkout code
131
+ uses: actions/checkout@v6
132
+
133
+ - name: Install pnpm
134
+ run: corepack enable
135
+
136
+ - name: Setup Node.js
137
+ uses: actions/setup-node@v6
138
+ with:
139
+ node-version: '22'
140
+ cache: 'pnpm'
141
+
142
+ - name: Install dependencies
143
+ run: pnpm install --ignore-scripts
144
+
145
+ - name: Run lint
146
+ run: pnpm lint
147
+
148
+ - name: Build project
149
+ run: pnpm build
150
+
151
+ - name: Start Redis
152
+ uses: supercharge/redis-github-action@1.8.0
153
+ with:
154
+ redis-version: '7'
155
+ redis-port: 6379
156
+
157
+ - name: Install redis-cli
158
+ run: sudo apt-get update && sudo apt-get install -y redis-tools
159
+
160
+ - name: Configure Redis Keyspace Notifications
161
+ run: redis-cli config set notify-keyspace-events Exe
162
+
163
+ - name: Install Integration Test Project
164
+ run: cd test/integration/next-app-15-4-7 && pnpm install
165
+
166
+ - name: Build Integration Test Project
167
+ run: cd test/integration/next-app-15-4-7 && pnpm build
168
+
169
+ - name: Run BUILD_ID integration test
170
+ run: pnpm test:integration:build-id-prefix
@@ -0,0 +1,38 @@
1
+ name: Deploy GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ pages: write
12
+ id-token: write
13
+
14
+ concurrency:
15
+ group: pages
16
+ cancel-in-progress: true
17
+
18
+ jobs:
19
+ deploy:
20
+ runs-on: ubuntu-latest
21
+ environment:
22
+ name: github-pages
23
+ url: ${{ steps.deployment.outputs.page_url }}
24
+ steps:
25
+ - name: Checkout code
26
+ uses: actions/checkout@v6
27
+
28
+ - name: Setup Pages
29
+ uses: actions/configure-pages@v5
30
+
31
+ - name: Upload static site artifact
32
+ uses: actions/upload-pages-artifact@v4
33
+ with:
34
+ path: docs
35
+
36
+ - name: Deploy to GitHub Pages
37
+ id: deployment
38
+ uses: actions/deploy-pages@v4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # [1.15.0](https://github.com/trieb-work/nextjs-turbo-redis-cache/compare/v1.14.1...v1.15.0) (2026-05-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **serializer:** treat deserialize failures as cache misses ([a33b2c8](https://github.com/trieb-work/nextjs-turbo-redis-cache/commit/a33b2c8dd5d94686a451ed004b05a7883131f623))
7
+
8
+
9
+ ### Features
10
+
11
+ * **serializer:** add pluggable valueSerializer for compression and custom encoding ([f55d0a3](https://github.com/trieb-work/nextjs-turbo-redis-cache/commit/f55d0a3ccb9f007d231826e27bca04785bcd2c50))
12
+
13
+ ## [1.14.1](https://github.com/trieb-work/nextjs-turbo-redis-cache/compare/v1.14.0...v1.14.1) (2026-05-15)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * treat Redis GET abort as cache miss ([#65](https://github.com/trieb-work/nextjs-turbo-redis-cache/issues/65)) ([52faf38](https://github.com/trieb-work/nextjs-turbo-redis-cache/commit/52faf38e267db3188a09a8495750d77c3b693ba8))
19
+
1
20
  # [1.14.0](https://github.com/trieb-work/nextjs-turbo-redis-cache/compare/v1.13.0...v1.14.0) (2026-05-08)
2
21
 
3
22
 
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # nextjs-turbo-redis-cache
1
+ # nextjs-turbo-redis-cache - Next.js Cache Handler
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@trieb.work/nextjs-turbo-redis-cache.svg)](https://www.npmjs.com/package/@trieb.work/nextjs-turbo-redis-cache)
4
4
  ![Turbo redis cache image](https://github.com/user-attachments/assets/4103191e-4f4d-4139-a519-0b5bfab3e8b4)
5
5
 
6
- The ultimate Redis caching solution for Next.js 15 / 16 and the app router. Built for production-ready, large-scale projects, it delivers unparalleled performance and efficiency with features tailored for high-traffic applications. This package has been created after extensibly testing the @neshca package and finding several major issues with it.
6
+ The ultimate Redis Cache Handler for Next.js 15 / 16 and the app router. Built for production-ready, large-scale projects, it delivers unparalleled performance and efficiency with features tailored for high-traffic applications. This package has been created after extensibly testing the @neshca package and finding several major issues with it.
7
7
 
8
8
  Key Features:
9
9
 
@@ -30,7 +30,7 @@ Tested versions are:
30
30
  - Nextjs 16.2.3 + redis client 4.7.0 (cacheComponents: false)
31
31
  - Nextjs 16.2.3 + redis client 4.7.0 (cacheComponents: true)
32
32
 
33
- Currently PPR, 'use cache', cacheLife and cacheTag are not tested. Use these operations with caution and your own risk. [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) are supported experimentally (Next.js 16+).
33
+ _Cache Components_ (Next.js 16+) are fully supported. Automated test coverage includes `'use cache'`, `cacheTag`, and `cacheLife` flows in the Cache Components integration suite.
34
34
 
35
35
  For Cache Components, see the "Cache Components handler (Next.js 16+)" section below.
36
36
 
@@ -153,6 +153,153 @@ A working example of above can be found in the `test/integration/next-app-custom
153
153
  | socketOptions | Redis client socket options for TLS/SSL configuration (e.g., `{ tls: true, rejectUnauthorized: false }`) | `{ connectTimeout: timeoutMs }` |
154
154
  | clientOptions | Additional Redis client options (e.g., username, password) | `undefined` |
155
155
  | killContainerOnErrorThreshold | Number of consecutive errors before the container is killed. Set to 0 to disable. | `Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0` |
156
+ | valueSerializer | Pluggable wire-format codec for Redis string values (compression, encryption, custom encoding). See [Custom value serializer](#custom-value-serializer-compression-encryption). | `jsonCacheValueSerializer` (`JSON.stringify` with built-in `Buffer` and `Map` encoding) |
157
+
158
+ ## Custom value serializer (compression, encryption)
159
+
160
+ By default cache entries are stored as `JSON.stringify(...)` with built-in
161
+ `Buffer` and `Map` encoding. For workloads where the encoded payload is large
162
+ (big RSC trees, large fetch responses) or sensitive (PII), you can plug in a
163
+ custom codec - gzip, brotli, AES, anything - via the `valueSerializer` option,
164
+ without forking this package or losing the existing dedup / batch / keyspace
165
+ features.
166
+
167
+ ### Contract
168
+
169
+ - `serialize(entry)` is called on every `set()` with the in-memory `CacheEntry`. It must return the string written to Redis, or a `Promise<string>` for async codecs.
170
+ - `deserialize(stored)` is called on every cache hit with the exact string read from Redis. It must return a `CacheEntry`, `null`, or a `Promise` of either. Returning `null` is treated as a cache miss - the handler returns `null` from `get()` without surfacing an error.
171
+ - Both methods may be async, enabling non-blocking codecs such as `zlib.brotliCompress` or `crypto.subtle` that don't block the Node.js event loop. Synchronous implementations continue to work unchanged.
172
+ - Only the main cache-entry storage path is routed through the serializer. Internal structures (`__sharedTags__`, `__revalidated_tags__`, `inMemoryDeduplicationCache`) are not affected.
173
+
174
+ ### Default export for reuse
175
+
176
+ The default serializer is exported so you can wrap it (e.g. compress + JSON
177
+ fallback) or compare against it by reference to detect that no custom
178
+ serializer was configured. The underlying `Buffer` / `Map` JSON helpers used by
179
+ the default are also exported for use inside custom codecs:
180
+
181
+ ```ts
182
+ import {
183
+ jsonCacheValueSerializer,
184
+ bufferAndMapReplacer,
185
+ bufferAndMapReviver,
186
+ } from '@trieb.work/nextjs-turbo-redis-cache';
187
+ ```
188
+
189
+ > **Important:** a plain `JSON.stringify(value)` does not preserve native
190
+ > `Buffer` or `Map` values inside a cache entry. RSC payloads contain
191
+ > `Buffer`s. If you write a custom codec that doesn't use the exported
192
+ > `bufferAndMapReplacer` / `bufferAndMapReviver` (or doesn't wrap
193
+ > `jsonCacheValueSerializer`), expect those to come back as plain objects.
194
+
195
+ ### Example: gzip (sync)
196
+
197
+ Wraps `bufferAndMapReplacer` / `bufferAndMapReviver` so native `Buffer` and
198
+ `Map` values inside the cache entry round-trip unchanged. `gzipSync` /
199
+ `gunzipSync` block the event loop - prefer the async brotli example below for
200
+ hot workloads.
201
+
202
+ ```ts
203
+ import { gzipSync, gunzipSync } from 'node:zlib';
204
+ import {
205
+ RedisStringsHandler,
206
+ bufferAndMapReplacer,
207
+ bufferAndMapReviver,
208
+ } from '@trieb.work/nextjs-turbo-redis-cache';
209
+
210
+ const gzipSerializer = {
211
+ serialize(value) {
212
+ const json = JSON.stringify(value, bufferAndMapReplacer);
213
+ return gzipSync(json).toString('base64');
214
+ },
215
+ deserialize(stored) {
216
+ const buf = Buffer.from(stored, 'base64');
217
+ return JSON.parse(gunzipSync(buf).toString('utf8'), bufferAndMapReviver);
218
+ },
219
+ };
220
+
221
+ export default class CustomizedCacheHandler {
222
+ constructor() {
223
+ this.handler = new RedisStringsHandler({
224
+ valueSerializer: gzipSerializer,
225
+ });
226
+ }
227
+ // ... delegate get/set/revalidateTag/resetRequestCache to this.handler
228
+ }
229
+ ```
230
+
231
+ ### Example: brotli (async, non-blocking)
232
+
233
+ Uses `promisify(brotliCompress)` and `promisify(brotliDecompress)` so
234
+ compression runs on a worker thread and doesn't block the event loop.
235
+
236
+ ```ts
237
+ import { promisify } from 'node:util';
238
+ import { brotliCompress, brotliDecompress } from 'node:zlib';
239
+ import {
240
+ RedisStringsHandler,
241
+ bufferAndMapReplacer,
242
+ bufferAndMapReviver,
243
+ } from '@trieb.work/nextjs-turbo-redis-cache';
244
+
245
+ const brotliCompressAsync = promisify(brotliCompress);
246
+ const brotliDecompressAsync = promisify(brotliDecompress);
247
+
248
+ const brotliSerializer = {
249
+ async serialize(value) {
250
+ const json = JSON.stringify(value, bufferAndMapReplacer);
251
+ const compressed = await brotliCompressAsync(Buffer.from(json, 'utf8'));
252
+ return compressed.toString('base64');
253
+ },
254
+ async deserialize(stored) {
255
+ const buf = Buffer.from(stored, 'base64');
256
+ const decompressed = await brotliDecompressAsync(buf);
257
+ return JSON.parse(decompressed.toString('utf8'), bufferAndMapReviver);
258
+ },
259
+ };
260
+
261
+ export default class CustomizedCacheHandler {
262
+ constructor() {
263
+ this.handler = new RedisStringsHandler({
264
+ valueSerializer: brotliSerializer,
265
+ });
266
+ }
267
+ // ... delegate get/set/revalidateTag/resetRequestCache to this.handler
268
+ }
269
+ ```
270
+
271
+ ### Operational notes
272
+
273
+ - **Changing the serializer makes existing Redis keys unreadable.** Any change
274
+ to the codec - swapping JSON for gzip, bumping a compression level, rotating
275
+ an encryption key - means previously written entries can no longer be
276
+ decoded. Either flush the affected keys (`FLUSHDB`, or scoped `UNLINK` of
277
+ `keyPrefix*`) or bump `keyPrefix` before deploying so old and new entries
278
+ live in disjoint keyspaces.
279
+ - **`Buffer` and `Map` encoding is built into the default.** The default
280
+ `jsonCacheValueSerializer` uses this package's `bufferAndMapReplacer` /
281
+ `bufferAndMapReviver` so native `Buffer` and `Map` values inside a
282
+ `CacheEntry` round-trip transparently. If you write a custom serializer that
283
+ doesn't reuse those (e.g. plain `JSON.stringify` over a binary payload),
284
+ expect RSC payload `Buffer`s to come back as plain `{ type: 'Buffer', data:
285
+ [...] }` objects. Reuse the exported default inside your codec, or use the
286
+ exported `bufferAndMapReplacer` / `bufferAndMapReviver`, to keep that
287
+ behavior.
288
+ - **The in-memory deduplication cache stores the wire-format string verbatim.**
289
+ When `redisGetDeduplication` is enabled (default), the value seeded after
290
+ `set()` and returned to subsequent `get()` calls is the exact string
291
+ produced by `serialize()`. With a compressing or encrypting codec that
292
+ means every dedup hit re-runs `deserialize()` (i.e. re-decompresses or
293
+ re-decrypts). For very hot keys, evaluate whether the per-hit codec cost
294
+ outweighs the Redis round-trip the dedup is saving.
295
+ - **Other internal trieb structures are not affected by `valueSerializer`.**
296
+ Only the main cache entries written by `set()` and read by `get()` go
297
+ through the codec. The shared-tags map and the revalidated-tags map are
298
+ untouched.
299
+ - **Cache Components handler is out of scope for now.** This option only
300
+ affects `RedisStringsHandler`. The Next.js 16+ `CacheComponentsHandler` does
301
+ not currently route through `valueSerializer`; that's a candidate follow-up.
302
+
156
303
 
157
304
  ## TLS Configuration
158
305
 
@@ -226,6 +373,13 @@ To run all tests you can use the following command:
226
373
  pnpm build && pnpm test
227
374
  ```
228
375
 
376
+ For CI, we use dedicated scripts:
377
+
378
+ ```bash
379
+ pnpm test:ci
380
+ pnpm test:integration:build-id-prefix
381
+ ```
382
+
229
383
  Folder layout / runners:
230
384
 
231
385
  - **Vitest** (unit + integration) lives in `src/**/*.test.ts(x)` and `test/**`.
@@ -248,6 +402,12 @@ To run integration tests you can use the following command:
248
402
  pnpm build && pnpm test:integration
249
403
  ```
250
404
 
405
+ To run the BUILD_ID integration test independently:
406
+
407
+ ```bash
408
+ pnpm build && pnpm test:integration:build-id-prefix
409
+ ```
410
+
251
411
  ### E2E tests (Playwright)
252
412
 
253
413
  To run Playwright tests (`tests/**`) you can use:
package/dist/index.d.mts CHANGED
@@ -1,5 +1,55 @@
1
1
  import { RedisClientOptions } from 'redis';
2
2
 
3
+ /**
4
+ * Pluggable wire-format codec for Redis string values used by `RedisStringsHandler`.
5
+ *
6
+ * The serializer is the single point at which an in-memory `CacheEntry` becomes the
7
+ * string written to Redis (and back). Plugging in a custom serializer lets you add
8
+ * compression (gzip/brotli), encryption, or any other custom encoding without forking
9
+ * this package or losing the existing dedup / batch / keyspace features.
10
+ *
11
+ * Both `serialize` and `deserialize` may return either a value directly or a `Promise`,
12
+ * which enables non-blocking async codecs such as stream-based compression
13
+ * (`zlib.brotliCompress`) or encryption (`crypto.subtle`). Synchronous implementations
14
+ * continue to work unchanged - awaiting a plain value is a no-op.
15
+ *
16
+ * The default export {@link jsonCacheValueSerializer} is `JSON.stringify` /
17
+ * `JSON.parse` paired with {@link bufferAndMapReplacer} / {@link bufferAndMapReviver},
18
+ * so native `Buffer` and `Map` values inside a `CacheEntry` round-trip transparently
19
+ * (this is required for Next.js RSC payloads).
20
+ *
21
+ * Operational note: changing the serializer (or any of its parameters such as a
22
+ * compression level or encryption key) makes existing Redis keys unreadable, because
23
+ * the deserializer will fail or return `null` for entries written by the previous
24
+ * format. Either flush the affected keys, bump `keyPrefix`, or migrate values
25
+ * out-of-band before deploying a new serializer.
26
+ */
27
+
28
+ 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
+ * Default serializer used by `RedisStringsHandler` when no `valueSerializer` is
44
+ * configured. Wraps `JSON.stringify` / `JSON.parse` with this package's
45
+ * {@link bufferAndMapReplacer} / {@link bufferAndMapReviver} so native `Buffer`
46
+ * and `Map` values inside a `CacheEntry` survive the round-trip.
47
+ *
48
+ * Exported as a singleton so consumers can compare against the default by
49
+ * reference (e.g. to detect that no custom serializer was configured).
50
+ */
51
+ declare const jsonCacheValueSerializer: CacheValueSerializer;
52
+
3
53
  type CacheEntry = {
4
54
  value: unknown;
5
55
  lastModified: number;
@@ -66,6 +116,27 @@ type CreateRedisStringsHandlerOptions = {
66
116
  * @example { username: 'user', password: 'pass' }
67
117
  */
68
118
  clientOptions?: Omit<RedisClientOptions, 'url' | 'database' | 'socket'>;
119
+ /** Pluggable wire-format codec for Redis string values. Lets you plug in
120
+ * compression (gzip/brotli), encryption, or any other custom encoding without
121
+ * forking this package or losing the existing dedup / batch / keyspace features.
122
+ *
123
+ * Both `serialize` and `deserialize` may return a `Promise`, enabling
124
+ * non-blocking async codecs (e.g. `zlib.brotliCompress`) that don't block the
125
+ * Node.js event loop. Synchronous implementations continue to work unchanged.
126
+ *
127
+ * Only the main cache-entry storage path is routed through the serializer.
128
+ * The shared-tags map and the revalidated-tags map are not. The in-memory
129
+ * deduplication cache stores the wire-format string verbatim - its contents
130
+ * change with the serializer, but the cache itself is not re-encoded.
131
+ *
132
+ * Operational note: changing the serializer (or any of its parameters such as
133
+ * a compression level or encryption key) makes existing Redis keys unreadable.
134
+ * Either flush the affected keys or bump `keyPrefix` before deploying.
135
+ *
136
+ * @default jsonCacheValueSerializer (JSON.stringify with bufferAndMapReplacer
137
+ * so native Buffer and Map values inside a CacheEntry round-trip transparently)
138
+ */
139
+ valueSerializer?: CacheValueSerializer;
69
140
  };
70
141
  declare class RedisStringsHandler {
71
142
  private client;
@@ -82,7 +153,8 @@ declare class RedisStringsHandler {
82
153
  private defaultStaleAge;
83
154
  private estimateExpireAge;
84
155
  private killContainerOnErrorThreshold;
85
- constructor({ redisUrl, database, keyPrefix, sharedTagsKey, getTimeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, killContainerOnErrorThreshold, socketOptions, clientOptions, }: CreateRedisStringsHandlerOptions);
156
+ private valueSerializer;
157
+ constructor({ redisUrl, database, keyPrefix, sharedTagsKey, getTimeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, killContainerOnErrorThreshold, socketOptions, clientOptions, valueSerializer, }: CreateRedisStringsHandlerOptions);
86
158
  resetRequestCache(): void;
87
159
  private clientReadyCalls;
88
160
  private assertClientIsReady;
@@ -152,6 +224,9 @@ declare class CachedHandler {
152
224
  resetRequestCache(...args: Parameters<RedisStringsHandler['resetRequestCache']>): ReturnType<RedisStringsHandler['resetRequestCache']>;
153
225
  }
154
226
 
227
+ declare function bufferAndMapReviver(_: string, value: any): any;
228
+ declare function bufferAndMapReplacer(_: string, value: any): any;
229
+
155
230
  interface CacheComponentsEntry {
156
231
  value: ReadableStream<Uint8Array>;
157
232
  tags: string[];
@@ -175,4 +250,4 @@ type CreateCacheComponentsHandlerOptions = CreateRedisStringsHandlerOptions & {
175
250
  declare function getRedisCacheComponentsHandler(options?: CreateCacheComponentsHandlerOptions): CacheComponentsHandler;
176
251
  declare const redisCacheHandler: CacheComponentsHandler;
177
252
 
178
- export { type CreateRedisStringsHandlerOptions, RedisStringsHandler, CachedHandler as default, getRedisCacheComponentsHandler, redisCacheHandler };
253
+ export { type CacheValueSerializer, type CreateRedisStringsHandlerOptions, RedisStringsHandler, bufferAndMapReplacer, bufferAndMapReviver, CachedHandler as default, getRedisCacheComponentsHandler, jsonCacheValueSerializer, redisCacheHandler };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,55 @@
1
1
  import { RedisClientOptions } from 'redis';
2
2
 
3
+ /**
4
+ * Pluggable wire-format codec for Redis string values used by `RedisStringsHandler`.
5
+ *
6
+ * The serializer is the single point at which an in-memory `CacheEntry` becomes the
7
+ * string written to Redis (and back). Plugging in a custom serializer lets you add
8
+ * compression (gzip/brotli), encryption, or any other custom encoding without forking
9
+ * this package or losing the existing dedup / batch / keyspace features.
10
+ *
11
+ * Both `serialize` and `deserialize` may return either a value directly or a `Promise`,
12
+ * which enables non-blocking async codecs such as stream-based compression
13
+ * (`zlib.brotliCompress`) or encryption (`crypto.subtle`). Synchronous implementations
14
+ * continue to work unchanged - awaiting a plain value is a no-op.
15
+ *
16
+ * The default export {@link jsonCacheValueSerializer} is `JSON.stringify` /
17
+ * `JSON.parse` paired with {@link bufferAndMapReplacer} / {@link bufferAndMapReviver},
18
+ * so native `Buffer` and `Map` values inside a `CacheEntry` round-trip transparently
19
+ * (this is required for Next.js RSC payloads).
20
+ *
21
+ * Operational note: changing the serializer (or any of its parameters such as a
22
+ * compression level or encryption key) makes existing Redis keys unreadable, because
23
+ * the deserializer will fail or return `null` for entries written by the previous
24
+ * format. Either flush the affected keys, bump `keyPrefix`, or migrate values
25
+ * out-of-band before deploying a new serializer.
26
+ */
27
+
28
+ 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
+ * Default serializer used by `RedisStringsHandler` when no `valueSerializer` is
44
+ * configured. Wraps `JSON.stringify` / `JSON.parse` with this package's
45
+ * {@link bufferAndMapReplacer} / {@link bufferAndMapReviver} so native `Buffer`
46
+ * and `Map` values inside a `CacheEntry` survive the round-trip.
47
+ *
48
+ * Exported as a singleton so consumers can compare against the default by
49
+ * reference (e.g. to detect that no custom serializer was configured).
50
+ */
51
+ declare const jsonCacheValueSerializer: CacheValueSerializer;
52
+
3
53
  type CacheEntry = {
4
54
  value: unknown;
5
55
  lastModified: number;
@@ -66,6 +116,27 @@ type CreateRedisStringsHandlerOptions = {
66
116
  * @example { username: 'user', password: 'pass' }
67
117
  */
68
118
  clientOptions?: Omit<RedisClientOptions, 'url' | 'database' | 'socket'>;
119
+ /** Pluggable wire-format codec for Redis string values. Lets you plug in
120
+ * compression (gzip/brotli), encryption, or any other custom encoding without
121
+ * forking this package or losing the existing dedup / batch / keyspace features.
122
+ *
123
+ * Both `serialize` and `deserialize` may return a `Promise`, enabling
124
+ * non-blocking async codecs (e.g. `zlib.brotliCompress`) that don't block the
125
+ * Node.js event loop. Synchronous implementations continue to work unchanged.
126
+ *
127
+ * Only the main cache-entry storage path is routed through the serializer.
128
+ * The shared-tags map and the revalidated-tags map are not. The in-memory
129
+ * deduplication cache stores the wire-format string verbatim - its contents
130
+ * change with the serializer, but the cache itself is not re-encoded.
131
+ *
132
+ * Operational note: changing the serializer (or any of its parameters such as
133
+ * a compression level or encryption key) makes existing Redis keys unreadable.
134
+ * Either flush the affected keys or bump `keyPrefix` before deploying.
135
+ *
136
+ * @default jsonCacheValueSerializer (JSON.stringify with bufferAndMapReplacer
137
+ * so native Buffer and Map values inside a CacheEntry round-trip transparently)
138
+ */
139
+ valueSerializer?: CacheValueSerializer;
69
140
  };
70
141
  declare class RedisStringsHandler {
71
142
  private client;
@@ -82,7 +153,8 @@ declare class RedisStringsHandler {
82
153
  private defaultStaleAge;
83
154
  private estimateExpireAge;
84
155
  private killContainerOnErrorThreshold;
85
- constructor({ redisUrl, database, keyPrefix, sharedTagsKey, getTimeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, killContainerOnErrorThreshold, socketOptions, clientOptions, }: CreateRedisStringsHandlerOptions);
156
+ private valueSerializer;
157
+ constructor({ redisUrl, database, keyPrefix, sharedTagsKey, getTimeoutMs, revalidateTagQuerySize, avgResyncIntervalMs, redisGetDeduplication, inMemoryCachingTime, defaultStaleAge, estimateExpireAge, killContainerOnErrorThreshold, socketOptions, clientOptions, valueSerializer, }: CreateRedisStringsHandlerOptions);
86
158
  resetRequestCache(): void;
87
159
  private clientReadyCalls;
88
160
  private assertClientIsReady;
@@ -152,6 +224,9 @@ declare class CachedHandler {
152
224
  resetRequestCache(...args: Parameters<RedisStringsHandler['resetRequestCache']>): ReturnType<RedisStringsHandler['resetRequestCache']>;
153
225
  }
154
226
 
227
+ declare function bufferAndMapReviver(_: string, value: any): any;
228
+ declare function bufferAndMapReplacer(_: string, value: any): any;
229
+
155
230
  interface CacheComponentsEntry {
156
231
  value: ReadableStream<Uint8Array>;
157
232
  tags: string[];
@@ -175,4 +250,4 @@ type CreateCacheComponentsHandlerOptions = CreateRedisStringsHandlerOptions & {
175
250
  declare function getRedisCacheComponentsHandler(options?: CreateCacheComponentsHandlerOptions): CacheComponentsHandler;
176
251
  declare const redisCacheHandler: CacheComponentsHandler;
177
252
 
178
- export { type CreateRedisStringsHandlerOptions, RedisStringsHandler, CachedHandler as default, getRedisCacheComponentsHandler, redisCacheHandler };
253
+ export { type CacheValueSerializer, type CreateRedisStringsHandlerOptions, RedisStringsHandler, bufferAndMapReplacer, bufferAndMapReviver, CachedHandler as default, getRedisCacheComponentsHandler, jsonCacheValueSerializer, redisCacheHandler };
package/dist/index.js CHANGED
@@ -31,8 +31,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  RedisStringsHandler: () => RedisStringsHandler,
34
+ bufferAndMapReplacer: () => bufferAndMapReplacer,
35
+ bufferAndMapReviver: () => bufferAndMapReviver,
34
36
  default: () => index_default,
35
37
  getRedisCacheComponentsHandler: () => getRedisCacheComponentsHandler,
38
+ jsonCacheValueSerializer: () => jsonCacheValueSerializer,
36
39
  redisCacheHandler: () => redisCacheHandler
37
40
  });
38
41
  module.exports = __toCommonJS(index_exports);
@@ -487,6 +490,16 @@ function bufferAndMapReplacer(_, value) {
487
490
  return value;
488
491
  }
489
492
 
493
+ // src/serializer.ts
494
+ var jsonCacheValueSerializer = {
495
+ serialize(value) {
496
+ return JSON.stringify(value, bufferAndMapReplacer);
497
+ },
498
+ deserialize(stored) {
499
+ return JSON.parse(stored, bufferAndMapReviver);
500
+ }
501
+ };
502
+
490
503
  // src/RedisStringsHandler.ts
491
504
  function redisErrorHandler(debugInfo, redisCommandResult) {
492
505
  const beforeTimestamp = performance.now();
@@ -501,6 +514,11 @@ function redisErrorHandler(debugInfo, redisCommandResult) {
501
514
  throw error;
502
515
  });
503
516
  }
517
+ function isAbortError(error) {
518
+ if (!error || typeof error !== "object") return false;
519
+ const err = error;
520
+ return err.name === "AbortError" || err.code === "ABORT_ERR";
521
+ }
504
522
  if (process.env.DEBUG_CACHE_HANDLER) {
505
523
  setInterval(() => {
506
524
  const start = performance.now();
@@ -533,7 +551,8 @@ var RedisStringsHandler = class {
533
551
  estimateExpireAge = (staleAge) => process.env.VERCEL_ENV === "production" ? staleAge * 2 : staleAge * 1.2,
534
552
  killContainerOnErrorThreshold = process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD ? Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0 : 0,
535
553
  socketOptions,
536
- clientOptions
554
+ clientOptions,
555
+ valueSerializer = jsonCacheValueSerializer
537
556
  }) {
538
557
  this.clientReadyCalls = 0;
539
558
  try {
@@ -544,6 +563,7 @@ var RedisStringsHandler = class {
544
563
  this.estimateExpireAge = estimateExpireAge;
545
564
  this.killContainerOnErrorThreshold = killContainerOnErrorThreshold;
546
565
  this.getTimeoutMs = getTimeoutMs;
566
+ this.valueSerializer = valueSerializer;
547
567
  try {
548
568
  this.client = (0, import_redis.createClient)({
549
569
  url: redisUrl,
@@ -692,12 +712,18 @@ var RedisStringsHandler = class {
692
712
  debug("green", "RedisStringsHandler.get() called with", key, ctx);
693
713
  await this.assertClientIsReady();
694
714
  const clientGet = this.redisGetDeduplication ? this.deduplicatedRedisGet(key) : this.redisGet;
715
+ const redisGetOperation = clientGet(
716
+ (0, import_redis.commandOptions)({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
717
+ this.keyPrefix + key
718
+ ).catch((error) => {
719
+ if (isAbortError(error)) {
720
+ return null;
721
+ }
722
+ throw error;
723
+ });
695
724
  const serializedCacheEntry = await redisErrorHandler(
696
725
  "RedisStringsHandler.get(), operation: get" + (this.redisGetDeduplication ? "deduplicated" : "") + " " + this.getTimeoutMs + "ms " + this.keyPrefix + " " + key,
697
- clientGet(
698
- (0, import_redis.commandOptions)({ signal: AbortSignal.timeout(this.getTimeoutMs) }),
699
- this.keyPrefix + key
700
- )
726
+ redisGetOperation
701
727
  );
702
728
  debug(
703
729
  "green",
@@ -707,10 +733,17 @@ var RedisStringsHandler = class {
707
733
  if (!serializedCacheEntry) {
708
734
  return null;
709
735
  }
710
- const cacheEntry = JSON.parse(
711
- serializedCacheEntry,
712
- bufferAndMapReviver
713
- );
736
+ let cacheEntry;
737
+ try {
738
+ cacheEntry = await this.valueSerializer.deserialize(serializedCacheEntry);
739
+ } catch (err) {
740
+ console.warn(
741
+ "RedisStringsHandler.get() valueSerializer.deserialize failed, treating as cache miss",
742
+ this.keyPrefix + key,
743
+ err
744
+ );
745
+ return null;
746
+ }
714
747
  debug(
715
748
  "green",
716
749
  "RedisStringsHandler.get() finished with result (cacheEntry)",
@@ -820,10 +853,7 @@ var RedisStringsHandler = class {
820
853
  tags: ctx?.tags || [],
821
854
  value: data
822
855
  };
823
- const serializedCacheEntry = JSON.stringify(
824
- cacheEntry,
825
- bufferAndMapReplacer
826
- );
856
+ const serializedCacheEntry = await this.valueSerializer.serialize(cacheEntry);
827
857
  if (this.redisGetDeduplication) {
828
858
  this.redisDeduplicationHandler.seedRequestReturn(
829
859
  key,
@@ -1359,7 +1389,10 @@ var index_default = CachedHandler;
1359
1389
  // Annotate the CommonJS export names for ESM import in node:
1360
1390
  0 && (module.exports = {
1361
1391
  RedisStringsHandler,
1392
+ bufferAndMapReplacer,
1393
+ bufferAndMapReviver,
1362
1394
  getRedisCacheComponentsHandler,
1395
+ jsonCacheValueSerializer,
1363
1396
  redisCacheHandler
1364
1397
  });
1365
1398
  //# sourceMappingURL=index.js.map