383 lines
9.6 KiB
TypeScript
383 lines
9.6 KiB
TypeScript
import { isEqual } from 'lodash-es'
|
|
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import { logError } from '../../utils/log.js'
|
|
import { createSignal } from '../../utils/signal.js'
|
|
|
|
type GrowthBookRefreshListener = () => void | Promise<void>
|
|
|
|
// Subscribers use this to react to local cache / override changes.
|
|
const refreshed = createSignal()
|
|
|
|
function callSafe(listener: GrowthBookRefreshListener): void {
|
|
try {
|
|
void Promise.resolve(listener()).catch(e => {
|
|
logError(e)
|
|
})
|
|
} catch (e) {
|
|
logError(e)
|
|
}
|
|
}
|
|
|
|
function hasAnyCachedGrowthBookFeatures(): boolean {
|
|
try {
|
|
return Object.keys(getGlobalConfig().cachedGrowthBookFeatures ?? {})
|
|
.length > 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a callback to fire when GrowthBook values change.
|
|
* This is now backed by local cache / override changes only.
|
|
*/
|
|
export function onGrowthBookRefresh(
|
|
listener: GrowthBookRefreshListener,
|
|
): () => void {
|
|
let subscribed = true
|
|
const unsubscribe = refreshed.subscribe(() => callSafe(listener))
|
|
|
|
if (hasAnyCachedGrowthBookFeatures()) {
|
|
queueMicrotask(() => {
|
|
if (subscribed) {
|
|
callSafe(listener)
|
|
}
|
|
})
|
|
}
|
|
|
|
return () => {
|
|
subscribed = false
|
|
unsubscribe()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse env var overrides for GrowthBook features.
|
|
* These bypass disk cache and are used by eval harnesses / tests.
|
|
*/
|
|
let envOverrides: Record<string, unknown> | null = null
|
|
let envOverridesParsed = false
|
|
|
|
function getEnvOverrides(): Record<string, unknown> | null {
|
|
if (!envOverridesParsed) {
|
|
envOverridesParsed = true
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
const raw = process.env.CLAUDE_INTERNAL_FC_OVERRIDES
|
|
if (raw) {
|
|
try {
|
|
envOverrides = JSON.parse(raw) as Record<string, unknown>
|
|
logForDebugging(
|
|
`GrowthBook: Using env var overrides for ${Object.keys(envOverrides).length} features: ${Object.keys(envOverrides).join(', ')}`,
|
|
)
|
|
} catch {
|
|
logError(
|
|
new Error(
|
|
`GrowthBook: Failed to parse CLAUDE_INTERNAL_FC_OVERRIDES: ${raw}`,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return envOverrides
|
|
}
|
|
|
|
/**
|
|
* Check if a feature has an env-var override (CLAUDE_INTERNAL_FC_OVERRIDES).
|
|
*/
|
|
export function hasGrowthBookEnvOverride(feature: string): boolean {
|
|
const overrides = getEnvOverrides()
|
|
return overrides !== null && feature in overrides
|
|
}
|
|
|
|
/**
|
|
* Local config overrides set via /config Gates tab (ant-only).
|
|
*/
|
|
function getConfigOverrides(): Record<string, unknown> | undefined {
|
|
if (process.env.USER_TYPE !== 'ant') return undefined
|
|
try {
|
|
return getGlobalConfig().growthBookOverrides
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function getCachedGrowthBookFeature<T>(feature: string): T | undefined {
|
|
try {
|
|
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
|
|
return cached !== undefined ? (cached as T) : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function getCachedStatsigGate(gate: string): boolean | undefined {
|
|
try {
|
|
const cached = getGlobalConfig().cachedStatsigGates?.[gate]
|
|
return cached !== undefined ? Boolean(cached) : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enumerate all known GrowthBook features from the local cache.
|
|
*/
|
|
export function getAllGrowthBookFeatures(): Record<string, unknown> {
|
|
try {
|
|
return getGlobalConfig().cachedGrowthBookFeatures ?? {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
export function getGrowthBookConfigOverrides(): Record<string, unknown> {
|
|
return getConfigOverrides() ?? {}
|
|
}
|
|
|
|
/**
|
|
* Set or clear a single config override. Pass undefined to clear.
|
|
*/
|
|
export function setGrowthBookConfigOverride(
|
|
feature: string,
|
|
value: unknown,
|
|
): void {
|
|
if (process.env.USER_TYPE !== 'ant') return
|
|
try {
|
|
saveGlobalConfig(c => {
|
|
const current = c.growthBookOverrides ?? {}
|
|
if (value === undefined) {
|
|
if (!(feature in current)) return c
|
|
const { [feature]: _, ...rest } = current
|
|
if (Object.keys(rest).length === 0) {
|
|
const { growthBookOverrides: __, ...configWithout } = c
|
|
return configWithout
|
|
}
|
|
return { ...c, growthBookOverrides: rest }
|
|
}
|
|
if (isEqual(current[feature], value)) return c
|
|
return { ...c, growthBookOverrides: { ...current, [feature]: value } }
|
|
})
|
|
refreshed.emit()
|
|
} catch (e) {
|
|
logError(e)
|
|
}
|
|
}
|
|
|
|
export function clearGrowthBookConfigOverrides(): void {
|
|
if (process.env.USER_TYPE !== 'ant') return
|
|
try {
|
|
saveGlobalConfig(c => {
|
|
if (
|
|
!c.growthBookOverrides ||
|
|
Object.keys(c.growthBookOverrides).length === 0
|
|
) {
|
|
return c
|
|
}
|
|
const { growthBookOverrides: _, ...rest } = c
|
|
return rest
|
|
})
|
|
refreshed.emit()
|
|
} catch (e) {
|
|
logError(e)
|
|
}
|
|
}
|
|
|
|
async function getFeatureValueInternal<T>(
|
|
feature: string,
|
|
defaultValue: T,
|
|
): Promise<T> {
|
|
const overrides = getEnvOverrides()
|
|
if (overrides && feature in overrides) {
|
|
return overrides[feature] as T
|
|
}
|
|
|
|
const configOverrides = getConfigOverrides()
|
|
if (configOverrides && feature in configOverrides) {
|
|
return configOverrides[feature] as T
|
|
}
|
|
|
|
const cached = getCachedGrowthBookFeature<T>(feature)
|
|
if (cached !== undefined) {
|
|
return cached
|
|
}
|
|
|
|
return defaultValue
|
|
}
|
|
|
|
/**
|
|
* GrowthBook is local-cache-only in this build.
|
|
* These no-op lifecycle helpers keep call sites stable.
|
|
*/
|
|
export async function initializeGrowthBook(): Promise<null> {
|
|
return null
|
|
}
|
|
|
|
export function refreshGrowthBookAfterAuthChange(): void {
|
|
refreshed.emit()
|
|
}
|
|
|
|
export function resetGrowthBook(): void {
|
|
envOverrides = null
|
|
envOverridesParsed = false
|
|
refreshed.emit()
|
|
}
|
|
|
|
/**
|
|
* Get a feature value with a default fallback.
|
|
*/
|
|
export async function getFeatureValue_DEPRECATED<T>(
|
|
feature: string,
|
|
defaultValue: T,
|
|
): Promise<T> {
|
|
return getFeatureValueInternal(feature, defaultValue)
|
|
}
|
|
|
|
/**
|
|
* Get a feature value from local cache immediately.
|
|
*/
|
|
export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
|
|
feature: string,
|
|
defaultValue: T,
|
|
): T {
|
|
const envOverride = getEnvOverrides()
|
|
if (envOverride && feature in envOverride) {
|
|
return envOverride[feature] as T
|
|
}
|
|
|
|
const configOverride = getConfigOverrides()
|
|
if (configOverride && feature in configOverride) {
|
|
return configOverride[feature] as T
|
|
}
|
|
|
|
const cached = getCachedGrowthBookFeature<T>(feature)
|
|
return cached !== undefined ? cached : defaultValue
|
|
}
|
|
|
|
/**
|
|
* Keep the old API shape; this now resolves from local cache only.
|
|
*/
|
|
export function getFeatureValue_CACHED_WITH_REFRESH<T>(
|
|
feature: string,
|
|
defaultValue: T,
|
|
_refreshIntervalMs: number,
|
|
): T {
|
|
return getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue)
|
|
}
|
|
|
|
/**
|
|
* Check a Statsig feature gate value via local GrowthBook cache, with fallback
|
|
* to Statsig's cached gates.
|
|
*/
|
|
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
|
gate: string,
|
|
): boolean {
|
|
const overrides = getEnvOverrides()
|
|
if (overrides && gate in overrides) {
|
|
return Boolean(overrides[gate])
|
|
}
|
|
|
|
const configOverrides = getConfigOverrides()
|
|
if (configOverrides && gate in configOverrides) {
|
|
return Boolean(configOverrides[gate])
|
|
}
|
|
|
|
const cached = getCachedGrowthBookFeature<boolean>(gate)
|
|
if (cached !== undefined) {
|
|
return Boolean(cached)
|
|
}
|
|
|
|
const statsigCached = getCachedStatsigGate(gate)
|
|
if (statsigCached !== undefined) {
|
|
return Boolean(statsigCached)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Check a security restriction gate using only local caches.
|
|
*/
|
|
export async function checkSecurityRestrictionGate(
|
|
gate: string,
|
|
): Promise<boolean> {
|
|
const overrides = getEnvOverrides()
|
|
if (overrides && gate in overrides) {
|
|
return Boolean(overrides[gate])
|
|
}
|
|
|
|
const configOverrides = getConfigOverrides()
|
|
if (configOverrides && gate in configOverrides) {
|
|
return Boolean(configOverrides[gate])
|
|
}
|
|
|
|
const cached = getCachedGrowthBookFeature<boolean>(gate)
|
|
if (cached !== undefined) {
|
|
return Boolean(cached)
|
|
}
|
|
|
|
const statsigCached = getCachedStatsigGate(gate)
|
|
if (statsigCached !== undefined) {
|
|
return Boolean(statsigCached)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Check a boolean entitlement gate with fallback-to-blocking semantics.
|
|
* In this build the slow path no longer blocks on network.
|
|
*/
|
|
export async function checkGate_CACHED_OR_BLOCKING(
|
|
gate: string,
|
|
): Promise<boolean> {
|
|
const overrides = getEnvOverrides()
|
|
if (overrides && gate in overrides) {
|
|
return Boolean(overrides[gate])
|
|
}
|
|
|
|
const configOverrides = getConfigOverrides()
|
|
if (configOverrides && gate in configOverrides) {
|
|
return Boolean(configOverrides[gate])
|
|
}
|
|
|
|
const cached = getCachedGrowthBookFeature<boolean>(gate)
|
|
if (cached !== undefined) {
|
|
return Boolean(cached)
|
|
}
|
|
|
|
const statsigCached = getCachedStatsigGate(gate)
|
|
if (statsigCached !== undefined) {
|
|
return Boolean(statsigCached)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ============================================================================
|
|
// Dynamic Config Functions
|
|
// These are semantic wrappers around feature functions for Statsig API parity.
|
|
// In GrowthBook, dynamic configs are just features with object values.
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get a dynamic config value - cached/local-override only.
|
|
*/
|
|
export async function getDynamicConfig_BLOCKS_ON_INIT<T>(
|
|
configName: string,
|
|
defaultValue: T,
|
|
): Promise<T> {
|
|
return getFeatureValue_DEPRECATED(configName, defaultValue)
|
|
}
|
|
|
|
/**
|
|
* Get a dynamic config value from local cache immediately.
|
|
*/
|
|
export function getDynamicConfig_CACHED_MAY_BE_STALE<T>(
|
|
configName: string,
|
|
defaultValue: T,
|
|
): T {
|
|
return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue)
|
|
}
|