feature-flags.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. 'use client'
  2. import { components } from 'api-types'
  3. import { FlagValues } from 'flags/react'
  4. import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'
  5. import { useAuth } from './auth'
  6. import { getFlags as getDefaultConfigCatFlags } from './configcat'
  7. import { hasConsented } from './consent-state'
  8. import { get, post } from './fetchWrappers'
  9. import { ensurePlatformSuffix } from './helpers'
  10. import { useParams } from './hooks'
  11. type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBody']
  12. export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponse']
  13. export async function getFeatureFlags(
  14. API_URL: string,
  15. options: { organizationSlug?: string; projectRef?: string } = {}
  16. ) {
  17. try {
  18. const url = new URL(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`)
  19. if (options.organizationSlug) {
  20. url.searchParams.set('organization_slug', options.organizationSlug)
  21. }
  22. if (options.projectRef) {
  23. url.searchParams.set('project_ref', options.projectRef)
  24. }
  25. const data = await get(url.toString())
  26. return data as CallFeatureFlagsResponse
  27. } catch (error: any) {
  28. if (error.message.includes('Failed to fetch')) {
  29. console.error('Failed to fetch PH flags: API is not available')
  30. }
  31. throw error
  32. }
  33. }
  34. export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) {
  35. const consent = hasConsented()
  36. if (!consent) return undefined
  37. await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body })
  38. }
  39. export type FeatureFlagContextType = {
  40. API_URL?: string
  41. configcat: { [key: string]: boolean | number | string | null }
  42. posthog: CallFeatureFlagsResponse
  43. hasLoaded?: boolean
  44. }
  45. export const FeatureFlagContext = createContext<FeatureFlagContextType>({
  46. API_URL: undefined,
  47. configcat: {},
  48. posthog: {},
  49. hasLoaded: false,
  50. })
  51. function getCookies() {
  52. const pairs = document.cookie.split(';')
  53. let cookies: Record<string, string> = {}
  54. for (var i = 0; i < pairs.length; i++) {
  55. var [t_key, value] = pairs[i].split('=')
  56. const key = t_key.trim()
  57. cookies[key] = unescape(value)
  58. }
  59. return cookies
  60. }
  61. export const FeatureFlagProvider = ({
  62. API_URL,
  63. enabled = true,
  64. organizationSlug,
  65. projectRef,
  66. getConfigCatFlags,
  67. children,
  68. }: PropsWithChildren<{
  69. API_URL?: string
  70. /** Accepts either `boolean` which controls all feature flags or `{ cc: boolean, ph: boolean }` for individual providers */
  71. enabled?: boolean | { cc: boolean; ph: boolean }
  72. organizationSlug?: string
  73. projectRef?: string
  74. /** Custom fetcher for ConfigCat flags if passing in custom attributes */
  75. getConfigCatFlags?: (
  76. userEmail?: string
  77. ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]>
  78. }>) => {
  79. const { session, isLoading } = useAuth()
  80. const userEmail = session?.user?.email
  81. const params = useParams()
  82. const resolvedOrganizationSlug = organizationSlug ?? params.slug
  83. const resolvedProjectRef = projectRef ?? params.ref
  84. const lastSentGroupContextRef = useRef<string | null>(null)
  85. const [store, setStore] = useState<FeatureFlagContextType>({
  86. API_URL,
  87. configcat: {},
  88. posthog: {},
  89. hasLoaded: false,
  90. })
  91. useEffect(() => {
  92. let mounted = true
  93. async function ensureGroupContext() {
  94. if (!API_URL) return
  95. const userId = session?.user?.id
  96. if (!userId) return
  97. if (!hasConsented()) return
  98. if (!resolvedOrganizationSlug && !resolvedProjectRef) return
  99. const contextKey = [userId, resolvedOrganizationSlug ?? '', resolvedProjectRef ?? ''].join(
  100. '|'
  101. )
  102. if (lastSentGroupContextRef.current === contextKey) return
  103. try {
  104. await post(
  105. `${ensurePlatformSuffix(API_URL)}/telemetry/identify`,
  106. {
  107. user_id: userId,
  108. ...(resolvedOrganizationSlug && { organization_slug: resolvedOrganizationSlug }),
  109. ...(resolvedProjectRef && { project_ref: resolvedProjectRef }),
  110. },
  111. { headers: { Version: '2' } }
  112. )
  113. lastSentGroupContextRef.current = contextKey
  114. } catch {}
  115. }
  116. async function processFlags() {
  117. if (!enabled || isLoading) return
  118. const loadPHFlags =
  119. (enabled === true || (typeof enabled === 'object' && enabled.ph)) && !!API_URL
  120. const loadCCFlags = enabled === true || (typeof enabled === 'object' && enabled.cc)
  121. let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} }
  122. // Run both async operations in parallel — allSettled so a failure in one doesn't block the other
  123. const [phResult, ccResult] = await Promise.allSettled([
  124. loadPHFlags
  125. ? (async () => {
  126. await ensureGroupContext()
  127. return getFeatureFlags(API_URL, {
  128. organizationSlug: resolvedOrganizationSlug,
  129. projectRef: resolvedProjectRef,
  130. })
  131. })()
  132. : Promise.resolve({}),
  133. loadCCFlags
  134. ? typeof getConfigCatFlags === 'function'
  135. ? getConfigCatFlags(userEmail)
  136. : getDefaultConfigCatFlags(userEmail)
  137. : Promise.resolve([]),
  138. ])
  139. const flags = phResult.status === 'fulfilled' ? phResult.value : {}
  140. if (phResult.status === 'rejected') {
  141. console.warn('[FeatureFlags] PostHog flags failed', phResult.reason)
  142. }
  143. const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : []
  144. // Dev toolbar flag overrides are available in local dev and staging.
  145. // Duplicated for tree-shaking — bundler must see literal process.env reference.
  146. // Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx
  147. const env = process.env.NEXT_PUBLIC_ENVIRONMENT
  148. const isDevToolsEnabled = env === 'local' || env === 'staging'
  149. const safeParse = (value: string | undefined): Record<string, boolean | number | string> => {
  150. if (!value) return {}
  151. try {
  152. return JSON.parse(value)
  153. } catch {
  154. return {}
  155. }
  156. }
  157. // Process PostHog flags if loaded
  158. if (Object.keys(flags).length > 0) {
  159. // Apply dev toolbar overrides for PostHog flags
  160. if (isDevToolsEnabled) {
  161. try {
  162. const cookies = getCookies()
  163. const phOverrides = safeParse(cookies['x-ph-flag-overrides'])
  164. flagStore.posthog = { ...flags, ...phOverrides }
  165. } catch {
  166. flagStore.posthog = flags
  167. }
  168. } else {
  169. flagStore.posthog = flags
  170. }
  171. }
  172. // Process ConfigCat flags if loaded
  173. if (flagValues.length > 0) {
  174. let overridesCookieValue: Record<string, boolean | number | string> = {}
  175. try {
  176. const cookies = getCookies()
  177. // Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only)
  178. // x-cc-flag-overrides takes precedence when dev tools are enabled
  179. const vercelOverrides = safeParse(cookies['vercel-flag-overrides'])
  180. const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {}
  181. overridesCookieValue = {
  182. ...vercelOverrides,
  183. ...ccOverrides, // local overrides take precedence
  184. }
  185. } catch {}
  186. flagValues.forEach((item) => {
  187. flagStore['configcat'][item.settingKey] =
  188. overridesCookieValue[item.settingKey] ??
  189. (item.settingValue === null ? null : (item.settingValue ?? false))
  190. })
  191. }
  192. flagStore.hasLoaded = true
  193. if (mounted) {
  194. setStore(flagStore)
  195. }
  196. }
  197. // [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
  198. // as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
  199. // The polling downloads the config.json at the set interval and are stored in the internal cache
  200. // which subsequently all getValueAsync() calls are served from there
  201. processFlags()
  202. return () => {
  203. mounted = false
  204. }
  205. }, [
  206. enabled,
  207. isLoading,
  208. userEmail,
  209. API_URL,
  210. session?.user?.id,
  211. resolvedOrganizationSlug,
  212. resolvedProjectRef,
  213. getConfigCatFlags,
  214. ])
  215. return (
  216. <FeatureFlagContext.Provider value={store}>
  217. {/*
  218. [Joshen] Just support configcat flags in Vercel flags for now for simplicity
  219. although I think it should be fairly simply to support PH too
  220. */}
  221. <FlagValues values={store.configcat} />
  222. {children}
  223. </FeatureFlagContext.Provider>
  224. )
  225. }
  226. export const useFeatureFlags = () => {
  227. return useContext(FeatureFlagContext)
  228. }
  229. const isObjectEmpty = (obj: Object) => {
  230. return Object.keys(obj).length === 0
  231. }
  232. export function useFlag<T = boolean>(name: string) {
  233. const flagStore = useFeatureFlags()
  234. const store = flagStore.configcat
  235. // Flag store is empty means config cat is not loaded yet, return false
  236. if (isObjectEmpty(store)) {
  237. return false
  238. }
  239. if (store[name] === undefined) {
  240. console.error(`Flag key "${name}" does not exist in ConfigCat flag store`)
  241. return false
  242. }
  243. return store[name] as T
  244. }