Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
### ZeroBounce API key for email validation
# ZEROBOUNCE_API_KEY=

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

### Plain API key for customer support integration
# (Required if NEXT_PUBLIC_INCLUDE_REPORT_ISSUE=1)
Expand Down
5 changes: 1 addition & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ jobs:
runs-on: ubuntu-latest
needs: unit-tests
env:
KV_URL: redis://localhost:6379
KV_REST_API_READ_ONLY_TOKEN: test-read-only-token
KV_REST_API_TOKEN: test-api-token
KV_REST_API_URL: https://test-kv-api.example.com
REDIS_URL: redis://localhost:6379
SUPABASE_SERVICE_ROLE_KEY: test-service-role-key
BILLING_API_URL: https://billing.e2b-test.dev
NEXT_PUBLIC_E2B_DOMAIN: e2b-test.dev
Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,12 @@ cp .env.example .env.local
#### a. Key-Value Store Setup
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.

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:
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:
```
KV_REST_API_URL=your_redis_rest_api_url
KV_REST_API_TOKEN=your_redis_api_write_token
REDIS_URL=redis://localhost:6379
```

> **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.

> **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.
> **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.

6. Start the development server:
```bash
Expand Down
23 changes: 16 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@
"@trpc/tanstack-react-query": "^11.7.1",
"@types/micromatch": "^4.0.9",
"@vercel/analytics": "^1.5.0",
"@vercel/kv": "^3.0.0",
"@vercel/otel": "^1.13.0",
"@vercel/speed-insights": "^1.2.0",
"cheerio": "^1.0.0",
Expand Down Expand Up @@ -130,6 +129,7 @@
"react-icons": "^5.4.0",
"react-shiki": "^0.5.2",
"recharts": "^2.15.1",
"redis": "^5.12.1",
"semver": "^7.7.2",
"serialize-error": "^12.0.0",
"server-only": "^0.0.1",
Expand Down
7 changes: 0 additions & 7 deletions scripts/check-app-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,6 @@ loadEnvConfig(projectDir)

const schema = serverSchema
.merge(clientSchema)
.refine(
(data) => Boolean(data.KV_REST_API_URL) === Boolean(data.KV_REST_API_TOKEN),
{
message: 'KV_REST_API_URL and KV_REST_API_TOKEN must be set together',
path: ['KV_REST_API_URL'],
}
)
.refine(
(data) => {
if (data.NEXT_PUBLIC_INCLUDE_BILLING === '1') {
Expand Down
104 changes: 93 additions & 11 deletions src/core/shared/clients/kv.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { kv } from '@vercel/kv'
import 'server-only'
import { createClient } from 'redis'
import { l, serializeErrorForLog } from './logger/logger'

export type OptionalKvResult<T> =
| { ok: true; configured: true; value: T }
Expand All @@ -15,19 +17,86 @@ export type KvCapabilityStatus =
| { configured: true; available: true; status: 'ok' }
| { configured: true; available: false; status: 'error'; error: unknown }

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

if (!(hasUrl || hasToken)) {
let redisClient: RedisClient | null = null
let redisConnectPromise: Promise<RedisClient> | null = null

function isValidRedisUrl(url: string) {
try {
const parsedUrl = new URL(url)
return parsedUrl.protocol === 'redis:' || parsedUrl.protocol === 'rediss:'
} catch {
return false
}
}

function getKvConfigStatus(): KvConfigStatus {
const redisUrl = process.env.REDIS_URL

if (!redisUrl) {
return 'not_configured'
}

if (hasUrl && hasToken) {
return 'configured'
if (!isValidRedisUrl(redisUrl)) {
return 'misconfigured'
}

return 'misconfigured'
return 'configured'
}

function createRedisClient() {
const client = createClient({
url: process.env.REDIS_URL,
// Fail commands fast when the client isn't ready instead of queueing them
// behind the auto-reconnect. Preserves the {status: 'error'} envelope on
// pingKv() and keeps /api/health snappy during Redis outages.
disableOfflineQueue: true,
})

client.on('error', (error) => {
l.error(
{
key: 'redis_client:error',
error: serializeErrorForLog(error),
},
'Redis client error'
)
})

// When the socket fully ends (TCP close, retries exhausted, manual close),
// drop the cached singletons so the next getRedisClient() call rebuilds the
// client instead of returning the stale resolved promise. The identity guard
// protects against a late 'end' from a previous client clobbering a fresh one.
client.on('end', () => {
if (redisClient === client) {
redisClient = null
redisConnectPromise = null
}
})

return client
}

async function getRedisClient() {
if (redisClient?.isReady) {
return redisClient
}

if (!redisClient) {
redisClient = createRedisClient()
}

if (!redisConnectPromise) {
Comment thread
drankou marked this conversation as resolved.
redisConnectPromise = redisClient.connect().catch((error) => {
redisConnectPromise = null
redisClient = null
throw error
})
}

return redisConnectPromise
}

export function isKvConfigured() {
Expand All @@ -42,7 +111,8 @@ export async function pingKv(): Promise<KvCapabilityStatus> {
}

try {
await kv.ping()
const redis = await getRedisClient()
await redis.ping()
return { configured: true, available: true, status: 'ok' }
} catch (error) {
return { configured: true, available: false, status: 'error', error }
Expand All @@ -59,7 +129,14 @@ export async function getKvValue<T>(
}

try {
return { ok: true, configured: true, value: await kv.get<T>(key) }
const redis = await getRedisClient()
const value = await redis.get(key)

return {
ok: true,
configured: true,
value: value === null ? null : (JSON.parse(value) as T),
}
} catch (error) {
return { ok: false, configured: true, reason: 'error', error }
}
Expand All @@ -76,7 +153,12 @@ export async function setKvValue(
}

try {
return { ok: true, configured: true, value: await kv.set(key, value) }
const redis = await getRedisClient()
return {
ok: true,
configured: true,
value: await redis.set(key, JSON.stringify(value)),
}
} catch (error) {
return { ok: false, configured: true, reason: 'error', error }
}
Expand Down
9 changes: 7 additions & 2 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { z } from 'zod'

export const serverSchema = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
KV_REST_API_TOKEN: z.string().min(1).optional(),
KV_REST_API_URL: z.url().optional(),
REDIS_URL: z
.url()
.refine(
(url) => url.startsWith('redis://') || url.startsWith('rediss://'),
'REDIS_URL must use redis:// or rediss://'
)
.optional(),

ENABLE_USER_BOOTSTRAP: z.string().optional(),
DASHBOARD_API_ADMIN_TOKEN: z.string().min(1).optional(),
Expand Down
Loading
Loading