@trieb.work/nextjs-turbo-redis-cache 1.16.0 → 1.16.2

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.
@@ -444,21 +444,32 @@ export default class RedisStringsHandler {
444
444
  'assertClientIsReady called more than 10 times without being ready.',
445
445
  );
446
446
  }
447
- await Promise.race([
448
- Promise.all([
449
- this.sharedTagsMap.waitUntilReady(),
450
- this.revalidatedTagsMap.waitUntilReady(),
451
- ]),
452
- new Promise((_, reject) =>
453
- setTimeout(() => {
454
- reject(
455
- new Error(
456
- 'assertClientIsReady: Timeout waiting for Redis maps to be ready',
457
- ),
458
- );
459
- }, 30_000),
460
- ),
461
- ]);
447
+ let readyTimeout: ReturnType<typeof setTimeout> | undefined;
448
+ try {
449
+ await Promise.race([
450
+ Promise.all([
451
+ this.sharedTagsMap.waitUntilReady(),
452
+ this.revalidatedTagsMap.waitUntilReady(),
453
+ ]),
454
+ new Promise((_, reject) => {
455
+ readyTimeout = setTimeout(() => {
456
+ reject(
457
+ new Error(
458
+ 'assertClientIsReady: Timeout waiting for Redis maps to be ready',
459
+ ),
460
+ );
461
+ }, 30_000);
462
+ }),
463
+ ]);
464
+ } finally {
465
+ // Always clear the timeout: once the race is won (the common case after
466
+ // the initial sync), the timer would otherwise stay pending for the full
467
+ // 30s. Timers capture the ambient async context (AsyncLocalStorage),
468
+ // so in a Next.js server every leaked timer pins the request's store —
469
+ // and with it the whole response object graph — for at least 30s on
470
+ // every single cache operation.
471
+ clearTimeout(readyTimeout);
472
+ }
462
473
  this.clientReadyCalls = 0;
463
474
  if (!this.client.isReady) {
464
475
  throw new Error(
@@ -50,6 +50,12 @@ describe('redis kill/reconnect end-to-end (both handlers)', () => {
50
50
 
51
51
  const res = await runNode(script, 180_000);
52
52
 
53
+ if (res.code !== 0) {
54
+ console.error('Script exited with code', res.code);
55
+ console.error('stdout:', res.stdout);
56
+ console.error('stderr:', res.stderr);
57
+ }
58
+
53
59
  expect(res.code).toBe(0);
54
60
  expect(res.stdout).toContain('OK');
55
61
  expect(res.stderr).not.toContain('Socket already opened');
@@ -92,8 +92,9 @@ async function main() {
92
92
  if (r.code !== 0) throw new Error(`podman run failed: ${r.stderr}`);
93
93
  }
94
94
 
95
- // IMPORTANT: importing from "src" will eagerly instantiate the singleton via `redisCacheHandler`.
96
- // So we must set env BEFORE importing.
95
+ // Set env BEFORE importing in case any module-level code reads REDIS_URL.
96
+ // (With lazy init the singleton is no longer created at import time, but
97
+ // setting env first is still good practice.)
97
98
  process.env.REDIS_URL = `redis://127.0.0.1:${port}`;
98
99
  process.env.VERCEL_URL = `e2e-${name}-`;
99
100
 
@@ -112,9 +113,6 @@ async function main() {
112
113
  // allow redis client to reconnect; this scenario is about stability under restart
113
114
  reconnectStrategy: (retries) => Math.min(50 + retries * 50, 500),
114
115
  },
115
- clientOptions: {
116
- disableOfflineQueue: true,
117
- } as any,
118
116
  });
119
117
 
120
118
  const cacheComponentsClient = (cacheComponentsHandler as any).client as {
@@ -4,7 +4,7 @@ import type { CreateRedisStringsHandlerOptions } from '../../../src/index';
4
4
  import RedisStringsHandler from '../../../src/RedisStringsHandler';
5
5
 
6
6
  vi.mock('redis', () => {
7
- const createClient = () => {
7
+ const createClient = vi.fn(() => {
8
8
  return {
9
9
  isReady: true,
10
10
  on: vi.fn(),
@@ -31,7 +31,7 @@ vi.mock('redis', () => {
31
31
  unlink: vi.fn(async () => 1),
32
32
  set: vi.fn(async () => 'OK'),
33
33
  };
34
- };
34
+ });
35
35
  return {
36
36
  createClient,
37
37
  commandOptions: vi.fn((opts) => opts),
@@ -85,6 +85,34 @@ describe('RedisStringsHandler', () => {
85
85
  expect(res).toBeNull();
86
86
  expect(consoleErrorSpy).not.toHaveBeenCalled();
87
87
  });
88
+
89
+ it('does not leave the readiness timeout timer pending after an operation', async () => {
90
+ vi.useFakeTimers();
91
+ try {
92
+ const handler = new RedisStringsHandler({
93
+ redisUrl: 'redis://localhost:6379',
94
+ keyPrefix: 'test:',
95
+ database: 0,
96
+ getTimeoutMs: 1,
97
+ redisGetDeduplication: false,
98
+ });
99
+
100
+ const timersBefore = vi.getTimerCount();
101
+ await handler.get('missing-key', {
102
+ kind: 'APP_PAGE',
103
+ isRoutePPREnabled: false,
104
+ isFallback: false,
105
+ });
106
+
107
+ // assertClientIsReady() races waitUntilReady() against a 30s timeout;
108
+ // the timer must not stay pending once the race is settled, because
109
+ // pending timers retain the ambient async context (and with it, in a
110
+ // Next.js server, the whole per-request object graph).
111
+ expect(vi.getTimerCount()).toBe(timersBefore);
112
+ } finally {
113
+ vi.useRealTimers();
114
+ }
115
+ });
88
116
  });
89
117
 
90
118
  describe('Public exports', () => {
@@ -96,3 +124,50 @@ describe('Public exports', () => {
96
124
  expect(_typeCheck.keyPrefix).toBe('test');
97
125
  });
98
126
  });
127
+
128
+ describe('redisCacheHandler lazy proxy', () => {
129
+ it('does not construct a Redis connection on import', async () => {
130
+ vi.resetModules();
131
+ const { createClient } = await import('redis');
132
+ vi.mocked(createClient).mockClear();
133
+
134
+ // Importing the module should NOT trigger a Redis connection
135
+ await import('../../../src');
136
+
137
+ expect(vi.mocked(createClient)).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('has trap returns true for known methods without constructing the handler', async () => {
141
+ vi.resetModules();
142
+ const { createClient } = await import('redis');
143
+ vi.mocked(createClient).mockClear();
144
+
145
+ const { redisCacheHandler } = await import('../../../src');
146
+
147
+ expect('getExpiration' in redisCacheHandler).toBe(true);
148
+ expect('get' in redisCacheHandler).toBe(true);
149
+ expect('set' in redisCacheHandler).toBe(true);
150
+ expect('refreshTags' in redisCacheHandler).toBe(true);
151
+ expect('updateTags' in redisCacheHandler).toBe(true);
152
+
153
+ expect(vi.mocked(createClient)).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it('method call delegates to the singleton and binds correctly', async () => {
157
+ vi.resetModules();
158
+ const { createClient } = await import('redis');
159
+ vi.mocked(createClient).mockClear();
160
+
161
+ const { redisCacheHandler } = await import('../../../src');
162
+
163
+ // Accessing a method triggers construction and delegates to the real handler
164
+ const getFn = redisCacheHandler.get;
165
+ expect(typeof getFn).toBe('function');
166
+ expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1);
167
+
168
+ // Subsequent accesses reuse the same singleton (no extra construction)
169
+ const getFn2 = redisCacheHandler.get;
170
+ expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1);
171
+ expect(typeof getFn2).toBe('function');
172
+ });
173
+ });
@@ -1,5 +1,58 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
2
 
3
+ vi.mock('redis', () => ({
4
+ createClient: vi.fn(() => {
5
+ const listeners = new Map<string, Function[]>();
6
+ const client = {
7
+ isOpen: false,
8
+ isReady: false,
9
+ on: vi.fn((event: string, cb: Function) => {
10
+ if (!listeners.has(event)) listeners.set(event, []);
11
+ listeners.get(event)!.push(cb);
12
+ }),
13
+ emit: vi.fn((event: string, ...args: unknown[]) => {
14
+ (listeners.get(event) ?? []).forEach((cb) => cb(...args));
15
+ }),
16
+ connect: vi.fn(async () => undefined),
17
+ disconnect: vi.fn(),
18
+ quit: vi.fn(async () => undefined),
19
+ duplicate: vi.fn(() => ({
20
+ connect: vi.fn(async () => undefined),
21
+ subscribe: vi.fn(async () => undefined),
22
+ on: vi.fn(),
23
+ quit: vi.fn(async () => undefined),
24
+ configGet: vi.fn(async () => ({ 'notify-keyspace-events': 'Exe' })),
25
+ })),
26
+ get: vi.fn(async () => null),
27
+ hScan: vi.fn(async () => ({ cursor: 0, tuples: [] })),
28
+ scan: vi.fn(async () => ({ cursor: 0, keys: [] })),
29
+ hSet: vi.fn(async () => 1),
30
+ hDel: vi.fn(async () => 1),
31
+ publish: vi.fn(async () => 1),
32
+ unlink: vi.fn(async () => 1),
33
+ set: vi.fn(async () => 'OK'),
34
+ };
35
+ return client;
36
+ }),
37
+ commandOptions: vi.fn((opts) => opts),
38
+ }));
39
+
40
+ vi.mock('../../../src/SyncedMap', () => {
41
+ class SyncedMap {
42
+ waitUntilReady = vi.fn(async () => undefined);
43
+ get = vi.fn(() => undefined);
44
+ set = vi.fn(async () => undefined);
45
+ delete = vi.fn(async () => undefined);
46
+ entries = vi.fn(function* () {
47
+ return;
48
+ });
49
+
50
+ constructor() {}
51
+ }
52
+
53
+ return { SyncedMap };
54
+ });
55
+
3
56
  /**
4
57
  * Deterministic regression test for:
5
58
  * "Failed to reconnect RedisCacheComponentsHandler client after connection loss: Error: Socket already opened"
@@ -34,6 +87,9 @@ describe('RedisCacheComponentsHandler reconnect logic', () => {
34
87
 
35
88
  const client = (handler as any).client;
36
89
 
90
+ // Clear the initial connect() call from the constructor.
91
+ vi.mocked(client.connect).mockClear();
92
+
37
93
  // Simulate a connection-loss situation where the socket is still open.
38
94
  Object.defineProperty(client, 'isOpen', { value: true });
39
95
  Object.defineProperty(client, 'isReady', { value: false });