Skip to content

Commit 8924ffa

Browse files
Launch Darkly Support for Frontend (#408)
1 parent 194cafd commit 8924ffa

12 files changed

Lines changed: 581 additions & 0 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev
5353
### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1)
5454
# BILLING_API_URL=https://billing.e2b.dev
5555

56+
### LaunchDarkly server-side SDK key for server-evaluated feature flags
57+
# LAUNCHDARKLY_SDK_KEY=
58+
5659
### Vercel URL (automatically set in Vercel deployments)
5760
# VERCEL_URL=
5861

bun.lock

Lines changed: 15 additions & 0 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
},
4848
"dependencies": {
4949
"@hookform/resolvers": "^5.2.2",
50+
"@launchdarkly/node-server-sdk": "^9.11.2",
5051
"@marsidev/react-turnstile": "^1.4.1",
5152
"@next-safe-action/adapter-react-hook-form": "^2.0.0",
5253
"@next/env": "^16.2.7",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { notFound, redirect } from 'next/navigation'
2+
import { AUTH_URLS } from '@/configs/urls'
3+
import { auth } from '@/core/server/auth'
4+
import { listFeatureFlags } from '@/core/server/feature-flags/list.server'
5+
import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug'
6+
import { FeatureFlagsTable } from '@/features/dashboard/flags/feature-flags'
7+
import { Page } from '@/features/dashboard/layouts/page'
8+
9+
interface FlagsPageProps {
10+
params: Promise<{
11+
teamSlug: string
12+
}>
13+
}
14+
15+
export default async function FlagsPage({ params }: FlagsPageProps) {
16+
const [{ teamSlug }, authContext] = await Promise.all([
17+
params,
18+
auth.getAuthContext(),
19+
])
20+
21+
if (!authContext) {
22+
redirect(AUTH_URLS.SIGN_IN)
23+
}
24+
25+
const teamIdResult = await getTeamIdFromSlug(
26+
teamSlug,
27+
authContext.accessToken
28+
)
29+
30+
if (!teamIdResult.ok || !teamIdResult.data) {
31+
notFound()
32+
}
33+
34+
const context = {
35+
userId: authContext.user.id,
36+
teamId: teamIdResult.data,
37+
}
38+
const flags = await listFeatureFlags(context)
39+
const isAdmin = flags.some((flag) => flag.id === 'isAdmin' && flag.value)
40+
41+
if (!isAdmin) {
42+
notFound()
43+
}
44+
45+
return (
46+
<Page>
47+
<FeatureFlagsTable flags={flags} teamId={teamIdResult.data} />
48+
</Page>
49+
)
50+
}

src/configs/flags.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { z } from 'zod'
2+
13
export const ALLOW_SEO_INDEXING = process.env.ALLOW_SEO_INDEXING === '1'
24
export const VERBOSE = process.env.NEXT_PUBLIC_VERBOSE === '1'
35
export const ENABLE_USER_BOOTSTRAP = process.env.ENABLE_USER_BOOTSTRAP === '1'
@@ -38,3 +40,37 @@ export function isAuthMigrationInProgress() {
3840
}
3941

4042
export const AUTH_MIGRATION_IN_PROGRESS = isAuthMigrationInProgress()
43+
44+
export type BooleanFeatureFlagDefinition = {
45+
kind: 'boolean'
46+
key: string
47+
defaultValue: boolean
48+
description?: string
49+
}
50+
51+
export type JsonFeatureFlagDefinition<T> = {
52+
kind: 'json'
53+
key: string
54+
defaultValue: T
55+
schema: z.ZodType<T>
56+
description?: string
57+
}
58+
59+
export type FeatureFlagDefinition =
60+
| BooleanFeatureFlagDefinition
61+
| JsonFeatureFlagDefinition<unknown>
62+
63+
export const FEATURE_FLAGS = {
64+
isAdmin: {
65+
kind: 'boolean',
66+
key: 'is_admin',
67+
defaultValue: false,
68+
description: 'Enables dashboard admin-only surfaces.',
69+
},
70+
iExist: {
71+
kind: 'boolean',
72+
key: 'i_exist',
73+
defaultValue: false,
74+
description: 'Test flag for validating LaunchDarkly team targeting.',
75+
},
76+
} as const satisfies Record<string, FeatureFlagDefinition>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type FeatureFlagContextInput = {
2+
userId: string
3+
teamId?: string
4+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import 'server-only'
2+
3+
import type {
4+
BooleanFeatureFlagDefinition,
5+
JsonFeatureFlagDefinition,
6+
} from '@/configs/flags'
7+
import type { FeatureFlagContextInput } from '@/core/server/feature-flags/context'
8+
import { launchDarklyFeatureFlagProvider } from '@/core/server/feature-flags/launchdarkly'
9+
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
10+
11+
export type FeatureFlagProvider = {
12+
getBoolean(
13+
flag: BooleanFeatureFlagDefinition,
14+
context: FeatureFlagContextInput
15+
): Promise<boolean>
16+
getJson<T>(
17+
flag: JsonFeatureFlagDefinition<T>,
18+
context: FeatureFlagContextInput
19+
): Promise<unknown>
20+
}
21+
22+
export type FeatureFlagService = {
23+
getBoolean(
24+
flag: BooleanFeatureFlagDefinition,
25+
context: FeatureFlagContextInput
26+
): Promise<boolean>
27+
getJson<T>(
28+
flag: JsonFeatureFlagDefinition<T>,
29+
context: FeatureFlagContextInput
30+
): Promise<T>
31+
}
32+
33+
export function createFeatureFlagService(
34+
provider: FeatureFlagProvider = launchDarklyFeatureFlagProvider
35+
): FeatureFlagService {
36+
return {
37+
async getBoolean(flag, context) {
38+
return provider.getBoolean(flag, context)
39+
},
40+
async getJson(flag, context) {
41+
const value = await provider.getJson(flag, context)
42+
const parsed = flag.schema.safeParse(value)
43+
44+
if (!parsed.success) {
45+
l.warn(
46+
{
47+
key: 'feature_flags:invalid_json_flag',
48+
context: { flagKey: flag.key },
49+
error: serializeErrorForLog(parsed.error),
50+
},
51+
'Feature flag JSON value has invalid shape'
52+
)
53+
54+
return flag.defaultValue
55+
}
56+
57+
return parsed.data
58+
},
59+
}
60+
}
61+
62+
export const featureFlags = createFeatureFlagService()

0 commit comments

Comments
 (0)