@trieb.work/nextjs-turbo-redis-cache 1.15.1 → 1.16.1

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.
Files changed (31) hide show
  1. package/.github/workflows/ci.yml +45 -0
  2. package/.github/workflows/release.yml +9 -1
  3. package/CHANGELOG.md +28 -0
  4. package/README.md +13 -2
  5. package/dist/index.d.mts +71 -45
  6. package/dist/index.d.ts +71 -45
  7. package/dist/index.js +45 -8
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +45 -8
  10. package/dist/index.mjs.map +1 -1
  11. package/docs/index.html +7 -6
  12. package/package.json +2 -1
  13. package/src/CacheComponentsHandler.ts +35 -2
  14. package/src/RedisStringsHandler.ts +125 -66
  15. package/test/README.md +21 -6
  16. package/test/nextjs-test-projects/next-pages-16-2-6/README.md +16 -0
  17. package/test/nextjs-test-projects/next-pages-16-2-6/eslint.config.mjs +18 -0
  18. package/test/nextjs-test-projects/next-pages-16-2-6/next.config.ts +7 -0
  19. package/test/nextjs-test-projects/next-pages-16-2-6/package.json +26 -0
  20. package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +3896 -0
  21. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/api/revalidate.ts +24 -0
  22. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/index.tsx +11 -0
  23. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/isr/[slug].tsx +49 -0
  24. package/test/nextjs-test-projects/next-pages-16-2-6/src/pages/static-forever.tsx +20 -0
  25. package/test/nextjs-test-projects/next-pages-16-2-6/tsconfig.json +34 -0
  26. package/test/vitest/integration/cache-components/redis-kill-reconnect.test.ts +6 -0
  27. package/test/vitest/integration/cache-components/scripts/redis-kill-reconnect.ts +3 -5
  28. package/test/vitest/integration/pages-router.integration.test.ts +420 -0
  29. package/test/vitest/unit/index.test.ts +49 -2
  30. package/test/vitest/unit/pages-router-kinds.test.ts +292 -0
  31. package/test/vitest/unit/reconnect-socket-already-opened.test.ts +56 -0
@@ -530,5 +530,38 @@ export function getRedisCacheComponentsHandler(
530
530
  return singletonHandler;
531
531
  }
532
532
 
533
- export const redisCacheHandler: CacheComponentsHandler =
534
- getRedisCacheComponentsHandler();
533
+ // Lazily resolve the default Cache Components handler.
534
+ //
535
+ // Constructing a RedisCacheComponentsHandler opens a Redis connection in its
536
+ // constructor. Building the singleton at module-eval time therefore means that simply
537
+ // *importing this package* — e.g. only for `RedisStringsHandler` (the legacy
538
+ // `cacheHandler`), with Cache Components never enabled — eagerly connects to Redis,
539
+ // defaulting to `redis://localhost:6379` when neither `REDIS_URL` nor `REDISHOST` is
540
+ // set. In a deployment whose Redis is not on localhost that yields a non-stop
541
+ // `RedisCacheComponentsHandler client error ECONNREFUSED 127.0.0.1:6379` reconnect
542
+ // loop, and it also makes a consumer's later `getRedisCacheComponentsHandler(options)`
543
+ // a no-op, because the singleton was already built with defaults (see #84).
544
+ //
545
+ // Defer construction to first use via a Proxy: importing the package never connects, a
546
+ // consumer that configures the handler via `getRedisCacheComponentsHandler(options)`
547
+ // before it is first used has that configuration honored, and a consumer that never
548
+ // touches Cache Components never opens a Redis connection at all.
549
+ let resolvedHandler: CacheComponentsHandler | undefined;
550
+
551
+ export const redisCacheHandler: CacheComponentsHandler = new Proxy(
552
+ {} as CacheComponentsHandler,
553
+ {
554
+ get(_target, prop) {
555
+ if (!resolvedHandler) {
556
+ resolvedHandler = getRedisCacheComponentsHandler();
557
+ }
558
+ const value = resolvedHandler[prop as keyof CacheComponentsHandler];
559
+ return typeof value === 'function'
560
+ ? (value as (...args: unknown[]) => unknown).bind(resolvedHandler)
561
+ : value;
562
+ },
563
+ has(_target, prop) {
564
+ return prop in RedisCacheComponentsHandler.prototype;
565
+ },
566
+ },
567
+ );
@@ -13,6 +13,84 @@ export type CacheEntry = {
13
13
  tags: string[];
14
14
  };
15
15
 
16
+ /** Discriminated union of the `ctx` argument Next.js passes to
17
+ * {@link RedisStringsHandler.get}. The Pages Router (`PAGES`) has no PPR
18
+ * concept, so `isRoutePPREnabled` is optional for it.
19
+ *
20
+ * Note: `REDIRECT` is intentionally absent. Next.js always calls `get()` with
21
+ * `kind: 'PAGES'` for Pages Router routes, even when the stored value has
22
+ * `kind: 'REDIRECT'`. The handler returns whatever value is cached and Next.js
23
+ * interprets it accordingly. */
24
+ export type GetContext =
25
+ | {
26
+ kind: 'APP_ROUTE' | 'APP_PAGE';
27
+ isRoutePPREnabled: boolean;
28
+ isFallback: boolean;
29
+ }
30
+ | {
31
+ kind: 'PAGES';
32
+ isRoutePPREnabled?: boolean;
33
+ isFallback: boolean;
34
+ }
35
+ | {
36
+ kind: 'FETCH';
37
+ revalidate: number;
38
+ fetchUrl: string;
39
+ fetchIdx: number;
40
+ tags: string[];
41
+ softTags: string[];
42
+ isFallback: boolean;
43
+ };
44
+
45
+ /** Discriminated union of cache payloads accepted by
46
+ * {@link RedisStringsHandler.set}. `null` is a valid payload: the Pages Router
47
+ * stores `notFound: true` results as a cache entry with a null value. */
48
+ export type SetCacheValue =
49
+ | {
50
+ kind: 'APP_PAGE';
51
+ status?: number;
52
+ headers: {
53
+ 'x-nextjs-stale-time': string; // timestamp in ms
54
+ 'x-next-cache-tags': string; // comma separated paths (tags)
55
+ };
56
+ html: string;
57
+ rscData: Buffer;
58
+ segmentData: unknown;
59
+ postboned: unknown;
60
+ }
61
+ | {
62
+ kind: 'APP_ROUTE';
63
+ status: number;
64
+ headers: {
65
+ 'cache-control'?: string;
66
+ 'x-nextjs-stale-time': string; // timestamp in ms
67
+ 'x-next-cache-tags': string; // comma separated paths (tags)
68
+ };
69
+ body: Buffer;
70
+ }
71
+ | {
72
+ kind: 'PAGES';
73
+ html: string;
74
+ pageData: Record<string, unknown>;
75
+ headers?: Record<string, number | string | string[] | undefined>;
76
+ status?: number;
77
+ }
78
+ | {
79
+ kind: 'REDIRECT';
80
+ props: Record<string, unknown>;
81
+ }
82
+ | {
83
+ kind: 'FETCH';
84
+ data: {
85
+ headers: Record<string, string>;
86
+ body: string; // base64 encoded
87
+ status: number;
88
+ url: string;
89
+ };
90
+ revalidate: number | false;
91
+ }
92
+ | null;
93
+
16
94
  export function redisErrorHandler<T extends Promise<unknown>>(
17
95
  debugInfo: string,
18
96
  redisCommandResult: T,
@@ -144,6 +222,24 @@ const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
144
222
  // This helps track when specific tags were last invalidated
145
223
  const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
146
224
 
225
+ // Cache kinds this handler is designed and tested for. Kept as the single
226
+ // source of truth for both payload validation and the warning message, so
227
+ // adding a kind is a one-line change here instead of edits scattered across
228
+ // the type union, the guard chain, and the log string.
229
+ const SUPPORTED_GET_KINDS = [
230
+ 'APP_ROUTE',
231
+ 'APP_PAGE',
232
+ 'PAGES',
233
+ 'FETCH',
234
+ ] as const;
235
+ const SUPPORTED_SET_KINDS = [
236
+ 'APP_ROUTE',
237
+ 'APP_PAGE',
238
+ 'PAGES',
239
+ 'REDIRECT',
240
+ 'FETCH',
241
+ ] as const;
242
+
147
243
  let killContainerOnErrorCount: number = 0;
148
244
  export default class RedisStringsHandler {
149
245
  private client: Client;
@@ -371,35 +467,14 @@ export default class RedisStringsHandler {
371
467
  }
372
468
  }
373
469
 
374
- public async get(
375
- key: string,
376
- ctx:
377
- | {
378
- kind: 'APP_ROUTE' | 'APP_PAGE';
379
- isRoutePPREnabled: boolean;
380
- isFallback: boolean;
381
- }
382
- | {
383
- kind: 'FETCH';
384
- revalidate: number;
385
- fetchUrl: string;
386
- fetchIdx: number;
387
- tags: string[];
388
- softTags: string[];
389
- isFallback: boolean;
390
- },
391
- ): Promise<CacheEntry | null> {
470
+ public async get(key: string, ctx: GetContext): Promise<CacheEntry | null> {
392
471
  try {
393
- if (
394
- ctx.kind !== 'APP_ROUTE' &&
395
- ctx.kind !== 'APP_PAGE' &&
396
- ctx.kind !== 'FETCH'
397
- ) {
472
+ if (!(SUPPORTED_GET_KINDS as readonly string[]).includes(ctx.kind)) {
398
473
  console.warn(
399
474
  'RedisStringsHandler.get() called with',
400
475
  key,
401
476
  ctx,
402
- ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
477
+ `this cache handler is only designed and tested for kinds ${SUPPORTED_GET_KINDS.join(', ')} and not for kind`,
403
478
  (ctx as { kind: string })?.kind,
404
479
  );
405
480
  }
@@ -479,7 +554,10 @@ export default class RedisStringsHandler {
479
554
  'cacheEntry is mall formed (missing tags)',
480
555
  );
481
556
  }
482
- if (!cacheEntry?.value) {
557
+ // value === null is a legitimate entry: the Pages Router stores
558
+ // `notFound: true` results as a cache entry with a null value.
559
+ // Only an absent value indicates a malformed entry.
560
+ if (cacheEntry?.value === undefined) {
483
561
  console.warn(
484
562
  'RedisStringsHandler.get() called with',
485
563
  key,
@@ -589,71 +667,49 @@ export default class RedisStringsHandler {
589
667
  }
590
668
  public async set(
591
669
  key: string,
592
- data:
593
- | {
594
- kind: 'APP_PAGE';
595
- status?: number;
596
- headers: {
597
- 'x-nextjs-stale-time': string; // timestamp in ms
598
- 'x-next-cache-tags': string; // comma separated paths (tags)
599
- };
600
- html: string;
601
- rscData: Buffer;
602
- segmentData: unknown;
603
- postboned: unknown;
604
- }
605
- | {
606
- kind: 'APP_ROUTE';
607
- status: number;
608
- headers: {
609
- 'cache-control'?: string;
610
- 'x-nextjs-stale-time': string; // timestamp in ms
611
- 'x-next-cache-tags': string; // comma separated paths (tags)
612
- };
613
- body: Buffer;
614
- }
615
- | {
616
- kind: 'FETCH';
617
- data: {
618
- headers: Record<string, string>;
619
- body: string; // base64 encoded
620
- status: number;
621
- url: string;
622
- };
623
- revalidate: number | false;
624
- },
670
+ data: SetCacheValue,
625
671
  ctx: {
626
672
  isRoutePPREnabled: boolean;
627
673
  isFallback: boolean;
628
674
  tags?: string[];
629
675
  // Different versions of Next.js use different arguments for the same functionality
630
676
  revalidate?: number | false; // Version 15.0.3
631
- cacheControl?: { revalidate: 5; expire: undefined }; // Version 15.0.3
677
+ cacheControl?: { revalidate: number | false; expire: number | undefined }; // Version 15.0.3+
632
678
  },
633
679
  ) {
634
680
  try {
635
681
  if (
636
- data.kind !== 'APP_ROUTE' &&
637
- data.kind !== 'APP_PAGE' &&
638
- data.kind !== 'FETCH'
682
+ data !== null &&
683
+ !(SUPPORTED_SET_KINDS as readonly string[]).includes(data.kind)
639
684
  ) {
640
685
  console.warn(
641
686
  'RedisStringsHandler.set() called with',
642
687
  key,
643
688
  ctx,
644
689
  data,
645
- ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
690
+ `this cache handler is only designed and tested for kinds ${SUPPORTED_SET_KINDS.join(', ')} and not for kind`,
646
691
  (data as { kind: string })?.kind,
647
692
  );
648
693
  }
649
694
 
650
695
  await this.assertClientIsReady();
651
696
 
652
- if (data.kind === 'APP_PAGE' || data.kind === 'APP_ROUTE') {
697
+ if (data?.kind === 'APP_PAGE' || data?.kind === 'APP_ROUTE') {
653
698
  const tags = data.headers['x-next-cache-tags']?.split(',');
654
699
  ctx.tags = [...(ctx.tags || []), ...(tags || [])];
655
700
  }
656
701
 
702
+ // Pages Router entries (PAGES, REDIRECT and null/notFound results) do not
703
+ // carry an x-next-cache-tags header. Attach the implicit path tag
704
+ // (_N_T_/<path>) so that revalidatePath()/revalidateTag('_N_T_/<path>')
705
+ // invalidates them the same way as App Router entries.
706
+ if (data === null || data.kind === 'PAGES' || data.kind === 'REDIRECT') {
707
+ const implicitTag = NEXT_CACHE_IMPLICIT_TAG_ID + key;
708
+ if (!ctx.tags?.includes(implicitTag)) {
709
+ ctx.tags = [...(ctx.tags || []), implicitTag];
710
+ }
711
+ }
712
+
657
713
  // Constructing and serializing the value for storing it in redis
658
714
  const cacheEntry: CacheEntry = {
659
715
  lastModified: Date.now(),
@@ -676,10 +732,13 @@ export default class RedisStringsHandler {
676
732
  // Constructing the expire time for the cache entry
677
733
  const revalidate =
678
734
  // For fetch requests in newest versions, the revalidate context property is never used, and instead the revalidate property of the passed-in data is used
679
- (data.kind === 'FETCH' && data.revalidate) ||
735
+ (data?.kind === 'FETCH' && data.revalidate) ||
680
736
  ctx.revalidate ||
681
737
  ctx.cacheControl?.revalidate ||
682
- (data as { revalidate?: number | false })?.revalidate;
738
+ // Legacy fallback: older Next.js versions attached `revalidate` directly
739
+ // to the data payload. FETCH is already handled above, so this only
740
+ // matters for those older, non-discriminated shapes.
741
+ (data as { revalidate?: number | false } | null)?.revalidate;
683
742
  const expireAt =
684
743
  revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
685
744
  ? this.estimateExpireAge(revalidate)
package/test/README.md CHANGED
@@ -28,12 +28,13 @@ test/
28
28
 
29
29
  Fast tests with no external dependencies. Mocks are used where needed.
30
30
 
31
- | File | What it tests |
32
- | ----------------------------------------- | -------------------------------------------------------------------------------- |
33
- | `serializer.test.ts` | `CacheValueSerializer` interface, JSON round-trips, singleton stability |
34
- | `index.test.ts` | `RedisStringsHandler` constructor options, default behaviors |
35
- | `utils/prefix.test.ts` | `resolveKeyPrefix` logic (BUILD_ID fallback, env var precedence) |
36
- | `reconnect-socket-already-opened.test.ts` | Regression: reconnect logic doesn't call `connect()` when socket is already open |
31
+ | File | What it tests |
32
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
33
+ | `serializer.test.ts` | `CacheValueSerializer` interface, JSON round-trips, singleton stability |
34
+ | `index.test.ts` | `RedisStringsHandler` constructor options, default behaviors |
35
+ | `utils/prefix.test.ts` | `resolveKeyPrefix` logic (BUILD_ID fallback, env var precedence) |
36
+ | `reconnect-socket-already-opened.test.ts` | Regression: reconnect logic doesn't call `connect()` when socket is already open |
37
+ | `pages-router-kinds.test.ts` | Pages Router cache kinds (`PAGES`, `REDIRECT`, `null`/notFound), implicit tags, TTL derivation |
37
38
 
38
39
  ```bash
39
40
  pnpm test:unit # single run
@@ -60,6 +61,16 @@ Full cache lifecycle: static pages, fetch caching, revalidation, tag invalidatio
60
61
  pnpm test:integration
61
62
  ```
62
63
 
64
+ ### Pages Router (`pages-router.integration.test.ts`)
65
+
66
+ Pages Router cache lifecycle against the `next-pages-16-2-6` app: `PAGES` entry format, TTL derivation from `getStaticProps` `revalidate`, `fallback: 'blocking'` first hits, `notFound: true` (null cache entries), `redirect:` results, and `revalidate: false` TTL fallback.
67
+
68
+ Starts **two** `next start` instances of the same build sharing one Redis and proves that on-demand revalidation (`res.revalidate(path)`) triggered on instance A is served fresh by instance B (HTML and `/_next/data` pageData JSON) — the multi-instance ISR scenario behind a load balancer.
69
+
70
+ ```bash
71
+ pnpm test:integration:pages
72
+ ```
73
+
63
74
  ### BUILD_ID Prefix (`build-id-prefix.integration.test.ts`)
64
75
 
65
76
  Verifies that when neither `KEY_PREFIX` nor `VERCEL_URL` is set, the handler falls back to `.next/BUILD_ID` as the Redis key prefix. Runs in its own CI job because it needs a clean environment without those env vars.
@@ -136,6 +147,7 @@ Minimal Next.js applications used as fixtures. They are not test runners — the
136
147
  | `next-app-15-4-11` | 15.4.11 | Integration (matrix, default for local), build-id-prefix |
137
148
  | `next-app-16-0-11` | 16.0.11 | Integration (matrix) |
138
149
  | `next-app-16-2-6` | 16.2.6 | Integration (matrix) |
150
+ | `next-pages-16-2-6` | 16.2.6 | Integration (Pages Router, two-instance revalidation) |
139
151
  | `next-app-16-0-11-cache-components` | 16.0.11 | Integration (cache-components matrix), E2E (Playwright matrix) |
140
152
  | `next-app-16-2-6-cache-components` | 16.2.6 | Integration (cache-components matrix), E2E (Playwright matrix) |
141
153
  | `next-app-customized` | — | Example of custom config (referenced in project README) |
@@ -149,6 +161,7 @@ The CI workflow (`.github/workflows/ci.yml`) is structured as:
149
161
  ```
150
162
  lint-and-unit → Lint + Unit Tests + Coverage
151
163
  ├── integration → Matrix: 3 Next.js versions (15.4–16.2)
164
+ ├── integration-pages → Pages Router (two-instance revalidation)
152
165
  ├── integration-build-id-prefix → Isolated BUILD_ID prefix test
153
166
  ├── integration-cache-components → Matrix: 16.0.11 + 16.2.6 cache-components
154
167
  └── e2e → Matrix: Playwright against 16.0.11 + 16.2.6
@@ -160,6 +173,7 @@ lint-and-unit → Lint + Unit Tests + Coverage
160
173
  | ------------------------------ | --------------------------------------------------------- | --------------------------------------------------------------- |
161
174
  | `lint-and-unit` | — | `pnpm lint` + `pnpm test:unit:coverage` |
162
175
  | `integration` | `next-app-15-4-11`, `next-app-16-0-11`, `next-app-16-2-6` | `pnpm test:integration` (per matrix entry) |
176
+ | `integration-pages` | `next-pages-16-2-6` | `pnpm test:integration:pages` |
163
177
  | `integration-build-id-prefix` | `next-app-15-4-11` | `pnpm test:integration:build-id-prefix` |
164
178
  | `integration-cache-components` | `next-app-16-{0-3,2-3}-cache-components` | `pnpm test:integration:cache-components` + Redis kill/reconnect |
165
179
  | `e2e` | `next-app-16-{0-3,2-3}-cache-components` | `pnpm test:e2e` (Playwright) |
@@ -171,6 +185,7 @@ lint-and-unit → Lint + Unit Tests + Coverage
171
185
  | Variable | Used by | Description |
172
186
  | ----------------------- | ------------------------------ | ------------------------------------------------------------------------- |
173
187
  | `NEXT_TEST_APP` | Integration | Which test app to use (default: `next-app-15-4-11`) |
188
+ | `NEXT_PAGES_TEST_APP` | Integration (Pages Router) | Which Pages Router test app to use (default: `next-pages-16-2-6`) |
174
189
  | `CACHE_COMPONENTS_APP` | Integration (cache-components) | Which cache-components app (default: `next-app-16-2-6-cache-components`) |
175
190
  | `PLAYWRIGHT_TEST_APP` | E2E | Which app Playwright starts (default: `next-app-16-2-6-cache-components`) |
176
191
  | `PLAYWRIGHT_BASE_URL` | E2E | Override base URL (skips `webServer` auto-start) |
@@ -0,0 +1,16 @@
1
+ # next-pages-16-2-6
2
+
3
+ Next.js 16.2.6 **Pages Router** fixture for integration testing the Redis
4
+ cache handler with Pages Router cache entry kinds:
5
+
6
+ - `PAGES` — ISR pages via `getStaticProps` + `revalidate` and
7
+ `getStaticPaths` with `fallback: 'blocking'` (`/isr/[slug]`)
8
+ - `REDIRECT` — `getStaticProps` returning `redirect:` (`/isr/redirect`)
9
+ - `null` value entries — `getStaticProps` returning `notFound: true`
10
+ (`/isr/not-found`)
11
+ - `revalidate: false` TTL fallback (`/static-forever`)
12
+ - On-demand revalidation via `res.revalidate(path)`
13
+ (`/api/revalidate?path=...`), used by the two-instance cross-server
14
+ revalidation test
15
+
16
+ Used by `test/vitest/integration/pages-router.integration.test.ts`.
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ cacheHandler: require.resolve('@trieb.work/nextjs-turbo-redis-cache'),
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "next-pages-16-2-6",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "next": "16.2.6",
13
+ "react": "19.2.0",
14
+ "react-dom": "19.2.0",
15
+ "redis": "4.7.0",
16
+ "@trieb.work/nextjs-turbo-redis-cache": "file:../../../"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20",
20
+ "@types/react": "^19",
21
+ "@types/react-dom": "^19",
22
+ "eslint": "^9",
23
+ "eslint-config-next": "16.2.6",
24
+ "typescript": "^5"
25
+ }
26
+ }