@trieb.work/nextjs-turbo-redis-cache 1.12.0 → 1.13.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.
- package/.github/workflows/ci.yml +8 -10
- package/.github/workflows/release.yml +68 -7
- package/CHANGELOG.md +21 -0
- package/README.md +25 -17
- package/dist/index.d.mts +8 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +64 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +54 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -1
- package/src/CacheComponentsHandler.ts +11 -5
- package/src/CachedHandler.ts +16 -2
- package/src/RedisStringsHandler.ts +4 -2
- package/src/utils/prefix.test.ts +115 -0
- package/src/utils/prefix.ts +44 -0
- package/test/integration/build-id-prefix.integration.test.ts +102 -0
- package/test/integration/next-app-16-0-3/postcss.config.mjs +7 -7
- package/test/integration/next-app-16-1-1/postcss.config.mjs +7 -7
- package/test/integration/next-app-16-1-1-cache-components/postcss.config.mjs +7 -7
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { resolveKeyPrefix } from './prefix';
|
|
6
|
+
|
|
7
|
+
function withEnv<T>(env: Partial<NodeJS.ProcessEnv>, fn: () => T): T {
|
|
8
|
+
const original: NodeJS.ProcessEnv = { ...process.env };
|
|
9
|
+
Object.entries(env).forEach(([k, v]) => {
|
|
10
|
+
const envRec = process.env as Record<string, string | undefined>;
|
|
11
|
+
if (v === undefined) delete envRec[k];
|
|
12
|
+
else envRec[k] = v as string;
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
return fn();
|
|
16
|
+
} finally {
|
|
17
|
+
process.env = original;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeBuildIdTree(buildId: string) {
|
|
22
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'prefix-test-'));
|
|
23
|
+
const nextDir = path.join(tmp, '.next');
|
|
24
|
+
const serverDir = path.join(nextDir, 'server');
|
|
25
|
+
fs.mkdirSync(serverDir, { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(nextDir, 'BUILD_ID'), buildId, 'utf8');
|
|
27
|
+
return {
|
|
28
|
+
serverDistDir: serverDir,
|
|
29
|
+
cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('resolveKeyPrefix', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
const envRec = process.env as Record<string, string | undefined>;
|
|
36
|
+
delete envRec.KEY_PREFIX;
|
|
37
|
+
delete envRec.VERCEL_URL;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns option keyPrefix when provided (including empty string)', () => {
|
|
41
|
+
const p1 = resolveKeyPrefix({ optionKeyPrefix: 'opt_', env: process.env });
|
|
42
|
+
expect(p1).toBe('opt_');
|
|
43
|
+
|
|
44
|
+
const p2 = resolveKeyPrefix({ optionKeyPrefix: '', env: process.env });
|
|
45
|
+
expect(p2).toBe('');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses KEY_PREFIX when option is undefined', () => {
|
|
49
|
+
const res = withEnv({ KEY_PREFIX: 'envkp_' }, () =>
|
|
50
|
+
resolveKeyPrefix({ optionKeyPrefix: undefined, env: process.env }),
|
|
51
|
+
);
|
|
52
|
+
expect(res).toBe('envkp_');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('uses BUILD_ID when KEY_PREFIX and VERCEL_URL are absent and BUILD_ID readable', () => {
|
|
56
|
+
const { serverDistDir, cleanup } = makeBuildIdTree('BID123');
|
|
57
|
+
try {
|
|
58
|
+
const res = resolveKeyPrefix({
|
|
59
|
+
optionKeyPrefix: undefined,
|
|
60
|
+
env: process.env,
|
|
61
|
+
serverDistDir,
|
|
62
|
+
});
|
|
63
|
+
expect(res).toBe('BID123');
|
|
64
|
+
} finally {
|
|
65
|
+
cleanup();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses VERCEL_URL before BUILD_ID when both are available', () => {
|
|
70
|
+
const { serverDistDir, cleanup } = makeBuildIdTree('BIDXYZ');
|
|
71
|
+
try {
|
|
72
|
+
const res = withEnv({ VERCEL_URL: 'vercel.example' }, () =>
|
|
73
|
+
resolveKeyPrefix({
|
|
74
|
+
optionKeyPrefix: undefined,
|
|
75
|
+
env: process.env,
|
|
76
|
+
serverDistDir,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
expect(res).toBe('vercel.example');
|
|
80
|
+
} finally {
|
|
81
|
+
cleanup();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses VERCEL_URL when KEY_PREFIX and BUILD_ID not available', () => {
|
|
86
|
+
const res = withEnv({ VERCEL_URL: 'vercel.example' }, () =>
|
|
87
|
+
resolveKeyPrefix({ optionKeyPrefix: undefined, env: process.env }),
|
|
88
|
+
);
|
|
89
|
+
expect(res).toBe('vercel.example');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('falls back to UNDEFINED_URL_ when nothing else available', () => {
|
|
93
|
+
const res = resolveKeyPrefix({
|
|
94
|
+
optionKeyPrefix: undefined,
|
|
95
|
+
env: process.env,
|
|
96
|
+
});
|
|
97
|
+
expect(res).toBe('UNDEFINED_URL_');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('treats empty env values as absent', () => {
|
|
101
|
+
const { serverDistDir, cleanup } = makeBuildIdTree('BIDX');
|
|
102
|
+
try {
|
|
103
|
+
const res = withEnv({ KEY_PREFIX: '', VERCEL_URL: '' }, () =>
|
|
104
|
+
resolveKeyPrefix({
|
|
105
|
+
optionKeyPrefix: undefined,
|
|
106
|
+
env: process.env,
|
|
107
|
+
serverDistDir,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
expect(res).toBe('BIDX');
|
|
111
|
+
} finally {
|
|
112
|
+
cleanup();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function readBuildId(serverDistDir?: string): string | undefined {
|
|
5
|
+
try {
|
|
6
|
+
if (serverDistDir) {
|
|
7
|
+
const buildIdPath = path.join(serverDistDir, '..', 'BUILD_ID');
|
|
8
|
+
const buildId = fs.readFileSync(buildIdPath, 'utf8').trim();
|
|
9
|
+
return buildId || undefined;
|
|
10
|
+
}
|
|
11
|
+
} catch {
|
|
12
|
+
// fall through to cwd-based read
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const fromCwd = path.join(process.cwd(), '.next', 'BUILD_ID');
|
|
16
|
+
const buildId = fs.readFileSync(fromCwd, 'utf8').trim();
|
|
17
|
+
return buildId || undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveKeyPrefix({
|
|
24
|
+
optionKeyPrefix,
|
|
25
|
+
serverDistDir,
|
|
26
|
+
env,
|
|
27
|
+
}: {
|
|
28
|
+
optionKeyPrefix?: string;
|
|
29
|
+
serverDistDir?: string;
|
|
30
|
+
env: NodeJS.ProcessEnv;
|
|
31
|
+
}): string {
|
|
32
|
+
// If the option is explicitly provided, honor it even if it's an empty string
|
|
33
|
+
if (optionKeyPrefix !== undefined) {
|
|
34
|
+
return optionKeyPrefix;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const keyPrefixEnv =
|
|
38
|
+
env.KEY_PREFIX && env.KEY_PREFIX.length > 0 ? env.KEY_PREFIX : undefined;
|
|
39
|
+
const vercelUrl =
|
|
40
|
+
env.VERCEL_URL && env.VERCEL_URL.length > 0 ? env.VERCEL_URL : undefined;
|
|
41
|
+
const buildId = readBuildId(serverDistDir);
|
|
42
|
+
|
|
43
|
+
return keyPrefixEnv ?? vercelUrl ?? buildId ?? 'UNDEFINED_URL_';
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
|
3
|
+
import { createClient, RedisClientType } from 'redis';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import fetch from 'node-fetch';
|
|
7
|
+
|
|
8
|
+
const NEXT_APP = 'next-app-15-4-7';
|
|
9
|
+
const PORT = 3075;
|
|
10
|
+
const BASE_URL = `http://localhost:${PORT}`;
|
|
11
|
+
|
|
12
|
+
async function waitForServer(url: string, timeout = 20000) {
|
|
13
|
+
const start = Date.now();
|
|
14
|
+
while (Date.now() - start < timeout) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(url + '/api/cached-static-fetch');
|
|
17
|
+
if (res.ok) return;
|
|
18
|
+
} catch {}
|
|
19
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
20
|
+
}
|
|
21
|
+
throw new Error('Next.js server did not start in time');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('BUILD_ID-based key prefix (Next handlers)', () => {
|
|
25
|
+
let nextProcess: ChildProcessWithoutNullStreams | undefined;
|
|
26
|
+
let redis: RedisClientType;
|
|
27
|
+
let appDir: string;
|
|
28
|
+
let buildId: string;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
appDir = path.join(__dirname, NEXT_APP);
|
|
32
|
+
|
|
33
|
+
// Ensure clean env so BUILD_ID precedence kicks in
|
|
34
|
+
delete (process.env as Record<string, string | undefined>).KEY_PREFIX;
|
|
35
|
+
delete (process.env as Record<string, string | undefined>).VERCEL_URL;
|
|
36
|
+
process.env.VERCEL_ENV = 'production';
|
|
37
|
+
process.env.REDISHOST = process.env.REDISHOST || 'localhost';
|
|
38
|
+
process.env.REDISPORT = process.env.REDISPORT || '6379';
|
|
39
|
+
|
|
40
|
+
// Install + build
|
|
41
|
+
await new Promise<void>((resolve, reject) => {
|
|
42
|
+
const p = spawn('pnpm', ['install'], { cwd: appDir, stdio: 'inherit' });
|
|
43
|
+
p.on('close', (code) =>
|
|
44
|
+
code === 0 ? resolve() : reject(new Error('pnpm i failed')),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
await new Promise<void>((resolve, reject) => {
|
|
48
|
+
const p = spawn('pnpm', ['build'], { cwd: appDir, stdio: 'inherit' });
|
|
49
|
+
p.on('close', (code) =>
|
|
50
|
+
code === 0 ? resolve() : reject(new Error('pnpm build failed')),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Read BUILD_ID from the built app
|
|
55
|
+
buildId = fs
|
|
56
|
+
.readFileSync(path.join(appDir, '.next', 'BUILD_ID'), 'utf8')
|
|
57
|
+
.trim();
|
|
58
|
+
|
|
59
|
+
// Start server without KEY_PREFIX/VERCEL_URL
|
|
60
|
+
nextProcess = spawn('npx', ['next', 'start', '-p', String(PORT)], {
|
|
61
|
+
cwd: appDir,
|
|
62
|
+
env: { ...process.env, SKIP_KEYSPACE_CONFIG_CHECK: 'true' },
|
|
63
|
+
stdio: 'pipe',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
nextProcess.stderr?.on(
|
|
67
|
+
'data',
|
|
68
|
+
(d) => process.env.DEBUG_INTEGRATION && console.error(String(d)),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await waitForServer(BASE_URL);
|
|
72
|
+
|
|
73
|
+
// Connect Redis
|
|
74
|
+
redis = createClient({
|
|
75
|
+
url: `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`,
|
|
76
|
+
});
|
|
77
|
+
await redis.connect();
|
|
78
|
+
}, 180000);
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
try {
|
|
82
|
+
if (redis) await redis.quit();
|
|
83
|
+
} finally {
|
|
84
|
+
if (nextProcess) nextProcess.kill();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('uses BUILD_ID as the Redis key prefix when no KEY_PREFIX/VERCEL_URL set', async () => {
|
|
89
|
+
// Warm cache twice to ensure a set occurs even if first was during startup
|
|
90
|
+
const res1 = await fetch(BASE_URL + '/api/cached-static-fetch');
|
|
91
|
+
expect(res1.ok).toBe(true);
|
|
92
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
93
|
+
const res2 = await fetch(BASE_URL + '/api/cached-static-fetch');
|
|
94
|
+
expect(res2.ok).toBe(true);
|
|
95
|
+
|
|
96
|
+
// Allow background syncs
|
|
97
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
98
|
+
|
|
99
|
+
const keys = await redis.keys(`${buildId}*`);
|
|
100
|
+
expect(keys.length).toBeGreaterThan(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
const config = {
|
|
2
|
-
plugins: {
|
|
3
|
-
"@tailwindcss/postcss": {},
|
|
4
|
-
},
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export default config;
|
|
1
|
+
const config = {
|
|
2
|
+
plugins: {
|
|
3
|
+
"@tailwindcss/postcss": {},
|
|
4
|
+
},
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default config;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
const config = {
|
|
2
|
-
plugins: {
|
|
3
|
-
"@tailwindcss/postcss": {},
|
|
4
|
-
},
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export default config;
|
|
1
|
+
const config = {
|
|
2
|
+
plugins: {
|
|
3
|
+
"@tailwindcss/postcss": {},
|
|
4
|
+
},
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default config;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
const config = {
|
|
2
|
-
plugins: {
|
|
3
|
-
"@tailwindcss/postcss": {},
|
|
4
|
-
},
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export default config;
|
|
1
|
+
const config = {
|
|
2
|
+
plugins: {
|
|
3
|
+
"@tailwindcss/postcss": {},
|
|
4
|
+
},
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default config;
|