| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- 'use client'
- import { components } from 'api-types'
- import { FlagValues } from 'flags/react'
- import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'
- import { useAuth } from './auth'
- import { getFlags as getDefaultConfigCatFlags } from './configcat'
- import { hasConsented } from './consent-state'
- import { get, post } from './fetchWrappers'
- import { ensurePlatformSuffix } from './helpers'
- import { useParams } from './hooks'
- type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBody']
- export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponse']
- export async function getFeatureFlags(
- API_URL: string,
- options: { organizationSlug?: string; projectRef?: string } = {}
- ) {
- try {
- const url = new URL(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`)
- if (options.organizationSlug) {
- url.searchParams.set('organization_slug', options.organizationSlug)
- }
- if (options.projectRef) {
- url.searchParams.set('project_ref', options.projectRef)
- }
- const data = await get(url.toString())
- return data as CallFeatureFlagsResponse
- } catch (error: any) {
- if (error.message.includes('Failed to fetch')) {
- console.error('Failed to fetch PH flags: API is not available')
- }
- throw error
- }
- }
- export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) {
- const consent = hasConsented()
- if (!consent) return undefined
- await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body })
- }
- export type FeatureFlagContextType = {
- API_URL?: string
- configcat: { [key: string]: boolean | number | string | null }
- posthog: CallFeatureFlagsResponse
- hasLoaded?: boolean
- }
- export const FeatureFlagContext = createContext<FeatureFlagContextType>({
- API_URL: undefined,
- configcat: {},
- posthog: {},
- hasLoaded: false,
- })
- function getCookies() {
- const pairs = document.cookie.split(';')
- let cookies: Record<string, string> = {}
- for (var i = 0; i < pairs.length; i++) {
- var [t_key, value] = pairs[i].split('=')
- const key = t_key.trim()
- cookies[key] = unescape(value)
- }
- return cookies
- }
- export const FeatureFlagProvider = ({
- API_URL,
- enabled = true,
- organizationSlug,
- projectRef,
- getConfigCatFlags,
- children,
- }: PropsWithChildren<{
- API_URL?: string
- /** Accepts either `boolean` which controls all feature flags or `{ cc: boolean, ph: boolean }` for individual providers */
- enabled?: boolean | { cc: boolean; ph: boolean }
- organizationSlug?: string
- projectRef?: string
- /** Custom fetcher for ConfigCat flags if passing in custom attributes */
- getConfigCatFlags?: (
- userEmail?: string
- ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]>
- }>) => {
- const { session, isLoading } = useAuth()
- const userEmail = session?.user?.email
- const params = useParams()
- const resolvedOrganizationSlug = organizationSlug ?? params.slug
- const resolvedProjectRef = projectRef ?? params.ref
- const lastSentGroupContextRef = useRef<string | null>(null)
- const [store, setStore] = useState<FeatureFlagContextType>({
- API_URL,
- configcat: {},
- posthog: {},
- hasLoaded: false,
- })
- useEffect(() => {
- let mounted = true
- async function ensureGroupContext() {
- if (!API_URL) return
- const userId = session?.user?.id
- if (!userId) return
- if (!hasConsented()) return
- if (!resolvedOrganizationSlug && !resolvedProjectRef) return
- const contextKey = [userId, resolvedOrganizationSlug ?? '', resolvedProjectRef ?? ''].join(
- '|'
- )
- if (lastSentGroupContextRef.current === contextKey) return
- try {
- await post(
- `${ensurePlatformSuffix(API_URL)}/telemetry/identify`,
- {
- user_id: userId,
- ...(resolvedOrganizationSlug && { organization_slug: resolvedOrganizationSlug }),
- ...(resolvedProjectRef && { project_ref: resolvedProjectRef }),
- },
- { headers: { Version: '2' } }
- )
- lastSentGroupContextRef.current = contextKey
- } catch {}
- }
- async function processFlags() {
- if (!enabled || isLoading) return
- const loadPHFlags =
- (enabled === true || (typeof enabled === 'object' && enabled.ph)) && !!API_URL
- const loadCCFlags = enabled === true || (typeof enabled === 'object' && enabled.cc)
- let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} }
- // Run both async operations in parallel — allSettled so a failure in one doesn't block the other
- const [phResult, ccResult] = await Promise.allSettled([
- loadPHFlags
- ? (async () => {
- await ensureGroupContext()
- return getFeatureFlags(API_URL, {
- organizationSlug: resolvedOrganizationSlug,
- projectRef: resolvedProjectRef,
- })
- })()
- : Promise.resolve({}),
- loadCCFlags
- ? typeof getConfigCatFlags === 'function'
- ? getConfigCatFlags(userEmail)
- : getDefaultConfigCatFlags(userEmail)
- : Promise.resolve([]),
- ])
- const flags = phResult.status === 'fulfilled' ? phResult.value : {}
- if (phResult.status === 'rejected') {
- console.warn('[FeatureFlags] PostHog flags failed', phResult.reason)
- }
- const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : []
- // Dev toolbar flag overrides are available in local dev and staging.
- // Duplicated for tree-shaking — bundler must see literal process.env reference.
- // Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx
- const env = process.env.NEXT_PUBLIC_ENVIRONMENT
- const isDevToolsEnabled = env === 'local' || env === 'staging'
- const safeParse = (value: string | undefined): Record<string, boolean | number | string> => {
- if (!value) return {}
- try {
- return JSON.parse(value)
- } catch {
- return {}
- }
- }
- // Process PostHog flags if loaded
- if (Object.keys(flags).length > 0) {
- // Apply dev toolbar overrides for PostHog flags
- if (isDevToolsEnabled) {
- try {
- const cookies = getCookies()
- const phOverrides = safeParse(cookies['x-ph-flag-overrides'])
- flagStore.posthog = { ...flags, ...phOverrides }
- } catch {
- flagStore.posthog = flags
- }
- } else {
- flagStore.posthog = flags
- }
- }
- // Process ConfigCat flags if loaded
- if (flagValues.length > 0) {
- let overridesCookieValue: Record<string, boolean | number | string> = {}
- try {
- const cookies = getCookies()
- // Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only)
- // x-cc-flag-overrides takes precedence when dev tools are enabled
- const vercelOverrides = safeParse(cookies['vercel-flag-overrides'])
- const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {}
- overridesCookieValue = {
- ...vercelOverrides,
- ...ccOverrides, // local overrides take precedence
- }
- } catch {}
- flagValues.forEach((item) => {
- flagStore['configcat'][item.settingKey] =
- overridesCookieValue[item.settingKey] ??
- (item.settingValue === null ? null : (item.settingValue ?? false))
- })
- }
- flagStore.hasLoaded = true
- if (mounted) {
- setStore(flagStore)
- }
- }
- // [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
- // as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
- // The polling downloads the config.json at the set interval and are stored in the internal cache
- // which subsequently all getValueAsync() calls are served from there
- processFlags()
- return () => {
- mounted = false
- }
- }, [
- enabled,
- isLoading,
- userEmail,
- API_URL,
- session?.user?.id,
- resolvedOrganizationSlug,
- resolvedProjectRef,
- getConfigCatFlags,
- ])
- return (
- <FeatureFlagContext.Provider value={store}>
- {/*
- [Joshen] Just support configcat flags in Vercel flags for now for simplicity
- although I think it should be fairly simply to support PH too
- */}
- <FlagValues values={store.configcat} />
- {children}
- </FeatureFlagContext.Provider>
- )
- }
- export const useFeatureFlags = () => {
- return useContext(FeatureFlagContext)
- }
- const isObjectEmpty = (obj: Object) => {
- return Object.keys(obj).length === 0
- }
- export function useFlag<T = boolean>(name: string) {
- const flagStore = useFeatureFlags()
- const store = flagStore.configcat
- // Flag store is empty means config cat is not loaded yet, return false
- if (isObjectEmpty(store)) {
- return false
- }
- if (store[name] === undefined) {
- console.error(`Flag key "${name}" does not exist in ConfigCat flag store`)
- return false
- }
- return store[name] as T
- }
|