Skip to content

Commit e7cb01a

Browse files
committed
refactor: replace @vercel/kv with node-redis client
1 parent 71068a7 commit e7cb01a

9 files changed

Lines changed: 261 additions & 66 deletions

File tree

.env.example

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,9 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
3333
### ZeroBounce API key for email validation
3434
# ZEROBOUNCE_API_KEY=
3535

36-
### Optional KV database configuration
3736
### Required only for KV-backed capabilities such as alternate-email warning dedupe.
38-
### Must be Vercel/Upstash Redis REST compatible; raw redis://localhost:6379 is not supported by @vercel/kv.
39-
# KV_REST_API_TOKEN=
40-
# KV_REST_API_URL=
37+
### Use redis:// for local/self-hosted Redis or rediss:// for TLS-enabled Redis.
38+
# REDIS_URL=redis://localhost:6379
4139

4240
### Plain API key for customer support integration
4341
# (Required if NEXT_PUBLIC_INCLUDE_REPORT_ISSUE=1)

.github/workflows/test.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,7 @@ jobs:
4545
runs-on: ubuntu-latest
4646
needs: unit-tests
4747
env:
48-
KV_URL: redis://localhost:6379
49-
KV_REST_API_READ_ONLY_TOKEN: test-read-only-token
50-
KV_REST_API_TOKEN: test-api-token
51-
KV_REST_API_URL: https://test-kv-api.example.com
48+
REDIS_URL: redis://localhost:6379
5249
SUPABASE_SERVICE_ROLE_KEY: test-service-role-key
5350
BILLING_API_URL: https://billing.e2b-test.dev
5451
NEXT_PUBLIC_E2B_DOMAIN: e2b-test.dev

README.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,12 @@ cp .env.example .env.local
105105
#### a. Key-Value Store Setup
106106
Redis/KV is optional for standard dashboard deployments, including local, enterprise, and on-prem environments. The dashboard can boot and run core auth and dashboard workflows without KV configured.
107107

108-
KV is currently used for optional capability checks and for deduplicating ZeroBounce alternate-email warnings. If you need those capabilities, configure a Vercel/Upstash Redis REST-compatible store:
108+
KV is currently used for optional capability checks and for deduplicating ZeroBounce alternate-email warnings. If you need those capabilities, configure a Redis-compatible store:
109109
```
110-
KV_REST_API_URL=your_redis_rest_api_url
111-
KV_REST_API_TOKEN=your_redis_api_write_token
110+
REDIS_URL=redis://localhost:6379
112111
```
113112

114-
> **Note**: `@vercel/kv` expects a Redis REST API. A raw Redis server such as `redis://localhost:6379` is not compatible without an Upstash-compatible REST proxy.
115-
116-
> **Health check**: When `KV_REST_API_URL` and `KV_REST_API_TOKEN` are set, `/api/health` will report `503 degraded` if KV is unreachable. Leave both unset to opt out of the KV health check entirely.
113+
> **Health check**: When `REDIS_URL` is set, `/api/health` will report `503 degraded` if Redis is unreachable. Leave it unset to opt out of the Redis health check entirely.
117114
118115
6. Start the development server:
119116
```bash

bun.lock

Lines changed: 16 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@
9595
"@trpc/tanstack-react-query": "^11.7.1",
9696
"@types/micromatch": "^4.0.9",
9797
"@vercel/analytics": "^1.5.0",
98-
"@vercel/kv": "^3.0.0",
9998
"@vercel/otel": "^1.13.0",
10099
"@vercel/speed-insights": "^1.2.0",
101100
"cheerio": "^1.0.0",
@@ -130,6 +129,7 @@
130129
"react-icons": "^5.4.0",
131130
"react-shiki": "^0.5.2",
132131
"recharts": "^2.15.1",
132+
"redis": "^5.12.1",
133133
"semver": "^7.7.2",
134134
"serialize-error": "^12.0.0",
135135
"server-only": "^0.0.1",

scripts/check-app-env.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,6 @@ loadEnvConfig(projectDir)
66

77
const schema = serverSchema
88
.merge(clientSchema)
9-
.refine(
10-
(data) => Boolean(data.KV_REST_API_URL) === Boolean(data.KV_REST_API_TOKEN),
11-
{
12-
message: 'KV_REST_API_URL and KV_REST_API_TOKEN must be set together',
13-
path: ['KV_REST_API_URL'],
14-
}
15-
)
169
.refine(
1710
(data) => {
1811
if (data.NEXT_PUBLIC_INCLUDE_BILLING === '1') {

src/core/shared/clients/kv.ts

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { kv } from '@vercel/kv'
1+
import 'server-only'
2+
import { createClient } from 'redis'
3+
import { l, serializeErrorForLog } from './logger/logger'
24

35
export type OptionalKvResult<T> =
46
| { ok: true; configured: true; value: T }
@@ -15,19 +17,71 @@ export type KvCapabilityStatus =
1517
| { configured: true; available: true; status: 'ok' }
1618
| { configured: true; available: false; status: 'error'; error: unknown }
1719

18-
function getKvConfigStatus() {
19-
const hasUrl = Boolean(process.env.KV_REST_API_URL)
20-
const hasToken = Boolean(process.env.KV_REST_API_TOKEN)
20+
type KvConfigStatus = 'not_configured' | 'misconfigured' | 'configured'
21+
type RedisClient = ReturnType<typeof createClient>
2122

22-
if (!(hasUrl || hasToken)) {
23+
let redisClient: RedisClient | null = null
24+
let redisConnectPromise: Promise<RedisClient> | null = null
25+
26+
function isValidRedisUrl(url: string) {
27+
try {
28+
const parsedUrl = new URL(url)
29+
return parsedUrl.protocol === 'redis:' || parsedUrl.protocol === 'rediss:'
30+
} catch {
31+
return false
32+
}
33+
}
34+
35+
function getKvConfigStatus(): KvConfigStatus {
36+
const redisUrl = process.env.REDIS_URL
37+
38+
if (!redisUrl) {
2339
return 'not_configured'
2440
}
2541

26-
if (hasUrl && hasToken) {
27-
return 'configured'
42+
if (!isValidRedisUrl(redisUrl)) {
43+
return 'misconfigured'
2844
}
2945

30-
return 'misconfigured'
46+
return 'configured'
47+
}
48+
49+
function createRedisClient() {
50+
const client = createClient({
51+
url: process.env.REDIS_URL,
52+
})
53+
54+
client.on('error', (error) => {
55+
l.error(
56+
{
57+
key: 'redis_client:error',
58+
error: serializeErrorForLog(error),
59+
},
60+
'Redis client error'
61+
)
62+
})
63+
64+
return client
65+
}
66+
67+
async function getRedisClient() {
68+
if (redisClient?.isReady) {
69+
return redisClient
70+
}
71+
72+
if (!redisClient) {
73+
redisClient = createRedisClient()
74+
}
75+
76+
if (!redisConnectPromise) {
77+
redisConnectPromise = redisClient.connect().catch((error) => {
78+
redisConnectPromise = null
79+
redisClient = null
80+
throw error
81+
})
82+
}
83+
84+
return redisConnectPromise
3185
}
3286

3387
export function isKvConfigured() {
@@ -42,7 +96,8 @@ export async function pingKv(): Promise<KvCapabilityStatus> {
4296
}
4397

4498
try {
45-
await kv.ping()
99+
const redis = await getRedisClient()
100+
await redis.ping()
46101
return { configured: true, available: true, status: 'ok' }
47102
} catch (error) {
48103
return { configured: true, available: false, status: 'error', error }
@@ -59,7 +114,14 @@ export async function getKvValue<T>(
59114
}
60115

61116
try {
62-
return { ok: true, configured: true, value: await kv.get<T>(key) }
117+
const redis = await getRedisClient()
118+
const value = await redis.get(key)
119+
120+
return {
121+
ok: true,
122+
configured: true,
123+
value: value === null ? null : (JSON.parse(value) as T),
124+
}
63125
} catch (error) {
64126
return { ok: false, configured: true, reason: 'error', error }
65127
}
@@ -76,7 +138,12 @@ export async function setKvValue(
76138
}
77139

78140
try {
79-
return { ok: true, configured: true, value: await kv.set(key, value) }
141+
const redis = await getRedisClient()
142+
return {
143+
ok: true,
144+
configured: true,
145+
value: await redis.set(key, JSON.stringify(value)),
146+
}
80147
} catch (error) {
81148
return { ok: false, configured: true, reason: 'error', error }
82149
}

src/lib/env.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { z } from 'zod'
22

33
export const serverSchema = z.object({
44
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
5-
KV_REST_API_TOKEN: z.string().min(1).optional(),
6-
KV_REST_API_URL: z.url().optional(),
5+
REDIS_URL: z
6+
.url()
7+
.refine(
8+
(url) => url.startsWith('redis://') || url.startsWith('rediss://'),
9+
'REDIS_URL must use redis:// or rediss://'
10+
)
11+
.optional(),
712

813
ENABLE_USER_BOOTSTRAP: z.string().optional(),
914
DASHBOARD_API_ADMIN_TOKEN: z.string().min(1).optional(),

0 commit comments

Comments
 (0)