@trieb.work/nextjs-turbo-redis-cache 1.11.0 → 1.11.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.
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { spawn } from 'child_process';
3
+ import path from 'path';
4
+
5
+ function runNode(script: string, timeoutMs = 120_000) {
6
+ return new Promise<{ code: number; stdout: string; stderr: string }>(
7
+ (resolve, reject) => {
8
+ const p = spawn('pnpm', ['-s', 'tsx', script], {
9
+ stdio: ['ignore', 'pipe', 'pipe'],
10
+ env: { ...process.env },
11
+ });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ p.stdout.on('data', (d) => (stdout += d.toString()));
15
+ p.stderr.on('data', (d) => (stderr += d.toString()));
16
+ const t = setTimeout(() => {
17
+ p.kill('SIGKILL');
18
+ reject(new Error('timeout'));
19
+ }, timeoutMs);
20
+ p.on('close', (code) => {
21
+ clearTimeout(t);
22
+ resolve({ code: code ?? -1, stdout, stderr });
23
+ });
24
+ },
25
+ );
26
+ }
27
+
28
+ describe('redis kill/reconnect end-to-end (both handlers)', () => {
29
+ it('survives redis restart without Socket already opened', async () => {
30
+ const script = path.join(__dirname, 'scripts', 'redis-kill-reconnect.ts');
31
+
32
+ const res = await runNode(script, 180_000);
33
+
34
+ expect(res.code).toBe(0);
35
+ expect(res.stdout).toContain('OK');
36
+ expect(res.stderr).not.toContain('Socket already opened');
37
+ }, 180_000);
38
+ });
@@ -0,0 +1,222 @@
1
+ import { spawnSync } from 'child_process';
2
+
3
+ function sh(cmd: string, args: string[], opts: { timeoutMs?: number } = {}) {
4
+ const res = spawnSync(cmd, args, {
5
+ encoding: 'utf8',
6
+ timeout: opts.timeoutMs ?? 30_000,
7
+ });
8
+ if (res.error) throw res.error;
9
+ return {
10
+ code: res.status ?? -1,
11
+ stdout: res.stdout ?? '',
12
+ stderr: res.stderr ?? '',
13
+ };
14
+ }
15
+
16
+ async function sleep(ms: number) {
17
+ await new Promise((r) => setTimeout(r, ms));
18
+ }
19
+
20
+ async function waitUntil(fn: () => Promise<boolean>, timeoutMs = 20_000) {
21
+ const start = Date.now();
22
+ // eslint-disable-next-line no-constant-condition
23
+ while (true) {
24
+ if (await fn()) return;
25
+ if (Date.now() - start > timeoutMs) throw new Error('waitUntil timeout');
26
+ await sleep(200);
27
+ }
28
+ }
29
+
30
+ async function main() {
31
+ const name = `redis-e2e-${Math.random().toString(36).slice(2, 8)}`;
32
+
33
+ // Pick a free localhost port
34
+ const net = await import('net');
35
+ const port: number = await new Promise((resolve, reject) => {
36
+ const srv = net.createServer();
37
+ srv.on('error', reject);
38
+ srv.listen(0, '127.0.0.1', () => {
39
+ const addr = srv.address();
40
+ if (!addr || typeof addr === 'string')
41
+ return reject(new Error('bad addr'));
42
+ const p = addr.port;
43
+ srv.close(() => resolve(p));
44
+ });
45
+ });
46
+
47
+ // cleanup
48
+ sh('podman', ['rm', '-f', name]);
49
+
50
+ // Start redis with keyspace notifications enabled (required by SyncedMap)
51
+ {
52
+ const r = sh(
53
+ 'podman',
54
+ [
55
+ 'run',
56
+ '-d',
57
+ '--rm',
58
+ '--name',
59
+ name,
60
+ '-p',
61
+ `${port}:6379`,
62
+ 'docker.io/redis:7-alpine',
63
+ 'redis-server',
64
+ '--notify-keyspace-events',
65
+ 'Exe',
66
+ ],
67
+ { timeoutMs: 60_000 },
68
+ );
69
+ if (r.code !== 0) throw new Error(`podman run failed: ${r.stderr}`);
70
+ }
71
+
72
+ // IMPORTANT: importing from "src" will eagerly instantiate the singleton via `redisCacheHandler`.
73
+ // So we must set env BEFORE importing.
74
+ process.env.REDIS_URL = `redis://127.0.0.1:${port}`;
75
+ process.env.VERCEL_URL = `e2e-${name}-`;
76
+
77
+ const { getRedisCacheComponentsHandler } = await import(
78
+ '../../../src/CacheComponentsHandler'
79
+ );
80
+ const { default: RedisStringsHandler } = await import(
81
+ '../../../src/RedisStringsHandler'
82
+ );
83
+
84
+ // Cache Components handler
85
+ const cacheComponentsHandler = getRedisCacheComponentsHandler({
86
+ redisUrl: `redis://127.0.0.1:${port}`,
87
+ socketOptions: {
88
+ connectTimeout: 2_000,
89
+ // allow redis client to reconnect; this scenario is about stability under restart
90
+ reconnectStrategy: (retries) => Math.min(50 + retries * 50, 500),
91
+ },
92
+ clientOptions: {
93
+ disableOfflineQueue: true,
94
+ } as any,
95
+ });
96
+
97
+ const cacheComponentsClient = (cacheComponentsHandler as any).client as {
98
+ ping: () => Promise<string>;
99
+ isReady: boolean;
100
+ };
101
+
102
+ await waitUntil(async () => cacheComponentsClient.isReady, 20_000);
103
+ if ((await cacheComponentsClient.ping()) !== 'PONG') {
104
+ throw new Error('expected PONG (cache components)');
105
+ }
106
+
107
+ // Regular handler
108
+ const regularHandler = new RedisStringsHandler({
109
+ redisUrl: `redis://127.0.0.1:${port}`,
110
+ socketOptions: {
111
+ connectTimeout: 2_000,
112
+ reconnectStrategy: (retries: number) => Math.min(50 + retries * 50, 500),
113
+ },
114
+ // Keep default offline queue behavior here; SyncedMap does initial sync during construction.
115
+ keyPrefix: `e2e-${name}-regular-`,
116
+ } as any);
117
+
118
+ const regularClient = (regularHandler as any).client as {
119
+ ping: () => Promise<string>;
120
+ isReady: boolean;
121
+ };
122
+
123
+ await waitUntil(async () => regularClient.isReady, 20_000);
124
+ if ((await regularClient.ping()) !== 'PONG') {
125
+ throw new Error('expected PONG (regular handler)');
126
+ }
127
+
128
+ // Seed a small entry so we know regular handler does real ops
129
+ await regularHandler.set(
130
+ 'e2e-key',
131
+ {
132
+ kind: 'FETCH',
133
+ data: {
134
+ headers: {},
135
+ body: Buffer.from('hello').toString('base64'),
136
+ status: 200,
137
+ url: 'https://example.com/e2e',
138
+ },
139
+ revalidate: 10,
140
+ },
141
+ { isRoutePPREnabled: false, isFallback: false, tags: ['e2e'] },
142
+ );
143
+
144
+ const got = await regularHandler.get('e2e-key', {
145
+ kind: 'FETCH',
146
+ revalidate: 10,
147
+ fetchUrl: 'https://example.com/e2e',
148
+ fetchIdx: 0,
149
+ tags: ['e2e'],
150
+ softTags: [],
151
+ isFallback: false,
152
+ });
153
+ if (!got) throw new Error('expected cache hit (regular handler)');
154
+
155
+ // Kill redis
156
+ sh('podman', ['stop', '-t', '0', name], { timeoutMs: 30_000 });
157
+
158
+ // Wait a bit, then start again
159
+ await sleep(500);
160
+
161
+ {
162
+ const r = sh(
163
+ 'podman',
164
+ [
165
+ 'run',
166
+ '-d',
167
+ '--rm',
168
+ '--name',
169
+ name,
170
+ '-p',
171
+ `${port}:6379`,
172
+ 'docker.io/redis:7-alpine',
173
+ 'redis-server',
174
+ '--notify-keyspace-events',
175
+ 'Exe',
176
+ ],
177
+ { timeoutMs: 60_000 },
178
+ );
179
+ if (r.code !== 0) throw new Error(`podman run2 failed: ${r.stderr}`);
180
+ }
181
+
182
+ // Should recover (both)
183
+ await waitUntil(async () => {
184
+ try {
185
+ return (await cacheComponentsClient.ping()) === 'PONG';
186
+ } catch {
187
+ return false;
188
+ }
189
+ }, 30_000);
190
+
191
+ await waitUntil(async () => {
192
+ try {
193
+ return (await regularClient.ping()) === 'PONG';
194
+ } catch {
195
+ return false;
196
+ }
197
+ }, 30_000);
198
+
199
+ // Shut down clients before cleaning up Redis, otherwise reconnect loops keep the process alive.
200
+ try {
201
+ await (cacheComponentsHandler as any).client?.quit?.();
202
+ } catch {}
203
+ try {
204
+ await (regularHandler as any).client?.quit?.();
205
+ } catch {}
206
+
207
+ // cleanup best effort
208
+ sh('podman', ['rm', '-f', name]);
209
+
210
+ // If we reach here without crashing due to Socket already opened, we're good.
211
+ // (Vitest will check stdout for this line.)
212
+ process.stdout.write('OK\n');
213
+
214
+ // Ensure we exit even if background reconnect timers exist.
215
+ process.exit(0);
216
+ }
217
+
218
+ main().catch((err) => {
219
+ // Ensure we don't hide the key error string
220
+ console.error(err);
221
+ process.exit(1);
222
+ });