Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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 @@ -63,11 +63,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 @@ -90,15 +90,12 @@ cp .env.example .env.local
#### c. Key-Value Store Setup (Optional)
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.

#### d. 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 @@ -91,7 +91,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": "^2.1.2",
"@vercel/speed-insights": "^1.2.0",
"@xterm/xterm": "^6.0.0",
Expand Down Expand Up @@ -128,6 +127,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 @@ -24,13 +24,6 @@ const oryRequiredEnvVars = [

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', 'KV_REST_API_TOKEN'],
}
)
.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) {
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